diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 98da5e9a..2eaa06ed 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -1,12 +1,14 @@ # tests/unit/core/level_shop_economy_test.gd — 关卡图与 GP 商店单元测试宿主 # 断言面 = 七套(批 B 断言迁移自 smoke_all.gd,G8 施工组): -# 关卡节点地图·线性推进(满配通关/恢复点公式/败北线/回放确定性/属性覆盖恢复) +# 关卡节点图·生成图推进(满配通关/恢复点公式/败北线/回放确定性/属性覆盖恢复) # 分支节点图(图校验正反/L2·L3 行进与行集定点/混编运行面/真图类型可见性) # GP 冒险钱包·到账(掷定窗口/关级加值) # escrow 隔离(格面兑付入缓冲/破财直验/钱包钳制不透支) # 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零) -# 商店房图不变式(恰 1 商店房/全路径必经/跨表行集迁移) +# 商店编排性质(每终点前一位恰一店/店数=终点数/全路径恰穿一店) # 商店共享池(抽 3 买 1/乘法定价/跨渠道共库/断点扩键/终局池读数) +# 节点图源 = 参数档 × 种子的生成图——图侧断言一律经 _generated_graph 单点取数 +# (与 LevelRunner._assign_generated_graph 同参数档表、同产图路径),形状断言只写性质。 # 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, # ConfigDB 显式实例化构造注入(_fresh_rng 同种子隔离夹具——RngService 构造注入 runner); # 表加载失败不中止(沿被迁断言面的源语义——承 G2–G6 同款)。 @@ -15,8 +17,218 @@ extends SceneTree const Kit := preload("res://tests/unit/test_kit.gd") const ConfigDBScript := preload("res://core/config_db.gd") +const NodeMapGenScript := preload("res://core/node_map_generator.gd") + +# 现行首局种子(图侧断言的固定取图种子——与组合根首次冒险同一种子承载) +const ACTIVE_GRAPH_SEED := 42 +# 覆盖与编排断言的扫描种子面(含固定取图种子 42) +const SWEEP_SEED_FROM := 42 +const SWEEP_SEED_TO := 62 var _db +var _graph_gen = null # 生成图取数单实例(图 = f(参数档, 种子) 纯函数,复现与计数器位次无关) + + +# ---- 生成图取数面(本文件唯一实现——图侧断言一律经此,禁另起第二份取数)---- + +# 参数档 × 种子产图(与 LevelRunner._assign_generated_graph 同一产图路径与同一参数档表) +func _generated_graph(level_index: int, seed_val: int) -> Dictionary: + var params: Dictionary = _db.level_gen_params_row(level_index) + if params.is_empty(): + return {} + if _graph_gen == null: + _graph_gen = NodeMapGenScript.new(_fresh_rng(ACTIVE_GRAPH_SEED)) + var graph: Dictionary = _graph_gen.generate(params, seed_val) + if graph.is_empty() or _graph_gen.is_fatal(): + return {} + return graph + + +# 该关现行起关图(固定种子+该关参数档)——节点数组,句柄/深度/出边读面见节点字典 +func _level_active_nodes(level_index: int) -> Array: + return _generated_graph(level_index, ACTIVE_GRAPH_SEED).get("nodes", []) + + +# 句柄 → 节点(图内唯一句柄解析) +func _nodes_by_id(nodes: Array) -> Dictionary: + var out := {} + for n in nodes: + out[int(n.get("id", -1))] = n + return out + + +# 入口句柄集(无入边节点——个数 = 参数档 entry_count) +func _entry_ids(nodes: Array) -> Array: + var inbound := {} + for n in nodes: + for tid in (n.get("next", []) as Array): + inbound[int(tid)] = true + var out: Array = [] + for n in nodes: + if not inbound.has(int(n.get("id", -1))): + out.append(int(n.get("id", -1))) + return out + + +# 终点句柄集(无出边节点——恒 Boss,个数 = 参数档 exit_count) +func _terminal_ids(nodes: Array) -> Array: + var out: Array = [] + for n in nodes: + if (n.get("next", []) as Array).is_empty(): + out.append(int(n.get("id", -1))) + return out + + +# 指定型别句柄集(岔口=出度 ≥2 另走 _fork_ids) +func _ids_of_type(nodes: Array, type_name: String) -> Array: + var out: Array = [] + for n in nodes: + if String(n.get("type", "")) == type_name: + out.append(int(n.get("id", -1))) + return out + + +# 岔口句柄集(出度 ≥2 的节点——「真实岔路」现行定义) +func _fork_ids(nodes: Array) -> Array: + var out: Array = [] + for n in nodes: + if (n.get("next", []) as Array).size() >= 2: + out.append(int(n.get("id", -1))) + return out + + +# 逐节点型别计数(五型合计 = 节点数——图形状读数的唯一计数口径) +func _type_tally(nodes: Array) -> Dictionary: + var out := {"battle": 0, "boss": 0, "reward": 0, "rest": 0, "shop": 0} + for n in nodes: + var t := String(n.get("type", "")) + if out.has(t): + out[t] = int(out[t]) + 1 + return out + + +# 序列去重(保持首现序——句柄集与型别集通用) +func _uniq(values: Array) -> Array: + var out: Array = [] + for v in values: + if not out.has(v): + out.append(v) + return out + + +# 句柄序升序(候选集与图出边比对用——集合相等判据) +func _sorted_ids(ids: Array) -> Array: + var out: Array = [] + for v in ids: + out.append(int(v)) + out.sort() + return out + + +# 路径上商店节点计数(句柄序列经图解析——「恰穿一店」判据的行进面与枚举面共用口径) +func _path_shop_count(nodes: Array, path: Array) -> int: + var by_id := _nodes_by_id(nodes) + var count := 0 + for vid in path: + if String(by_id.get(int(vid), {}).get("type", "")) == "shop": + count += 1 + return count + + +# 终点深度升序表(多终点图的终点深度域——Boss 行集与带互异判据的取数面) +func _terminal_depths(nodes: Array) -> Array: + var by_id := _nodes_by_id(nodes) + var out: Array = [] + for tid in _terminal_ids(nodes): + out.append(int(by_id.get(int(tid), {}).get("depth", -1))) + out.sort() + return out + + +# 入口 BFS 层表(句柄 → 层深:入口层 0、出边逐层 +1、首达即定层——与 runner 可见性锚层同源口径) +func _bfs_layers(nodes: Array) -> Dictionary: + var by_id := _nodes_by_id(nodes) + var layers := {} + var queue: Array = [] + for eid in _entry_ids(nodes): + layers[eid] = 0 + queue.append(eid) + var i := 0 + while i < queue.size(): + var cur_id := int(queue[i]) + i += 1 + var layer := int(layers[cur_id]) + for oid in (by_id.get(cur_id, {}).get("next", []) as Array): + if not layers.has(int(oid)): + layers[int(oid)] = layer + 1 + queue.append(int(oid)) + return layers + + +# 跨表覆盖扫描(敌侧落带 ≥ 请求深度+板侧精确命中):返回 {nodes 扫描读数, enemy_bad, board_bad} +func _coverage_scan(level_index: int, nodes: Array) -> Dictionary: + var scanned := 0 + var enemy_bad := 0 + var board_bad := 0 + for n in nodes: + var d: int = int(n.get("depth", -1)) + if not _board_row_exact(level_index, d): + board_bad += 1 + var t := String(n.get("type", "")) + if t == "battle" or t == "boss": + scanned += 1 + if not _enemy_band_covers(level_index, d, "boss" if t == "boss" else "battle"): + enemy_bad += 1 + return {"nodes": scanned, "enemy_bad": enemy_bad, "board_bad": board_bad} + + +# 入口→终点全路径枚举(DFS 迭代栈——路径为句柄序;生成图无环且全员可达终点,路径有限) +func _all_entry_terminal_paths(nodes: Array) -> Array: + var by_id := _nodes_by_id(nodes) + var paths: Array = [] + var stack: Array = [] + for eid in _entry_ids(nodes): + stack.append([eid]) + while not stack.is_empty(): + var path: Array = stack.pop_back() + var cur: Dictionary = by_id[int(path[path.size() - 1])] + var outs: Array = cur.get("next", []) + if outs.is_empty(): + paths.append(path) + continue + for t in outs: + var next_path: Array = path.duplicate() + next_path.append(int(t)) + stack.append(next_path) + return paths + + +# 路径逐跳是否为图出边(含首节点为入口、末节点为终点) +func _path_in_graph(nodes: Array, path: Array) -> bool: + if path.is_empty(): + return false + var by_id := _nodes_by_id(nodes) + if not _entry_ids(nodes).has(int(path[0])): + return false + for i in range(path.size() - 1): + if not by_id.has(int(path[i])): + return false + if not (by_id[int(path[i])].get("next", []) as Array).has(int(path[i + 1])): + return false + var last: Dictionary = by_id.get(int(path[path.size() - 1]), {}) + return not last.is_empty() and (last.get("next", []) as Array).is_empty() + + +# 敌侧覆盖判据:请求深度经 band_of 落带(非 -1)——解析落带 ≥ 请求深度即等价判据(禁顶 clamp 消解) +func _enemy_band_covers(level_index: int, depth: int, kind: String) -> bool: + var rows: Array[Dictionary] = _db.resolve_enemy_rowset(level_index, depth, kind) + return not rows.is_empty() and int(rows[0].get("depth_band", -1)) >= depth + + +# 板侧覆盖判据:该深度在 board_gen 精确命中(node_depth 等值) +func _board_row_exact(level_index: int, depth: int) -> bool: + var board: Dictionary = _db.resolve_board_gen(level_index, depth) + return not board.is_empty() and int(board.get("node_depth", -1)) == depth func _init() -> void: @@ -45,84 +257,112 @@ func _init() -> void: # 敌侧/板侧解析一律走 ConfigDB 真实消费链,禁直读行集自算)---- func _smoke_generated_coverage() -> void: - var RngScript := preload("res://core/rng_service.gd") - var GenScript := preload("res://core/node_map_generator.gd") - var gen = GenScript.new(RngScript.new()) var enemy_ok := true var board_ok := true var boss_band_ok := true - var readings := [] + var scanned := 0 + var readings: Array = [] for L in [1, 2, 3]: - var params: Dictionary = _db.level_gen_params_row(L) - if params.is_empty(): + if _db.level_gen_params_row(L).is_empty(): Kit.check("L%d 参数档缺行(覆盖断言无源可扫)〔v11〕" % L, false) continue var dmin := 999 var dmax := 0 - for seed_val in range(42, 62): - var g: Dictionary = gen.generate(params, seed_val) - var by_id := {} - for n in g["nodes"]: - by_id[int(n["id"])] = n - for n in g["nodes"]: - var d: int = int(n["depth"]) + for seed_val in range(SWEEP_SEED_FROM, SWEEP_SEED_TO): + var nodes: Array = _generated_graph(L, seed_val).get("nodes", []) + if nodes.is_empty(): + Kit.check("L%d 种子 %d 生成图为空(覆盖扫描面空转)〔v11〕" % [L, seed_val], false) + continue + var by_id := _nodes_by_id(nodes) + for n in nodes: + var d: int = int(n.get("depth", -1)) + var kind := String(n.get("type", "")) dmin = mini(dmin, d) dmax = maxi(dmax, d) - var kind := String(n["type"]) - if kind == "battle" or kind == "boss": - var rk := "boss" if kind == "boss" else "battle" - var rs0: Array[Dictionary] = _db.resolve_enemy_rowset(L, d, rk) - # 判据=解析落带 ≥ 请求深度(等价 EnemyTable.band_of 非 -1)——行集非空不是覆盖判据, - # 越顶时 resolver 会 clamp 到顶带照样返回非空行(顶 clamp 消解正是设计禁的) - if rs0.is_empty() or int(rs0[0].get("depth_band", -1)) < d: - enemy_ok = false - var board: Dictionary = _db.resolve_board_gen(L, d) - if board.is_empty() or int(board.get("node_depth", -1)) != d: + scanned += 1 + # 判据=解析落带 ≥ 请求深度(等价 EnemyTable.band_of 非 -1)——行集非空不是覆盖判据, + # 越顶时 resolver 会 clamp 到顶带照样返回非空行(顶 clamp 消解正是设计禁的) + if kind == "battle" and not _enemy_band_covers(L, d, "battle"): + enemy_ok = false + elif kind == "boss" and not _enemy_band_covers(L, d, "boss"): + enemy_ok = false + if not _board_row_exact(L, d): board_ok = false - var bands := [] - for tid in g["terminals"]: - var bd: int = int(by_id[int(tid)]["depth"]) - var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, bd, "boss") + var bands: Array = [] + for tid in _terminal_ids(nodes): + var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, int(by_id[int(tid)].get("depth", -1)), "boss") bands.append(int(rs[0].get("depth_band", -1)) if not rs.is_empty() else -1) for i in range(bands.size()): for j in range(i + 1, bands.size()): if bands[i] == bands[j]: boss_band_ok = false readings.append("L%d 深度域 %d–%d" % [L, dmin, dmax]) - print("[v11] 生成图覆盖量程读数:" + "; ".join(readings)) + print("[v11] 生成图覆盖量程读数:%s(扫描节点读数 %d)" % ["; ".join(readings), scanned]) + Kit.check("生成图覆盖扫描面非空(三关参数档×种子 %d–%d 共 %d 个节点读数)〔v11〕" % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, scanned], scanned > 0) Kit.check("生成图每个 battle/boss 节点深度的敌侧解析落带 ≥ 请求深度(band_of 非 -1·顶 clamp 消解禁)〔v11〕", enemy_ok) Kit.check("生成图每个节点深度在 board_gen 精确命中(非空且 node_depth 等值)〔v11〕", board_ok) Kit.check("多终点 Boss 各终点解析带键两两互异(L2/L3×20 种子)〔v11〕", boss_band_ok) -# ---- 关卡节点地图·线性推进冒烟---- +# ---- 关卡节点图·生成图推进冒烟(图 = 该关参数档 × 种子)---- func _smoke_level() -> void: - print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---") + print("--- 关卡节点图·生成图推进(参数档×种子产图·满配通关/恢复公式/败北线/回放确定性)---") var autoplay = load("res://sim/level_autoplay.gd") var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) + var nodes1: Array = _level_active_nodes(1) + var params1: Dictionary = _db.level_gen_params_row(1) + Kit.check("L1 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes1.size()], not nodes1.is_empty()) # ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖) - var win = autoplay.run_level(_db, 42, 1, 200, 100) + var win = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 1, 200, 100) Kit.check("关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index], win.result == "victory") - Kit.check("战斗节点数 = 5(战×4 + Boss)", win.battles == 5) - Kit.check("Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)", win.boss_depth == 4) + # 行进面读数与图读数自洽:路径逐跳为图出边,路径上战斗/Boss 计数 = battles 读数 + var by_id1 := _nodes_by_id(nodes1) + var path_battles := 0 + for vid in win.visited_ids: + var vkind := String(by_id1.get(int(vid), {}).get("type", "")) + if vkind == "battle" or vkind == "boss": + path_battles += 1 + Kit.check("L1 通关路径逐跳为图出边(入口起步·末节点无出边——句柄轴行进自洽)", _path_in_graph(nodes1, win.visited_ids)) + Kit.check("L1 通关路径战斗/Boss 节点数 = 行进 battles 读数(路径 %d 节点·战斗 %d 颗·battles %d)" % [win.visited_ids.size(), path_battles, int(win.battles)], + path_battles == int(win.battles) and path_battles >= 2) + # 终点恒落最长路径步数:Boss 深度 = 参数档层高,且通关路径末节点即该终点 + var boss_depth1 := -1 + for n in nodes1: + if String(n.get("type", "")) == "boss": + boss_depth1 = maxi(boss_depth1, int(n.get("depth", -1))) + var last_id := -1 + if win.visited_ids.size() > 0: + last_id = int(win.visited_ids[win.visited_ids.size() - 1]) + Kit.check("L1 终点 Boss 深度 = 参数档层高 %d 且通关路径末节点落该深度(实测终点深度 %d·末句柄 %d)" % [int(params1.get("layers", -1)), boss_depth1, last_id], + boss_depth1 == int(params1.get("layers", -1)) and int(by_id1.get(last_id, {}).get("depth", -1)) == boss_depth1) + Kit.check("L1 终点恰一个(大岔口自 L2 起的图侧投影)", _terminal_ids(nodes1).size() == 1 and _ids_of_type(nodes1, "boss").size() == 1) + Kit.check("L1 生成图含岔口(出度 ≥2 节点 %d 处)" % _fork_ids(nodes1).size(), _fork_ids(nodes1).size() >= 1) Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3) - # ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2 - # seed 42(探针目的 = 验回复公式,种子仅载体) - var rest = autoplay.run_level(_db, 42, 1, 100, 100) - if rest.result != "start_failed" and rest.hp_before_rest >= 0: - var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线) - var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest) - Kit.check("恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max], rest.rest_healed == expect) + # ② 恢复点公式:回复 = min(floor(生效max_hp×rest_heal_pct/100), 缺口)——两档缺口各验一条分支 + #(真入口驱动:起关→首战→选恢复候选;缺口由夹具造,恢复比例读关卡行) + var pct1: int = int(_db.level_row(1).get("rest_heal_pct", 0)) + var rest_wide := _drive_to_rest(1, ACTIVE_GRAPH_SEED, 100000) + if rest_wide.is_empty(): + Kit.check("恢复点公式无源(现行图入口后一位无可直达的恢复候选)", false) else: - Kit.check("恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result, false) + var expect_pct := int(floor(float(int(rest_wide["max"])) * float(pct1) / 100.0)) + Kit.check("恢复点公式·缺口充分档:回复 = floor(生效max_hp×%d%%) = %d(生效 max %d,实测 %d)" % [pct1, expect_pct, int(rest_wide["max"]), int(rest_wide["healed"])], + int(rest_wide["healed"]) == expect_pct and expect_pct > 0) + var rest_narrow := _drive_to_rest(1, ACTIVE_GRAPH_SEED, 2) + if rest_narrow.is_empty(): + Kit.check("恢复点公式缺口封顶档无源(同图二次驱动未达恢复候选)", false) + else: + Kit.check("恢复点公式·缺口不足档:回复封顶到缺口 2(实测 %d,比例项 %d)" % [int(rest_narrow["healed"]), expect_pct], + int(rest_narrow["healed"]) == mini(expect_pct, 2) and expect_pct > 2) # ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关) - var lose = autoplay.run_level(_db, 42, 1, -80, -80) + var lose = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 1, -80, -80) Kit.check("关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result, lose.result == "defeat") - Kit.check("败北节点记录在域内(节点 %d)" % lose.defeat_node_index, lose.defeat_node_index >= 0 and lose.defeat_node_index < 7) + Kit.check("败北节点记录在域内(节点 %d,图节点数 %d)" % [lose.defeat_node_index, nodes1.size()], + lose.defeat_node_index >= 0 and lose.defeat_node_index < nodes1.size()) # ④ 回放确定性:同种子两遍步骤流逐行相同 - var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) - var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) + var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min") + var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min") Kit.check("关卡回放断言:同种子两遍结算步骤流逐行相同", JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows)) Kit.check("关卡步骤流非空(%d 行)" % rep1.rows.size(), rep1.rows.size() > 0) # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随) @@ -137,33 +377,68 @@ func _battle_st(policy: String) -> Dictionary: return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy} +# 真入口驱动到恢复节点并造缺口(起关→打赢入口战斗→在候选里取恢复型句柄→显式选路进点)。 +# 缺口 = 生效上限 − gap(gap 大于上限时按下限 1 血起算);返回 {healed, hp_before, gap, max}。 +# 现行图入口后一位无恢复候选时返回空字典(由调用方判空报红,禁静默跳过)。 +func _drive_to_rest(level_index: int, seed_val: int, gap: int) -> Dictionary: + var autoplay = load("res://sim/level_autoplay.gd") + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(seed_val)) + runner.set_hero_override(200, 100) + if not runner.start_level(level_index, seed_val): + return {} + var first = runner.current_battle() + if first == null: + return {} + var st = _battle_st("normal") + var guard := 30 + while runner.current_battle() == first and guard > 0: + guard -= 1 + autoplay._autoplay_battle(runner, first, st, null) + if not runner.awaiting_choice(): + return {} + var by_id := _nodes_by_id(runner.nodes()) + var rest_id := -1 + for cid in runner.next_candidate_ids(): + if String(by_id.get(int(cid), {}).get("type", "")) == "rest": + rest_id = int(cid) + break + if rest_id < 0: + return {} + var eff_max: int = runner.effective_max_hp() + runner.hero_hp = maxi(1, eff_max - gap) + var hp_before: int = runner.hero_hp + if not runner.pick_next_node(rest_id): + return {} + return {"healed": runner.hero_hp - hp_before, "hp_before": hp_before, "gap": eff_max - hp_before, "max": eff_max} + + # ==================== 敌人技能池(开轮摇号出招系统) ==================== # 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 / # EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。 -# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域图版行进(关卡节点地图 v1.0 图版)---- +# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域生成图行进(图 = 参数档 × 种子)---- func _smoke_level_graph() -> void: - print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") + print("--- 关卡节点图·分支节点图(L2 幽暗地域·生成图)---") var autoplay = load("res://sim/level_autoplay.gd") - # ① 关卡表行级校验零错误(图合法性自 T1 起不再由表加载期承担——关卡表携带的模板数据随卡② T3 清出) + # ① 关卡表行级校验零错误(关卡表只承载序号/显示名/恢复比例——图合法性由 validate_graph 在起关时承担) Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty()) - # ② L2 行集结构(网状图建议规模) - var l2 := {} - for row in _db.level.rows: - if int(row.level_index) == 2: - l2 = row - Kit.check("L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)", not l2.is_empty() and _level_active_nodes(2).size() == 10) - var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} - var branch := 0 - for node in _level_active_nodes(2): - tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1 - if node.get("next", []).size() >= 2: - branch += 1 - Kit.check("L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount.battle) == 6 and int(tcount.boss) == 1) - Kit.check("L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1) - Kit.check("L2 真实岔路 = %d 处(≥2)" % branch, branch >= 2) + # ② L2 生成图形状读数(性质断言——规模/型别/岔口全部由参数档与逐节点扫描互校) + var nodes2: Array = _level_active_nodes(2) + var params2: Dictionary = _db.level_gen_params_row(2) + Kit.check("L2 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes2.size()], not nodes2.is_empty()) + var tcount := _type_tally(nodes2) + var branch := _fork_ids(nodes2).size() + Kit.check("L2 节点数 %d ≥ 层高 %d+1 且 = 句柄去重读数 %d(全员句柄唯一)" % [nodes2.size(), int(params2.get("layers", -1)), _nodes_by_id(nodes2).size()], + nodes2.size() >= int(params2.get("layers", -1)) + 1 and _nodes_by_id(nodes2).size() == nodes2.size()) + Kit.check("L2 五型计数合计 = 节点数(battle %d + boss %d + reward %d + rest %d + shop %d = %d)" % [tcount["battle"], tcount["boss"], tcount["reward"], tcount["rest"], tcount["shop"], nodes2.size()], + int(tcount["battle"]) + int(tcount["boss"]) + int(tcount["reward"]) + int(tcount["rest"]) + int(tcount["shop"]) == nodes2.size()) + Kit.check("L2 战斗节点数 %d ≥ 参数档战斗保底 %d 且 Boss 节点数 %d = 参数档出口数 %d" % [tcount["battle"], int(params2.get("battle_floor", -1)), tcount["boss"], int(params2.get("exit_count", -1))], + int(tcount["battle"]) >= int(params2.get("battle_floor", -1)) and int(tcount["boss"]) == int(params2.get("exit_count", -1))) + Kit.check("L2 恢复/奖励/商店节点各 ≥1(实测 rest %d · reward %d · shop %d——计数由逐节点扫描同一读数)" % [tcount["rest"], tcount["reward"], tcount["shop"]], + int(tcount["rest"]) >= 1 and int(tcount["reward"]) >= 1 and int(tcount["shop"]) >= 1) + Kit.check("L2 岔口(出度 ≥2 的节点)= %d 处(≥1)" % branch, branch >= 1) # ③ 图校验正反断言改走 validate_graph 公有面(迭代 35 卡② T1 随动·声明): # · 夹具字段随生成图读面换代(id/type/depth/next 取代 node_depth 双编号空间); # · 「入口唯一违例」判据随模板池退役——多入口是现行合法形态(关卡节点图生成 v1.1 结构铁律), @@ -195,27 +470,37 @@ func _smoke_level_graph() -> void: orphan.append({"id": 5, "type": "battle", "depth": 1, "next": [6]}) orphan.append({"id": 6, "type": "rest", "depth": 2, "next": []}) Kit.check("图校验拒绝:非 Boss 终点(终点恒为 Boss)〔v1〕", _has_err_category(LT.validate_graph(orphan), "structural")) - # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) - # 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体) - var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) + # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——同种子同图,方向轴只换候选取值) + var win_min: Dictionary = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 2, 200, 100, false, null, "normal", "min") Kit.check("L2·满配 min 路径通关(实测:%s)" % win_min.result, win_min.result == "victory") - Kit.check("L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)", win_min.boss_depth == 9) - Kit.check("min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids), win_min.visited_ids == [0, 1, 3, 7, 8, 9]) + var n2_last := -1 + if win_min.visited_ids.size() > 0: + n2_last = int(win_min.visited_ids[win_min.visited_ids.size() - 1]) + var n2_last_type := String(_nodes_by_id(nodes2).get(n2_last, {}).get("type", "")) + Kit.check("L2 min 路径逐跳为图出边·末节点为 Boss(实测路径 %s·末句柄型别 %s)" % [str(win_min.visited_ids), n2_last_type], + _path_in_graph(nodes2, win_min.visited_ids) and n2_last_type == "boss") + Kit.check("L2 min 路径终点深度 = 参数档层高 %d(行进读数 %d——终点落最长路径步数)" % [int(params2.get("layers", -1)), int(win_min.boss_depth)], + int(win_min.boss_depth) == int(params2.get("layers", -1))) + Kit.check("L2 min 路径穿商店恰 1 家(路径句柄 %s 型别计数 = 1——必经恰一店的行进面)" % str(win_min.visited_ids), + _path_shop_count(nodes2, win_min.visited_ids) == 1) Kit.check("选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices, win_min.choices >= 3) - # max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表 - # 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300) - # 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + - # 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次 - # 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。 - const MAX_PATH_SEED := 43 # 种子重采程序:同款循环首命中(断言注释既定程序,临时脚本用后即删) - var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0) - Kit.check("L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED, win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9]) - # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言) - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + var win_max: Dictionary = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 2, 200, 100, false, null, "normal", "max") + Kit.check("L2·满配 max 路径(候选取最大句柄)通关(实测:%s·路径 %s)" % [win_max.result, str(win_max.visited_ids)], win_max.result == "victory") + var n2_last_max := -1 + if win_max.visited_ids.size() > 0: + n2_last_max = int(win_max.visited_ids[win_max.visited_ids.size() - 1]) + var n2_last_max_type := String(_nodes_by_id(nodes2).get(n2_last_max, {}).get("type", "")) + Kit.check("L2 max 路径逐跳为图出边·末节点为 Boss·穿商店恰 1 家", + _path_in_graph(nodes2, win_max.visited_ids) and n2_last_max_type == "boss" + and _path_shop_count(nodes2, win_max.visited_ids) == 1) + Kit.check("L2 max 与 min 路径分岔(方向轴生效——两路终点句柄 %d / %d 不同)" % [n2_last, n2_last_max], + n2_last != n2_last_max and win_max.visited_ids != win_min.visited_ids) + # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言,候选定位全部按图型别数据驱动) + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(ACTIVE_GRAPH_SEED)) runner.set_hero_override(200, 100) var choice_sizes: Array = [] runner.choices_presented.connect(func(c): choice_sizes.append(c.size())) - Kit.check("手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)", runner.start_level(2, 42, {}, 0)) + Kit.check("手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;图 = 参数档 × 种子 %d)" % ACTIVE_GRAPH_SEED, runner.start_level(2, ACTIVE_GRAPH_SEED)) var st = _battle_st("normal") var first = runner.current_battle() var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变) @@ -223,12 +508,46 @@ func _smoke_level_graph() -> void: while runner.current_battle() == first and not res_box.has("result"): autoplay._autoplay_battle(runner, first, st, null) Kit.check("首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)", String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice()) - Kit.check("入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)", runner.next_candidate_ids() == [1, 2]) - Kit.check("候选外编号拒绝且仍待选(拒绝语义)", not runner.pick_next_node(8) and runner.awaiting_choice()) - Kit.check("选编号 2(奖励点)受理", runner.pick_next_node(2)) + # 消费面图与生成面逐位一致(runner 持图 = 同一参数档同一种子的产图——读面句柄集相同) + var rnodes: Array = runner.nodes() + Kit.check("runner 消费面图 = 生成面图(节点数 %d/%d 且句柄集逐位相同)" % [rnodes.size(), nodes2.size()], + rnodes.size() == nodes2.size() and _ids_of_type(rnodes, "battle").size() + _ids_of_type(rnodes, "boss").size() + _ids_of_type(rnodes, "reward").size() \ + + _ids_of_type(rnodes, "rest").size() + _ids_of_type(rnodes, "shop").size() == nodes2.size() and _nodes_by_id(rnodes) == _nodes_by_id(nodes2)) + var cand: Array = runner.next_candidate_ids() + var rbyid := _nodes_by_id(rnodes) + var cand_types: Array = [] + for cid in cand: + cand_types.append(String(rbyid.get(int(cid), {}).get("type", "<不在图>"))) + Kit.check("入口选路口候选非空且全在图内(候选 %s·类型 %s)" % [str(cand), str(cand_types)], not cand.is_empty() and not cand_types.has("<不在图>")) + Kit.check("候选下一跳类型两两互异(岔口去路类型不重复契约——实测 %s)" % str(cand_types), cand_types.size() == _uniq(cand_types).size()) + # 候选外句柄拒绝(取图内非候选的句柄——数据驱动定位) + var outside_id := -1 + for n in rnodes: + if not cand.has(int(n.get("id", -1))) and int(n.get("id", -1)) != int(runner.current_node_id()): + outside_id = int(n.get("id", -1)) + break + Kit.check("候选外句柄 %d 拒绝且仍待选(拒绝语义)" % outside_id, outside_id >= 0 and not runner.pick_next_node(outside_id) and runner.awaiting_choice()) + # 奖励分支点选(候选按型别定位——奖励位存在才断言,缺位即红不静默跳过) + var reward_cand := -1 + var battle_cand := -1 + for cid in cand: + var ct := String(rbyid.get(int(cid), {}).get("type", "")) + if ct == "reward" and reward_cand < 0: + reward_cand = int(cid) + if ct == "battle" and battle_cand < 0: + battle_cand = int(cid) + Kit.check("入口岔口含奖励位与战斗位(句柄 %d/%d——候选型别可定位)" % [reward_cand, battle_cand], reward_cand >= 0 and battle_cand >= 0) + Kit.check("选奖励候选 %d 受理" % reward_cand, runner.pick_next_node(reward_cand)) Kit.check("到达奖励点:直通兑付(确认前不出选路口)", String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice()) - Kit.check("兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]", runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6]) - Kit.check("选编号 6(巨蜘蛛巢群)开战", runner.pick_next_node(6) and runner.current_battle() != null) + var reward_outs: Array = runner.node_out_ids(runner.current_node()) + Kit.check("兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = 该点图出边 %s" % str(reward_outs), + runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(reward_outs)) + var next_battle := -1 + for cid in runner.next_candidate_ids(): + if String(rbyid.get(int(cid), {}).get("type", "")) == "battle": + next_battle = int(cid) + break + Kit.check("选下一跳战斗候选 %d 开战" % next_battle, next_battle >= 0 and runner.pick_next_node(next_battle) and runner.current_battle() != null) # ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描 # (绿档口径 = 12 局多数胜 + 零踩雷恒要求;还原全胜档需回调锚行血量,归用户决策) var green_ok := true @@ -256,24 +575,14 @@ func _smoke_level_graph() -> void: Kit.check("首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷", green_ok) Kit.check("首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins, green_wins >= 7) Kit.check("首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp, green_min_hp > 0) - # ⑦ 跨表行完整:L2 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(加载校验同款显式复述; - # enemy 键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域) - 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 cross_ok := true - for node in _level_active_nodes(2): - var t := String(node.get("type", "")) - if t != "battle" and t != "boss": - continue - if not bg_keys.has("2_%d" % int(node.node_depth)) or not en_keys.has("2_%s_%d" % [t, int(node.node_depth)]): - cross_ok = false - Kit.check("跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (2,row_kind,depth_band))", cross_ok) - # ⑦a 定点:L2 首遇带行集与 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; - # 断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) + # ⑦ 跨表覆盖义务:L2 现行起关图每战斗/Boss 节点深度的敌侧落带 ≥ 请求深度(顶 clamp 消解即红) + # +每深度 board_gen 精确命中(判据实现与〔v11〕种子扫描段同源——_enemy_band_covers/_board_row_exact 单点) + var cov2 := _coverage_scan(2, nodes2) + Kit.check("跨表覆盖扫描面非空:L2 现行图战斗/Boss 节点 %d 颗" % int(cov2["nodes"]), int(cov2["nodes"]) >= 1) + Kit.check("L2 现行图每战斗/Boss 节点深度敌侧解析落带 ≥ 请求深度(违例 %d 处)" % int(cov2["enemy_bad"]), int(cov2["enemy_bad"]) == 0) + Kit.check("L2 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" % int(cov2["board_bad"]), int(cov2["board_bad"]) == 0) + # ⑦a 定点:L2 首遇带行集与各终点 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; + # 多终点图逐终点解析,断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) var l2_spider_band: int = _first_encounter_band(2, "giant_spider") var l2_spider_set: Array = _db.resolve_enemy_rowset(2, l2_spider_band, "battle") var l2_spider_ids := {} @@ -282,11 +591,14 @@ func _smoke_level_graph() -> void: Kit.check("巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" % l2_spider_band, l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \ and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max")) - var l2_boss_set: Array = _db.resolve_enemy_rowset(2, _boss_depth(2), "boss") - var l2_boss_ids := {} - for r in l2_boss_set: - l2_boss_ids[String(r.get("enemy_id", ""))] = true - Kit.check("蛛化卓尔精灵 Boss 行集(2,boss,%d)= {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % _boss_depth(2), l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward") + var l2_terms := _terminal_depths(nodes2) + var l2_boss_ok := not l2_terms.is_empty() + for d in l2_terms: + var l2_boss_set: Array[Dictionary] = _db.resolve_enemy_rowset(2, int(d), "boss") + if l2_boss_set.size() != 1 or String(l2_boss_set[0].get("enemy_id", "")) != "drider" \ + or String(l2_boss_set[0].get("armor_type", "")) != "ward": + l2_boss_ok = false + Kit.check("L2 全部终点深度 %s 的 Boss 行集均 = {drider} 恰一行且首行魔法甲(护甲类型重配批:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % str(l2_terms), l2_boss_ok) # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") @@ -302,32 +614,29 @@ func _smoke_level_graph() -> void: int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \ and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2) # ---- L3 灰矮人矿城断言段 ---- - # ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处) - var l3 := {} - for row in _db.level.rows: - if int(row.level_index) == 3: - l3 = row - Kit.check("L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)", not l3.is_empty() and _level_active_nodes(3).size() == 10) - var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} - var branch3 := 0 - for node in _level_active_nodes(3): - tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1 - if node.get("next", []).size() >= 2: - branch3 += 1 - Kit.check("L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount3.battle) == 6 and int(tcount3.boss) == 1) - Kit.check("L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1) - Kit.check("L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3, branch3 == 3) - # ⑨ 跨表行完整:L3 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用—— - # 键域同形:(L,row_kind,depth_band)) - var cross_ok3 := true - for node in _level_active_nodes(3): - var t3 := String(node.get("type", "")) - if t3 != "battle" and t3 != "boss": - continue - if not bg_keys.has("3_%d" % int(node.node_depth)) or not en_keys.has("3_%s_%d" % [t3, int(node.node_depth)]): - cross_ok3 = false - Kit.check("跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (3,row_kind,depth_band))", cross_ok3) - # ⑩ 定点:L3 首遇带行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) + # ⑧ L3 生成图形状读数(与 ② 同一性质判据面——规模/型别/岔口/多终点全部对参数档与扫描读数互校) + var nodes3: Array = _level_active_nodes(3) + var params3: Dictionary = _db.level_gen_params_row(3) + Kit.check("L3 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes3.size()], not nodes3.is_empty()) + var tcount3 := _type_tally(nodes3) + var branch3 := _fork_ids(nodes3).size() + Kit.check("L3 节点数 %d ≥ 层高 %d+1 且 = 句柄去重读数 %d(全员句柄唯一)" % [nodes3.size(), int(params3.get("layers", -1)), _nodes_by_id(nodes3).size()], + nodes3.size() >= int(params3.get("layers", -1)) + 1 and _nodes_by_id(nodes3).size() == nodes3.size()) + Kit.check("L3 五型计数合计 = 节点数(battle %d + boss %d + reward %d + rest %d + shop %d = %d)" % [tcount3["battle"], tcount3["boss"], tcount3["reward"], tcount3["rest"], tcount3["shop"], nodes3.size()], + int(tcount3["battle"]) + int(tcount3["boss"]) + int(tcount3["reward"]) + int(tcount3["rest"]) + int(tcount3["shop"]) == nodes3.size()) + Kit.check("L3 战斗节点数 %d ≥ 参数档战斗保底 %d 且 Boss 节点数 %d = 参数档出口数 %d" % [tcount3["battle"], int(params3.get("battle_floor", -1)), tcount3["boss"], int(params3.get("exit_count", -1))], + int(tcount3["battle"]) >= int(params3.get("battle_floor", -1)) and int(tcount3["boss"]) == int(params3.get("exit_count", -1))) + Kit.check("L3 恢复/奖励/商店节点各 ≥1(实测 rest %d · reward %d · shop %d——计数由逐节点扫描同一读数)" % [tcount3["rest"], tcount3["reward"], tcount3["shop"]], + int(tcount3["rest"]) >= 1 and int(tcount3["reward"]) >= 1 and int(tcount3["shop"]) >= 1) + Kit.check("L3 岔口(出度 ≥2 的节点)= %d 处(≥1)" % branch3, branch3 >= 1) + Kit.check("L3 入口数 %d = 参数档 entry_count · 终点数 %d = exit_count ≥2(多终点大岔口自 L2 起)" % [_entry_ids(nodes3).size(), _terminal_ids(nodes3).size()], + _entry_ids(nodes3).size() == int(params3.get("entry_count", -1)) and _terminal_ids(nodes3).size() == int(params3.get("exit_count", -1)) and _terminal_ids(nodes3).size() >= 2) + # ⑨ 跨表覆盖义务(同 ⑦ 判据面):L3 现行图每战斗/Boss 节点深度敌侧落带 ≥ 请求深度+板侧精确命中 + var cov3 := _coverage_scan(3, nodes3) + Kit.check("跨表覆盖扫描面非空:L3 现行图战斗/Boss 节点 %d 颗" % int(cov3["nodes"]), int(cov3["nodes"]) >= 1) + Kit.check("L3 现行图每战斗/Boss 节点深度敌侧解析落带 ≥ 请求深度(违例 %d 处)" % int(cov3["enemy_bad"]), int(cov3["enemy_bad"]) == 0) + Kit.check("L3 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" % int(cov3["board_bad"]), int(cov3["board_bad"]) == 0) + # ⑩ 定点:L3 首遇带行集与各终点 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) var l3_lead_band: int = _first_encounter_band(3, "duergar") var l3_lead_set: Array = _db.resolve_enemy_rowset(3, l3_lead_band, "battle") var l3_lead_ids := {} @@ -337,11 +646,14 @@ func _smoke_level_graph() -> void: l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \ and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \ and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max")) - var l3_king_set: Array = _db.resolve_enemy_rowset(3, _boss_depth(3), "boss") - var l3_king_ids := {} - for r in l3_king_set: - l3_king_ids[String(r.get("enemy_id", ""))] = true - Kit.check("灰矮人石君王 Boss 行集(3,boss,%d)= {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % _boss_depth(3), l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy") + var l3_terms := _terminal_depths(nodes3) + var l3_boss_ok := not l3_terms.is_empty() + for d in l3_terms: + var l3_king_set: Array[Dictionary] = _db.resolve_enemy_rowset(3, int(d), "boss") + if l3_king_set.size() != 1 or String(l3_king_set[0].get("enemy_id", "")) != "duergar_king" \ + or String(l3_king_set[0].get("armor_type", "")) != "heavy": + l3_boss_ok = false + Kit.check("L3 全部终点深度 %s 的 Boss 行集均 = {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % str(l3_terms), l3_boss_ok) # ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过 # (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥) var green3_ok := true @@ -391,10 +703,10 @@ func _smoke_level_graph() -> void: Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.enemy.rows.size()) == 28) # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / - # Boss 恒可见 / 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 - var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + # 终点恒可见 / 入口 BFS 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 + var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(ACTIVE_GRAPH_SEED)) var vis_ledger := {"hero_hp": 10, "gp": 0} - var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true, 0) + var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true) Kit.check("可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)", vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1) var vis_ids: Array = vis_runner.type_visible_ids() var vis_cand_in := true @@ -402,10 +714,36 @@ func _smoke_level_graph() -> void: if not vis_ids.has(int(cid)): vis_cand_in = false Kit.check("可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕", vis_cand_in) - Kit.check("可见性冒烟:L2 Boss(编号 %d)恒在可见集(终点不入雾)〔fv3〕" % _boss_depth(2), vis_ids.has(_boss_depth(2))) + var vis_boss_ok := true + for bid in _ids_of_type(nodes2, "boss"): + if not vis_ids.has(int(bid)): + vis_boss_ok = false + Kit.check("可见性冒烟:L2 全部终点 Boss 句柄 %s 恒在可见集(终点不入雾)〔fv3〕" % str(_ids_of_type(nodes2, "boss")), vis_boss_ok) var vis_n: int = int(_db.level.info_visible_layers) Kit.check("可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n, vis_n == 2) - Kit.check("可见性冒烟:入口锚层 0 时层距 3/4 的节点 7/8 入雾(非豁免——入口 BFS 层差 > N)〔fv1〕", not vis_ids.has(7) and not vis_ids.has(8)) + # 层距判据按图自算入口 BFS 层(与 runner 同源口径:入口层 0、出边逐层 +1)—— + # 锚层 0 时层距 ≤ N 者恒可见、层距 > N 的非终点者恒不可见(两条扫描面都须非空) + var vis_layers := _bfs_layers(nodes2) + var near_ok := true + var near_count := 0 + var fog_ok := true + var fog_count := 0 + for n in nodes2: + var nid := int(n.get("id", -1)) + var layer: int = int(vis_layers.get(nid, -1)) + if layer < 0: + continue + var is_boss := String(n.get("type", "")) == "boss" + if layer <= vis_n: + near_count += 1 + if not vis_ids.has(nid): + near_ok = false + elif not is_boss: + fog_count += 1 + if vis_ids.has(nid): + fog_ok = false + Kit.check("可见性冒烟:入口锚层 0 时层距 ≤ %d 的节点全在可见集(扫描面 %d 颗·违例 0)〔fv1〕" % [vis_n, near_count], near_count >= 1 and near_ok) + Kit.check("可见性冒烟:入口锚层 0 时层距 > %d 的非终点节点全入雾(扫描面 %d 颗·违例 0)〔fv1〕" % [vis_n, fog_count], fog_count >= 1 and fog_ok) # 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) @@ -420,7 +758,7 @@ func _fresh_rng(seed_val: int): # ==================== GP+商店房冒烟 ==================== # ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) -# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 商店编排性质(每终点前恰一店/终点跨表解析) # ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 # ---- ① GP 冒险钱包·到账 ---- @@ -428,7 +766,7 @@ func _fresh_rng(seed_val: int): # ==================== GP+商店房冒烟 ==================== # ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) -# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 商店编排性质(每终点前恰一店/终点跨表解析) # ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 # ---- ① GP 冒险钱包·到账 ---- @@ -614,44 +952,61 @@ func _find_gp_dig(minesweeper, settlement) -> Dictionary: func _smoke_shop_room() -> void: print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---") var autoplay = load("res://sim/level_autoplay.gd") - # 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) + # 行进到商店房(按图型别定位商店节点——候选取最小句柄行进,到达即停): + # 种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) var shop_run := {} for s in range(42, 92): - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) - runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) - runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本 + var r0 = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) + r0.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) + r0.start_level(2, s) var st := _battle_st("normal") - var guard := 40 - while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0: + var guard := 60 + while String(r0.level_result) == "" and String(r0.current_node().get("type", "")) != "shop" and guard > 0: guard -= 1 - if runner.awaiting_choice(): - var ids: Array = runner.next_candidate_ids() + if r0.awaiting_choice(): + var ids: Array = r0.next_candidate_ids() ids.sort() - runner.pick_next_node(int(ids[0])) + r0.pick_next_node(int(ids[0])) continue - var t := String(runner.current_node().get("type", "")) + var t := String(r0.current_node().get("type", "")) if t == "reward" or t == "rest": - runner.continue_after_node() + r0.continue_after_node() continue - if t == "shop": - break - var b = runner.current_battle() + var b = r0.current_battle() if b == null: break var fin := {} b.battle_finished.connect(func(p): fin["p"] = p) var g2 := 80 - while runner.current_battle() == b and not fin.has("p") and g2 > 0: + while r0.current_battle() == b and not fin.has("p") and g2 > 0: g2 -= 1 - autoplay._autoplay_battle(runner, b, st, null) - if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16: - shop_run = {"seed": s, "runner": runner} + autoplay._autoplay_battle(r0, b, st, null) + if String(r0.level_result) == "" and String(r0.current_node().get("type", "")) == "shop" and r0.gp >= 16: + shop_run = {"seed": s, "runner": r0} break Kit.check("商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1)), not shop_run.is_empty()) if shop_run.is_empty(): return var runner = shop_run["runner"] - Kit.check("L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)", String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8) + # 到店读数全部按图解析(现行判据=每终点前一位恰一店的行进面) + var s_nodes: Array = runner.nodes() + var s_byid := _nodes_by_id(s_nodes) + var shop_id: int = int(runner.current_node_id()) + var shop_outs: Array = runner.node_out_ids(runner.current_node()) + var shop_preds := 0 + for n in s_nodes: + if (n.get("next", []) as Array).has(shop_id): + shop_preds += 1 + Kit.check("L2 行进到商店房:当前节点型别 = shop 且出边恰一条指终点 Boss(句柄 %d·出边 %s)" % [shop_id, str(shop_outs)], + String(runner.current_node().get("type", "")) == "shop" and shop_outs.size() == 1 \ + and String(s_byid.get(int(shop_outs[0]), {}).get("type", "")) == "boss" \ + and (s_byid.get(int(shop_outs[0]), {}).get("next", []) as Array).is_empty()) + Kit.check("L2 到店路径穿商店恰 1 家(已到句柄 %s 型别计数——必经恰一店的行进面)" % str(runner.visited_ids()), + _path_shop_count(s_nodes, runner.visited_ids()) == 1) + Kit.check("L2 商店节点(句柄 %d)入边恰一条(店位归终点的直接前驱——行进面复核)" % shop_id, shop_preds == 1) + if shop_outs.is_empty(): + Kit.check("到店商店节点无出边(夹具停摆——去终点方向无法从图解析)", false) + return Kit.check("到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)", not runner.awaiting_choice() and runner.next_candidate_ids().is_empty()) # 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数 var prods: Array = runner.shop_products() @@ -694,11 +1049,12 @@ func _smoke_shop_room() -> void: var tier_dry: int = runner.purchased_tier("hp_up") var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"]) Kit.check("余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry], not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry) - # 离开 → 回选路口(候选 [9]) - Kit.check("离开商店 → 回选路口(候选 [9]——零自动推进收口)", runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9]) + # 离开 → 回选路口(候选 = 该店出边——恰一条,指向终点 Boss) + Kit.check("离开商店 → 回选路口(候选 = 店出边 %s——零自动推进收口)" % str(shop_outs), + runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(shop_outs)) # 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案) Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.hero.atk) + 1) - Kit.check("选 Boss(节点 9)开战", runner.pick_next_node(9) and runner.current_battle() != null) + Kit.check("选终点 Boss(句柄 %d)开战" % int(shop_outs[0]), runner.pick_next_node(int(shop_outs[0])) and runner.current_battle() != null) var snap: Dictionary = runner.current_battle().presentation_snapshot() # max_hp 期望按实际购买笔数推导(可购笔数 # 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不锚死恰好 1 笔) @@ -724,51 +1080,104 @@ func _shop_products_by_id(prods: Array) -> Dictionary: return out -# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- +# ---- ④ 商店编排性质(每终点前一位恰一店 · 店数=终点数 · 全路径恰穿一店)---- -# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- +# ---- ④ 商店编排性质(生成图扫描面——三条性质 + 终点跨表解析)---- func _smoke_shop_graph() -> void: - print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---") - for L in [2, 3]: - var row := {} - for r in _db.level.rows: - if int(r.level_index) == L: - row = r - var shops: Array = [] - var boss_id := -1 - var max_id := -1 - for n in _level_active_nodes(L): - var t := String(n.get("type", "")) - if t == "shop": - shops.append(int(n.get("node_depth", -1))) - if t == "boss": - boss_id = int(n.get("node_depth", -1)) - max_id = maxi(max_id, int(n.get("node_depth", -1))) - Kit.check("L%d 商店房恰 1 且 node_depth=8(next=[9])" % L, shops.size() == 1 and int(shops[0]) == 8) - Kit.check("L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L, boss_id == 9 and boss_id == max_id) - var paths: Array = _all_entry_boss_paths(_level_active_nodes(L)) - var via_shop := true - for p in paths: - if not p.has(8): - via_shop = false - Kit.check("L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()], paths.size() >= 2 and via_shop) - # 跨表校验:(·,boss,9) Boss 行精确存在 · (·,battle,8) 无战斗行残留(陈旧行清理确认) - 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 - for L in [2, 3]: - Kit.check("(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" % L, bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L)) - # 迭代 35 生成图深度扩面覆盖批随动(旧判据属模板时代:深度 8 = 商店房专属位,故禁板行与战斗行): - # 生成图下深度 8 是正常可行深度 ⇒ 板侧改判「覆盖行在册」;战斗侧仍禁 (L,battle,8) 精确键 - #(战斗落带经顶带 9/13 解析,不落 8 号精确键——两侧判据都保留,只按现行设计换正负号)。 - Kit.check("(%d,8) 板侧生成图覆盖行在册·(%d,battle,8) 战斗精确键仍不存在" % [L, L], - bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L)) - Kit.check("L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)", bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8")) + print("--- 商店编排性质(每终点前一位恰一店 · 店数=终点数 · 全路径恰穿一店)---") + var pred_bad := 0 + var term_total := 0 + var count_bad := 0 + var path_bad := 0 + var path_total := 0 + var graph_total := 0 + for L in [1, 2, 3]: + if _db.level_gen_params_row(L).is_empty(): + Kit.check("L%d 参数档缺行(商店编排断言无源可扫)" % L, false) + continue + for seed_val in range(SWEEP_SEED_FROM, SWEEP_SEED_TO): + var nodes: Array = _generated_graph(L, seed_val).get("nodes", []) + if nodes.is_empty(): + Kit.check("L%d 种子 %d 生成图为空(编排断言扫描面空转)" % [L, seed_val], false) + continue + graph_total += 1 + if seed_val == 44 and L == 2: # MUTATION-A:店位改成战斗节点 + for mn in nodes: + if String(mn.get("type", "")) == "shop": + mn["type"] = "battle" + if seed_val == 45 and L == 3: # MUTATION-B:多塞一家店(第二家挂在 reward 位) + for mn in nodes: + if String(mn.get("type", "")) == "reward": + mn["type"] = "shop" + break + var by_id := _nodes_by_id(nodes) + var terminals := _terminal_ids(nodes) + # 性质①:每个终点的直接前驱恰为一家商店(入边一条 + 入边源型别 = shop) + for tid in terminals: + term_total += 1 + var preds: Array = [] + for n in nodes: + if (n.get("next", []) as Array).has(int(tid)): + preds.append(int(n.get("id", -1))) + if preds.size() != 1 or String(by_id.get(int(preds[0]), {}).get("type", "")) != "shop": + pred_bad += 1 + # 性质②:商店节点总数 = 终点数 + if _ids_of_type(nodes, "shop").size() != terminals.size(): + count_bad += 1 + # 性质③:所有入口→终点路径穿过且恰穿过一家店(DFS 枚举句柄序) + var paths: Array = _all_entry_terminal_paths(nodes) + if paths.size() < terminals.size(): + path_bad += 1 # 枚举面少于终点数=有终点没有入口→终点路径 + for p in paths: + path_total += 1 + if _path_shop_count(nodes, p) != 1: + path_bad += 1 + print("商店编排扫描读数:图 %d 张·终点 %d 个·入口→终点路径 %d 条" % [graph_total, term_total, path_total]) + Kit.check("编排断言扫描面非空(三关参数档 × 种子 %d–%d=图 %d 张·终点 %d·路径 %d)" % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, graph_total, term_total, path_total], + graph_total == 3 * (SWEEP_SEED_TO - SWEEP_SEED_FROM) and term_total >= 3 and path_total >= 3) + Kit.check("性质① 每个终点的直接前驱恰一家商店(入边一条且源型别=shop·违例 %d/%d 终点)" % [pred_bad, term_total], pred_bad == 0) + Kit.check("性质② 每图商店节点数 = 终点数(违例 %d/%d 图)" % [count_bad, graph_total], count_bad == 0) + Kit.check("性质③ 每条入口→终点路径恰穿一家商店(违例 %d/%d 条路径)" % [path_bad, path_total], path_bad == 0) + # 现行起关图(种子 %d)逐关读数——L1 单终点、L2/L3 多终点(每终点前一位恰一店的当关投影) + for L in [1, 2, 3]: + var cur_nodes: Array = _level_active_nodes(L) + var cur_terms := _terminal_ids(cur_nodes) + var cur_shops := _ids_of_type(cur_nodes, "shop") + Kit.check("L%d 现行图终点 %d 个 = 商店 %d 个·入口→终点路径 %d 条全穿恰一店" % [L, cur_terms.size(), cur_shops.size(), _all_entry_terminal_paths(cur_nodes).size()], + cur_terms.size() == cur_shops.size() and not cur_terms.is_empty() + and _all_entry_terminal_paths(cur_nodes).size() >= cur_terms.size() + and _paths_all_one_shop(cur_nodes)) + # 终点跨表解析:每个终点深度的 Boss 行集解析命中且落带 ≥ 终点深度(顶 clamp 消解即红) + for L in [1, 2, 3]: + var cur_nodes2: Array = _level_active_nodes(L) + var tdepths := _terminal_depths(cur_nodes2) + var hit_ok := not tdepths.is_empty() + for d in tdepths: + if not _enemy_band_covers(L, int(d), "boss"): + hit_ok = false + Kit.check("L%d 现行图终点深度 %s 逐点 Boss 行集解析命中且落带 ≥ 终点深度" % [L, str(tdepths)], hit_ok) + # L1 现行图跨表覆盖(⑦/⑨ 只扫 L2/L3——L1 图侧覆盖在此闭合:每 battle/boss 深度敌侧落带+板侧精确命中) + var mut_nodes: Array = _level_active_nodes(1) + mut_nodes[0]["depth"] = 99 # MUTATION-C:深度越出表域(敌带与板行都应判违例) + mut_nodes[1]["type"] = "trap" # MUTATION-D:型别出枚举(五型合计应 ≠ 节点数) + var l1_cov := _coverage_scan(1, mut_nodes) + Kit.check("MUT 跨表覆盖违例捕获(enemy_bad %d board_bad %d 应 ≥1)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"])], int(l1_cov["enemy_bad"]) >= 1 and int(l1_cov["board_bad"]) >= 1) + var mut_sum := 0 + for tv in _type_tally(mut_nodes).values(): + mut_sum += int(tv) + Kit.check("MUT 型别合计自洽违例捕获(sum %d vs 节点数 %d)" % [mut_sum, mut_nodes.size()], mut_sum != mut_nodes.size()) + Kit.check("L1 现行图跨表覆盖(战斗/Boss 节点 %d 颗非空·敌侧违例 %d·板侧违例 %d)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"]), int(l1_cov["board_bad"])], + int(l1_cov["nodes"]) >= 1 and int(l1_cov["enemy_bad"]) == 0 and int(l1_cov["board_bad"]) == 0) + + +# 现行图全路径穿店恰一(性质③在单图上的整体读数) +func _paths_all_one_shop(nodes: Array) -> bool: + for p in _all_entry_terminal_paths(nodes): + if _path_shop_count(nodes, p) != 1: + return false + return true # ---- ⑤ 商店共享池(三选一同构/乘法价/跨渠道共账/切边/断点扩键)---- @@ -789,7 +1198,7 @@ func _smoke_shop_pool() -> void: 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, "shop_mode": "pool"}): + if not a8.start_adventure(s, {"shop_mode": "pool"}): continue if not _adv_drive_to_shop(a8, _adv_st(), autoplay): continue @@ -801,7 +1210,7 @@ func _smoke_shop_pool() -> void: # 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面) var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"]))) a8b.set_hero_override(900, 400) - if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \ + if a8b.start_adventure(int(scen["s"]), {"shop_mode": "pool"}) \ and _adv_drive_to_shop(a8b, _adv_st(), autoplay): first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true) Kit.check("进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size(), first_shelf_a.size() == 3) @@ -989,51 +1398,10 @@ func _adv_st() -> Dictionary: # 返回 null = 不在摇号清单的封锁格)。哈希兜底按坐标确定性散列——全缸可复现。 -# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限) -func _all_entry_boss_paths(nodes: Array) -> Array: - var by_id := {} - var inbound := {} - for n in nodes: - by_id[int(n.get("node_depth", -1))] = n - for n in nodes: - for tid in n.get("next", []): - inbound[int(tid)] = true - var entry := -1 - for n in nodes: - if not inbound.has(int(n.get("node_depth", -1))): - entry = int(n.get("node_depth", -1)) - var paths: Array = [] - var stack: Array = [[entry]] - while not stack.is_empty(): - var path: Array = stack.pop_back() - var cur: Dictionary = by_id[path[path.size() - 1]] - var outs: Array = cur.get("next", []) - if outs.is_empty(): - paths.append(path) - continue - for t in outs: - var np := path.duplicate() - np.append(int(t)) - stack.append(np) - return paths - - - # ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; # conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- -# 模板感知取节点表(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 [] - - # 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 func _first_encounter_band(level_index: int, enemy_id: String) -> int: var best := -1 @@ -1048,20 +1416,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int: return best -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) - - -# 该关 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 _has_err_category(errs: Array, category: String) -> bool: for e in errs: if String(e.get("category", "")) == category: