# VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 三关连探冒险(地精洞窟 → 幽暗地域 → 灰矮人矿城)能否以一条命走完—— # 主种子恰一次注入、生命/GP/加算区跨关同账、大奖励非末关恰兑付一次、三结局摘要五件事成立(P0) # Date: 2026-09-21(冒险结构实现批——设计权威 design/gdd/systems/冒险循环/冒险结构.md) # 冒险结构编排器(Core 层零 UI 依赖——headless 可跑完整冒险):一局的关卡序列、生命账本、GP 冒险 # 账本、统一冒险加算区、关卡大奖励、三结局结算与主种子接线的唯一所有者。 # # 接口契约四件套落点(冒险结构.md「与关卡节点地图的边界」——本类是正式侧承载): # ① 开始关卡 = start_level_external(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接); # ② 战斗结果上报 = runner 经共享账本属性直写(LevelRunner._on_battle_finished 回写 hero_hp 到 # _ledger——零直调战斗域,战后生命终值随账本回流本类); # ③ 治疗封顶回报 = apply_rest(恢复点节点兑付时 LevelRunner 经 _rest_handler 回调——按比例回血并 # 封顶的执行归本类); # ④ 关卡结束 = _on_level_finished(LevelRunner.level_finished 到达——结果 + 生命终值 + 节点位置)。 # # 接口漂移清偿登记(06 清单①②③⑥⑦——LevelRunner 侧同批登记):①种子域归本类 # (start_adventure 调 rng.new_adventure 全冒险恰一次;LevelRunner 冒险模式不重播种); # ②生命账本归本类(共享 _ledger);③恢复兑付执行归本类(_rest_handler);⑥收束先清库后宣布—— # 现行无关卡域库存(格面资源批 2026-09-15 后为空集),战斗结束 retire_mapping + despawn_all 先于 # battle_finished 即事实清库,不另设空接口;⑦失败结算归本类(LevelRunner 只上报结果)。 # # 收束次序登记(大奖励兑付时点——冒险结构.md「关卡大奖励」):关卡节点地图先清库、后宣布关卡 # 结束(次序不可换);大奖励在宣布之后、下一关开始之前兑付——本类 _on_level_finished 兑付、 # continue_to_next_level 开始下一关,两步间为呈现层大奖励屏(冒烟/自动路径立即确认)。 # 结算受理只算一次(契约):state != "running" 的关卡结束宣布与放弃请求一律拒绝并留诊断, # 账本与摘要零变化。 # 停点前移批(2026-09-23,用户拍板「关卡开始即出节点图选路、选节点才进战斗」):每关开始 = # 入口选路停点——_start_current_level 以 defer_entry 起关,level_started 之后才 present_entry_choice # (信号顺序契约:呈现层先重取 runner 实例,选路口才不会被空防御丢弃);pick 入口候选走同一 # _enter_node(entry)、呈现零 RNG 消耗——与旧「start 直进首战」逐位等价,测量面零漂移。 # 关卡切换(continue_to_next_level)后新 LevelRunner 实例重新走 defer 流程;abandon 在入口停点 # 天然非战斗期(current_battle 为空)——受理语义与选路停点一致。 # 纪律:ADR-0001 S6——连 runner 信号一律 _forward_* 方法 Callable(禁 lambda 捕获 self 成环); # Core 层禁 UI 引用;数值零字面量——level_clear_hp_bonus / keystone_per_level 只读 db.adventure, # 基础上限只读 db.hero。 # 断点续玩(迭代 8 卡①):save_manager 经 set_save_manager 注入(缺省 null = 不写断点——模拟/ # 冒烟零触达零扰动);停点快照挂 choices_presented 转发点(选路停点=唯一可恢复停点形态,战斗中 # 不存);三结局 _settle 清断点;resume_adventure 经 rng.restore_adventure 回填「主种子+计数器」 # ——续玩从断点号位精确续接原随机时间线。 class_name AdventureRunner extends RefCounted signal level_started(level_index: int, display_name: String) # 每关开始(含冒险首关)——呈现层重取 runner()、刷新图条与层名行 signal bonus_choice_presented(payload: Dictionary) # 提升三选一停点(非末关通关恰一次;迭代 10 大奖励重构——big_reward_paid 退役)——{level_index, candidates, heal_gained, hp_before, hp_after, keystone_gained, keystone_total, gp, aux_cleared} signal bonus_resolved(result: Dictionary) # 三选一兑付完成(pick_bonus/替换受理后恰一次)——{picked, value, hp_before, hp_after, level_index} signal treasure_replace_presented(items: Array, incoming: Dictionary) # 同调位满位替换子交互(pick_bonus 道具件满位时)——items=现有持有件记录、incoming=新件记录 signal adventure_finished(summary: Dictionary) # 冒险结算(completed/failed/abandoned)——summary 五件事:打穿没有/通关几关/止步第几关/钥石合计/主种子(+additive keystone_drops 迭代 10) # ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明;ADR-0001 S6 经 _forward_* 方法)---- signal node_entered(node: Dictionary, info: Dictionary) signal choices_presented(candidates: Array) signal step_row(row: Dictionary) signal round_started(round_index: int) signal board_changed() signal wallet_changed(event: String, gp: int, extra: Dictionary) var db: ConfigDb var rng: RngServiceCore var state := "idle" # idle / running / settling(冒险结构.md 三态) var ledger := {"hero_hp": 0, "gp": 0} # 冒险账本(生命 + GP 钱包——经 start_level_external 注入 runner 共享) var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 统一冒险加算区(商店购买与三选一属性件同一本账;四轴 +ap——迭代 10 奖励重构) var purchased := {} # product_id → 已购档数(跨关延续、冒险收束清零——递增定价输入) var level_index := 0 # 当前关卡序号(1 起) var levels_total := 0 # 序列总关数(读 level 表行数) var keystone := 0 # 钥石入账累计(摘要第三件事的账面) var master_seed := 0 # 本局主种子(摘要第五件事) var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成) var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离) var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认 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;全零 = 白板) var _last_treasure_cleared := 0 # additive(迭代 7 T4——最近一次关卡末清空计数,summary 取数面) var _treasure_unlocked := {} # aux 解锁门控集(迭代 7 T5——params.treasure_unlocked 接收面) var _attunement: Array = [] # 同调装配集(迭代 11 第①批 T5——params.attunement 接收面,start_level 外下传 LevelRunner) var _attune_refresh_x := 0 # 刷新件余量(迭代 11 第①批 T5b P1 甲——每碰三选一重置=X 表值;优先消耗、不结转) var _attune_refresh_y := 0 # Y 冒险周期池(开局参数面注入,用完即无——营地升级行表值) var attune_charges := {} # 同调主动件冒险充能账本(迭代 11 第①批 T6——跨关卡共享 dict;settlement use_treasure 扣减直改同引用;断点随 attunement_charges 键入档) var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省) var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) # ---- 三选一停点态(迭代 10 奖励重构批)---- var _bonus_taken := {} # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载;池库存正主=_bonus_deck) var _bonus_deck: Array = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账;迭代 12 T1 起规则改写型特性带=relic 令牌随表追加) var _relics: Array[String] = [] # 特性持有集(迭代 12 T1——局内随机渠道获得、冒险级跨关携带、收束清零;🔴 层级纪律承载面:不入同调位不入局外树) var _tree_rules: Array = [] # 树 rule 键集(迭代 12 T10——Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面) var _bonus_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点 var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付) var _bonus_treasure_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗) var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源) var _last_draw_treasure: Array[String] = [] # 最近一次抽签掷定的道具件(掷定前置——卡片直显具体道具;追加批二) var _node_bonus_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽) var _shop_mode := "legacy" # 商店模式(迭代 11 第②批 T3——"legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool) func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: db = db_ref rng = rng_ref # ---- 冒险生命周期 ---- # 冒险开始(主种子注入恰一次——全冒险唯一 rng.new_adventure 调用点;重开 = 全新一轮: # 账本/加算区/已购档/钥石/摘要全重置,生命置满基础上限,从第 1 关起步)。 # params.hero_override = 永久强化 flat 注入(元进度实现批——缺省 {} = 全零白板,与 B1 空参数路径逐位一致; # -1/缺键 = 表值兜底口径,规范化为 0 flat) func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: if state != "idle": push_error("[冒险结构] start_adventure 拒绝——state=%s(重开须待上一局结算完毕)" % state) return false master_seed = seed_val rng.new_adventure(seed_val) ledger = {"hero_hp": 0, "gp": 0} bonus = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} purchased = {} keystone = 0 summary = {} _reset_bonus_state() levels_total = db.level_count() # 表访问收口(2026-10-02——行集直扫退役) level_index = 1 _pending_next_level = false _base_override = _normalize_override(params.get("hero_override", {})) _first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入) _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 迭代 11 第②批 T3——缺省 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(迭代 11 第①批 T5) _attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池) _seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义) # additive(迭代 7 T5) _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive(迭代 11 第①批 T5) _attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池) _seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义) # 特性开局注入(迭代 12 T7——MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派 # 各 2 件档一成型——测量面专用;真实局规则改写型特性只走局内随机渠道,红线②层级纪律不破〕) var relic_seed: Array = params.get("relics_seed", []) if params.get("relics_seed", []) is Array else [] _tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # 迭代 12 T10——A/B 面 rule 键集 if not relic_seed.is_empty(): _relics = [] for r in relic_seed: _relics.append(String(r)) ledger["hero_hp"] = effective_max_hp() # 生命账本置满(生效上限口径——T17 修复:含调试浮窗 pct,与战斗内同源) state = "running" return _start_current_level() # 注入参数规范化:三字段缺键/-1 = 表值兜底(→ 0 flat);负值一律 clamp 0(白板 = 全零合法态) func _normalize_override(raw: Dictionary) -> Dictionary: var out := {"atk": 0, "max_hp": 0, "armor_class": 0} for k in out.keys(): out[k] = maxi(0, int(raw.get(k, 0))) return out # 断点续玩(迭代 8 卡①——「继续上局」入口;bp = save.breakpoint_snapshot() 快照,params 同 start_adventure # 的注入参数面):账面(生命/GP/加算区/已购档/钥石累计)逐字段回填 + rng.restore_adventure 回填 # 「主种子+计数器」→ _start_current_level 续玩路径(模板随快照强制、重建到停点节点,零重抽)。 # 快照缺字段按防御默认(空快照拒绝——调用方先 has_breakpoint)。 func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: if state != "idle": push_error("[冒险结构] resume_adventure 拒绝——state=%s(非 idle 不可续玩)" % state) return false if bp.is_empty(): push_error("[冒险结构] resume_adventure 拒绝——空断点快照") 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))} var bp_bonus: Dictionary = bp.get("bonus", {}) as Dictionary bonus = {"atk": maxi(0, int(bp_bonus.get("atk", 0))), "max_hp": maxi(0, int(bp_bonus.get("max_hp", 0))), "armor_class": maxi(0, int(bp_bonus.get("armor_class", 0))), "ap": maxi(0, int(bp_bonus.get("ap", 0)))} purchased = (bp.get("shop_purchased", {}) as Dictionary).duplicate(true) keystone = int(bp.get("keystone", 0)) summary = {} levels_total = db.level_count() # 表访问收口(2026-10-02——行集直扫退役) level_index = int(bp.get("level_index", 1)) _pending_next_level = false _reset_bonus_state() var bp_taken: Dictionary = bp.get("bonus_taken", {}) as Dictionary for id in _bonus_taken.keys(): _bonus_taken[id] = maxi(0, int(bp_taken.get(id, 0))) # 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径; # 迭代 12 T1——relic 令牌 relic_id 随还原) var bp_deck = bp.get("bonus_deck", null) if bp_deck is Array: var restored: Array = [] for t in (bp_deck as Array): var tok: Dictionary = t as Dictionary var rtok := {"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))} if String(rtok["kind"]) == "relic": rtok["relic_id"] = String(tok.get("relic_id", "")) restored.append(rtok) _bonus_deck = restored # 特性持有集还原(迭代 12 T1——缺键 = 旧版断点防御空集) _relics = [] for r in (bp.get("relics", []) as Array): _relics.append(String(r)) for v in (bp.get("bonus_aux_carry", []) as Array): _bonus_treasure_carry.append(String(v)) _keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true) _base_override = _normalize_override(params.get("hero_override", {})) # 生命账本越界钳制(T17——spawn 守卫 EC16 免疫):断点可能由旧口径版本写出(如调试上限 # 调高期带出的账本值 > 新口径生效上限,实例:hero_hp=28 > 基础 26)——恢复后钳到生效上限, # 否则首战 spawn「hero_hp 入参越界」拒收 → 「敌人系统 spawn 被拒」fatal。上限 = 表值+永久 # 强化 flat+加算区 flat 后乘调试浮窗 pct(effective_max_hp 同式——此刻加算区尚未恢复, # 钳制用 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" # 迭代 11 第②批 T3——续玩参数面同 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(迭代 11 第①批 T5) _attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池) _seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义) # 三选一停点续玩分支(迭代 10——选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付) var bp_pending: Dictionary = bp.get("bonus_pending", {}) as Dictionary var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary if not bp_pending.is_empty(): _pending_next_level = true # 候选令牌化迁移(TP0——旧版断点候选为 kind 字符串,按普通档令牌防御归一;单版本周期口径) var migrated: Array = [] for c in (bp_pending.get("candidates", []) as Array): if c is Dictionary: migrated.append({"kind": String((c as Dictionary).get("kind", "")), "tier": String((c as Dictionary).get("tier", ""))}) else: migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON}) _bonus_pending = {"candidates": migrated} if bp_pending.has("aux_detail"): _bonus_pending["aux_detail"] = (bp_pending.get("aux_detail", []) as Array).duplicate(true) # 掷定前置随停点快照续接 var payload: Dictionary = (bp_pending.get("payload", {}) as Dictionary).duplicate(true) if bp_pending.has("incoming"): _bonus_pending["chosen"] = "treasure_item" var incoming: Array = (bp_pending.get("incoming", []) as Array).duplicate(true) _bonus_pending["incoming"] = incoming state = "running" bonus_choice_presented.emit(payload) # 替换在途续发(呈现层直落替换子态——持有件记录按恢复后 carry 重建) treasure_replace_presented.emit(_carry_records(), {"index": -1, "item_id": String(incoming[0]) if not incoming.is_empty() else "", "count": incoming.size()}) return true state = "running" bonus_choice_presented.emit(payload) return true if not bp_result.is_empty(): _pending_next_level = true _bonus_result = bp_result.duplicate(true) state = "running" bonus_resolved.emit(_bonus_result.duplicate(true)) return true _resume_bp = bp.duplicate(true) # 迭代 11 第①批 T6——充能账本随断点回局(缺键 = 旧档/无断点,保持 _seed_attune_charges 种子态) var bp_charges = bp.get("attunement_charges", null) if bp_charges is Dictionary: attune_charges = (bp_charges as Dictionary).duplicate(true) state = "running" return _start_current_level() # 三选一停点态归零(冒险开始/结算收束共用;令牌池按表配额重建——池消耗即上限,账本随池重生) func _reset_bonus_state() -> void: _bonus_taken = {"attack": 0, "max_hp": 0, "armor": 0, "gp": 0, "ap": 0, "heal": 0} _bonus_deck = _build_bonus_deck() _relics = [] _bonus_pending = {} _bonus_result = {} _bonus_treasure_carry = [] _keystone_drops = [] _node_bonus_pending = {} # 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = FEATURE_KINDS×TIERS 逐层, # 随机性只发生在洗牌消费 reward_pick 封闭流的时刻。迭代 12 T1:规则改写型特性带=relic 活行每件 1 张追加 # (rarity common/rare→稀有令牌档、rarest→罕见令牌档——relic 永不入普通档;空表=零追加零扰动)) func _build_bonus_deck() -> Array: var deck: Array = [] for kind in db.adventure.FEATURE_KINDS: for tier in db.adventure.TIERS: for i in int(db.adventure.feature_quota(String(kind), String(tier))): deck.append({"kind": String(kind), "tier": String(tier)}) for row in db.relic_rows(): var rtier := "rarest" if String(row.get("rarity", "")) == "rarest" else "rare" deck.append({"kind": "relic", "tier": rtier, "relic_id": String(row.get("relic_id", ""))}) return deck # 令牌唯一身份(抽签去重/兑付匹配共用——属性加成型特性=kind,规则改写型特性=relic_id;同档多张不同 relic 可同屏) func _token_identity(tok: Dictionary) -> String: if String(tok.get("kind", "")) == "relic": return "relic:" + String(tok.get("relic_id", "")) return String(tok.get("kind", "")) # 调试浮窗百分比通道(测试工具覆盖——每关新 runner 都要应用;正式侧归元进度) func set_hero_override(atk_pct: int, hp_pct: int) -> void: _override = [atk_pct, hp_pct] if _runner != null: _runner.set_hero_override(atk_pct, hp_pct) # 冒险账本侧生效上限(T17 修复——账本置满/回血封顶与战斗内上限同式同源): # (表值 + 永久强化 flat + 加算区 flat)后乘调试浮窗百分比——叠加次序与 LevelRunner.effective_max_hp # 逐字一致。旧口径两处只算 flat 不乘 pct:调生命上限 26→104 时开局血仍 26(账本 26 / 战斗内 104)、 # 通关 20% 回血封顶按 26 算。pct=0(模拟/冒烟默认)时乘法恒等——MC 锚零漂移。 func effective_max_hp() -> int: var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + int(bonus.get("max_hp", 0)) return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0))) # 断点承载注入(迭代 8 卡①——组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默) func set_save_manager(sm) -> void: _save_manager = sm # 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取) func runner() -> LevelRunner: return _runner # 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶)。算术宿主 = LevelRunner.rest_heal #(2026-10-02 修复批——同式双落点字面量退役,与 LevelRunner.rest_heal_amount 同一单点) func apply_rest(pct: int, hp: int, max_hp: int) -> int: return LevelRunner.rest_heal(pct, hp, max_hp) # 大奖励确认 → 进下一关(仅兑付后受理——零自动推进的冒险级停点;三选一未兑付拒绝推进) func continue_to_next_level() -> bool: if not _pending_next_level: push_error("[冒险结构] continue_to_next_level 拒绝——无待确认的关卡推进") return false if not _bonus_pending.is_empty(): push_error("[冒险结构] continue_to_next_level 拒绝——三选一停点未兑付(零自动推进契约)") return false _pending_next_level = false _bonus_result = {} level_index += 1 return _start_current_level() # ---- 提升三选一(迭代 10 奖励重构批——选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)---- # 候选令牌匹配(身份键恒等——属性加成型特性 kind+tier、规则改写型特性 relic_id;迭代 12 T1 规则改写型特性带) func _find_token(candidates: Array, kind: String, tier: String, relic_id: String = "") -> Dictionary: for t in candidates: var tok: Dictionary = t as Dictionary if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier: continue if kind == "relic" and String(tok.get("relic_id", "")) != relic_id: continue return tok return {} # 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(身份键精确匹配——relic 件 # 另须 relic_id)。属性件写加算区、AP 件写第四轴、GP 件走钱包「奖励入账」入口、恢复件当即回血、 # relic 件入特性持有集(🔴 层级纪律:只走局内随机渠道)、道具件受同调位约束(随池种退役不可达)。 # 消费=选中令牌踢池(未选回池由「抽签不摘牌、兑付才摘牌」语义承载——TP0 v2.2)。 func pick_bonus(kind: String, tier: String = "", relic_id: String = "") -> bool: if state != "running" or _bonus_pending.is_empty(): push_error("[冒险结构] pick_bonus 拒绝——无待选停点(state=%s)" % state) return false var candidates: Array = _bonus_pending.get("candidates", []) as Array if _find_token(candidates, kind, tier, relic_id).is_empty(): push_error("[冒险结构] pick_bonus 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, relic_id]) return false var value: int = int(db.adventure.feature_value(kind, tier)) var hp_before := int(ledger.get("hero_hp", 0)) if kind == "treasure_item": return _pick_treasure_item(value) # 道具件分派通关渠道(持有容器 = 跨关携带——与节点渠道分轨;池种退役后不可达) if not _apply_bonus_pick(kind, tier, value, relic_id): push_error("[冒险结构] pick_bonus 拒绝——未知件: %s|%s|%s" % [kind, tier, relic_id]) return false _finish_bonus_pick(kind, value, hp_before, tier, relic_id) return true # 兑付载荷 relic 伴随键(迭代 12 T1——反馈行取数面;非 relic 件零新增键) func _relic_payload_keys(relic_id: String) -> Dictionary: if relic_id == "": return {} var row: Dictionary = db.relic_row(relic_id) return {"relic_id": relic_id, "relic_name": String(row.get("display_name", "")), "relic_archetype": String(row.get("archetype", ""))} # 三选一件兑付共享体(2026-10-02 修复批双胞胎收口;TP0 改令牌消费——摘牌单点;迭代 12 T1 relic 分支): # 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包、恢复件当即回血(封顶生效上限—— # 与账本置满/通关回血同式 effective_max_hp)、relic 件入持有集(散件单体保底——效果接线面 # 按挂点消费,档位质变由 RelicsEngine 求值)。兑付即从 _bonus_deck 摘除该令牌(选中踢池; # 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。 func _apply_bonus_pick(kind: String, tier: String, value: int, relic_id: String = "") -> bool: if kind == "relic" and db.relic_row(relic_id).is_empty(): push_error("[冒险结构] relic 兑付失败——表无此件: %s" % relic_id) return false # 先校验后摘牌(令牌零丢失) if not _consume_deck_token(kind, tier, relic_id): push_error("[冒险结构] 令牌消费失败——牌库无此令牌: %s|%s|%s" % [kind, tier, relic_id]) return false if kind == "relic": if not _relics.has(relic_id): _relics.append(relic_id) _bonus_taken["relic"] = int(_bonus_taken.get("relic", 0)) + 1 return true match kind: "attack": bonus["atk"] = int(bonus.get("atk", 0)) + value _bonus_taken["attack"] = int(_bonus_taken.get("attack", 0)) + 1 "max_hp": bonus["max_hp"] = int(bonus.get("max_hp", 0)) + value _bonus_taken["max_hp"] = int(_bonus_taken.get("max_hp", 0)) + 1 "armor": bonus["armor_class"] = int(bonus.get("armor_class", 0)) + value _bonus_taken["armor"] = int(_bonus_taken.get("armor", 0)) + 1 "ap": bonus["ap"] = int(bonus.get("ap", 0)) + value _bonus_taken["ap"] = int(_bonus_taken.get("ap", 0)) + 1 "gp": if _runner != null: _runner.grant_bonus_gp(value) _bonus_taken["gp"] = int(_bonus_taken.get("gp", 0)) + 1 "heal": ledger["hero_hp"] = mini(int(ledger.get("hero_hp", 0)) + value, effective_max_hp()) _bonus_taken["heal"] = int(_bonus_taken.get("heal", 0)) + 1 _: return false return true # 从牌库摘除一枚令牌(TP0——选中踢池单点;迭代 12 T1 relic 按 relic_id 精确摘;找不到 = false) func _consume_deck_token(kind: String, tier: String, relic_id: String = "") -> bool: for i in _bonus_deck.size(): var tok: Dictionary = _bonus_deck[i] as Dictionary if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier: continue if kind == "relic" and String(tok.get("relic_id", "")) != relic_id: continue _bonus_deck.remove_at(i) return true return false # 特性持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写) func relics_held() -> Array: return _relics.duplicate() # ---- 商店共享池消费面(迭代 11 第②批 T3——P1 共享池单点账本;TP0 令牌池换代——库存=牌库剩余令牌)---- # 库存余量(TP0:stock = 牌库内该 kind 剩余令牌数——「池消耗即上限」,种类次数上限退役; # 奖励三选一兑付摘牌 与 商店购买消费 由同一 _bonus_deck 承载——两渠道跨关共库,全冒险一次性)。 func pool_stock(kind: String) -> int: var n := 0 for t in _bonus_deck: if String((t as Dictionary).get("kind", "")) == kind: n += 1 return n # 商店购买消费 handler(LevelRunner.buy_pool_item 经 Callable 调用——ADR-0001 S6 方法引用): # 消费在架令牌本体(kind+tier 精确——货架卡即令牌,TP0 令牌级货架)→ _apply_bonus_pick 同口 # 写加算区+账本+摘牌(与奖励三选一真单点——买一件=奖励面少一件的库存语义由同库成立)。 # 未知 kind 拒绝(货架词表封闭的前置防御)。 func _consume_pool_purchase(kind: String, tier: String) -> bool: if not db.adventure.FEATURE_KINDS.has(kind): push_error("[冒险结构] 商店购买消费拒绝——件不在池词表: %s" % kind) return false return _apply_bonus_pick(kind, tier, int(db.adventure.feature_value(kind, tier))) # 终局池快照(_settle 清账前调用——_reset_bonus_state 会重置 _bonus_taken,摘要 additive 键 # pool_stock 须在此前取数;货架四种 stock=cap−taken,读数面 = 商店买走 vs 奖励领走 vs 枯竭) func _pool_stock_snapshot() -> Dictionary: var out := {} for row in db.shop_pool_rows(): var kind := String(row.get("kind_id", "")) out[kind] = pool_stock(kind) return out # 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_treasure_replace)。 # 掷定前置(追加批二):具体道具随候选抽定(_last_draw_treasure / _bonus_pending.aux_detail)——本函数消费预掷值, # 断点续玩/无预掷值时回退当场掷定(流序不同但消费数不变,确定性由调用序保证)。 # 持有容器 = 跨关携带 _bonus_treasure_carry(与节点渠道的关卡域库存分轨——容器差异保守不合,注记在案)。 func _pick_treasure_item(count: int) -> bool: var picked: Array = _draw_pending_treasure(_bonus_pending, count, true) if picked.is_empty(): push_error("[冒险结构] pick_bonus(treasure_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)") return false var hp_before := int(ledger.get("hero_hp", 0)) # 迭代 11 第①批 T7——同调位守卫随过渡键退役拆除(aux 卡出池后本路径不可达;能力件容量闸 # 正主 = 营地装配面 Attunement.slots 满位禁选) for iid in picked: _bonus_treasure_carry.append(String(iid)) _finish_bonus_pick("treasure_item", picked.size(), hp_before) return true # 替换子交互受理(discard_index = -1 放弃新件不获取;否则丢该件腾位收新)——终态恒 ≤ 同调位 # 持有容器 = 跨关携带(序号寻址);与节点渠道(库存句柄寻址)的丢弃/授予/收尾三步保守不合。 func resolve_treasure_replace(discard_index: int) -> bool: if not _replace_in_transit(_bonus_pending): push_error("[冒险结构] resolve_treasure_replace 拒绝——无替换在途") return false var incoming: Array = _bonus_pending.get("incoming", []) as Array var hp_before := int(ledger.get("hero_hp", 0)) if discard_index >= 0: if discard_index >= _bonus_treasure_carry.size(): push_error("[冒险结构] resolve_treasure_replace 拒绝——序号不在持有集: %d" % discard_index) return false _bonus_treasure_carry.remove_at(discard_index) for iid in incoming: _bonus_treasure_carry.append(String(iid)) # 放弃新件(discard_index < 0)= 原持有集不动、新件不获取 _finish_bonus_pick("treasure_item", incoming.size(), hp_before) return true # 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。 # picked = kind;tier 随载荷(TP0——大小档位读数面);relic 伴随键随载荷(迭代 12 T1——反馈行取数) func _finish_bonus_pick(picked: String, value: int, hp_before: int, tier: String = "", relic_id: String = "") -> void: _bonus_pending = {} _bonus_result = { "picked": picked, "tier": tier, "value": value, "hp_before": hp_before, "hp_after": int(ledger.get("hero_hp", 0)), "level_index": level_index, } _bonus_result.merge(_relic_payload_keys(relic_id)) bonus_resolved.emit(_bonus_result.duplicate(true)) _snapshot_breakpoint() # ---- 奖励节点三选一(迭代 10 追加批——用户拍板:与通关三选一共享同池同权重同次数上限)---- # 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载, # 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。 # 掷定前置(追加批二):返回 dict 带 aux_detail(道具件具体道具)供卡片直显。 func node_bonus_candidates() -> Dictionary: var cands: Array = _draw_bonus_candidates() return {"candidates": cands, "aux_detail": _last_draw_treasure.duplicate()} func present_node_bonus(candidates: Array, treasure_detail: Array = []) -> void: _reset_refresh_x() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转) _node_bonus_pending = {"candidates": candidates.duplicate(true), "aux_detail": treasure_detail.duplicate(true)} # 点选兑付(节点域——无部分回血无钥石;令牌消费与牌库摘牌与通关三选一同库;relic 同权入持有集) func apply_node_bonus(kind: String, tier: String = "", relic_id: String = "") -> bool: if _node_bonus_pending.is_empty(): push_error("[冒险结构] apply_node_bonus 拒绝——无待选停点") return false var candidates: Array = _node_bonus_pending.get("candidates", []) as Array if _find_token(candidates, kind, tier, relic_id).is_empty(): push_error("[冒险结构] apply_node_bonus 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, relic_id]) return false var value: int = int(db.adventure.feature_value(kind, tier)) if kind == "treasure_item": return _apply_node_treasure(value) # 道具件分派节点渠道(持有容器 = 关卡域库存——与通关渠道分轨;池种退役后不可达) if not _apply_bonus_pick(kind, tier, value, relic_id): push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s|%s|%s" % [kind, tier, relic_id]) return false _finish_node_bonus(kind, value, tier, relic_id) return true # 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用); # 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨,容器语义保守不合)。 # 掷定前置:消费 _node_bonus_pending.aux_detail 预掷值(缺省回退当场掷定)。 func _apply_node_treasure(count: int) -> bool: var picked: Array = _draw_pending_treasure(_node_bonus_pending, count, false) if picked.is_empty(): push_error("[冒险结构] apply_node_bonus(treasure_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)") return false var records: Array = [] if _runner != null and _runner.treasure_inventory != null: records = _runner.treasure_inventory.holding_records() # 迭代 11 第①批 T7——守卫随过渡键退役拆除(FEATURE_KINDS 已除名 treasure_item,本分支不可达; # 替换子交互路由随容量闸一并退役——能力件容量闸正主 = 营地装配面满位禁选) if _runner != null: for iid in picked: var rec: Dictionary = _runner.grant_treasure_item(String(iid), "reward_node") if rec.is_empty(): push_warning("[冒险结构] 奖励节点道具授予被拒(防御兜底留痕): %s" % String(iid)) _finish_node_bonus("treasure_item", picked.size()) return true func _resolve_node_replace(discard_handle: int) -> bool: if not _replace_in_transit(_node_bonus_pending): push_error("[冒险结构] resolve_node_replace 拒绝——无替换在途") return false var incoming: Array = _node_bonus_pending.get("incoming", []) as Array if discard_handle >= 0: if _runner == null or _runner.treasure_inventory == null or not _runner.treasure_inventory.discard_item(discard_handle): push_error("[冒险结构] resolve_node_replace 拒绝——句柄不在持有集: %d" % discard_handle) return false for iid in incoming: var rec: Dictionary = _runner.grant_treasure_item(String(iid), "reward_node") if rec.is_empty(): push_warning("[冒险结构] 替换子道具授予被拒(防御兜底留痕): %s" % String(iid)) # 放弃新件(discard_handle < 0)= 原持有集不动、新件不获取 _finish_node_bonus("treasure_item", incoming.size()) return true # 冒险开始充能账本重置(T6——装备 active_charged 件各回 charges_per_adventure;被动件不入账) func _seed_attune_charges() -> void: attune_charges = {} for iid in _attunement: var arow: Dictionary = db.treasure_item_row(String(iid)) if String(arow.get("form", "")) == "active_charged": attune_charges[String(iid)] = maxi(0, int(arow.get("charges_per_adventure", 0))) # ---- 三选一刷新(迭代 11 第①批 T5b——P1 甲 X/Y 双源:X=刷新件每碰三选一重给〔优先消耗、不结转〕、 # Y=营地升级行冒险周期池〔用完即无〕;重抽消费 reward_pick 既有洗牌流,池规则全保留——不放回/ # 达上限剔牌/掷定前置直显;停点可中断)---- func _attune_refresh_x_total() -> int: return int(db.treasure_item_row("pick_refresh").get("magnitude", 0)) if _attunement.has("pick_refresh") else 0 func _reset_refresh_x() -> void: _attune_refresh_x = _attune_refresh_x_total() # 当前可刷新次数(X 优先 + Y 池——呈现层余量读数口) func attune_refresh_left() -> int: return _attune_refresh_x + _attune_refresh_y # 节点停点在途态读回(组合根重 present 用——candidates/aux_detail 值拷贝) func node_bonus_pending_state() -> Dictionary: return { "candidates": (_node_bonus_pending.get("candidates", []) as Array).duplicate(true), "aux_detail": (_node_bonus_pending.get("aux_detail", []) as Array).duplicate(true), } # 三选一刷新(组合根唯一调用口;无停点/无余量 false 零副作用)。通关停点重抽后重发 # bonus_choice_presented(呈现层重建卡片+刷新余量);节点停点由组合根读 node_bonus_pending_state 重 present。 func refresh_bonus() -> bool: if _attune_refresh_x <= 0 and _attune_refresh_y <= 0: return false if not _bonus_pending.is_empty(): if _attune_refresh_x > 0: _attune_refresh_x -= 1 else: _attune_refresh_y -= 1 var cands: Array = _draw_bonus_candidates() _bonus_pending["candidates"] = cands.duplicate(true) _bonus_pending["aux_detail"] = _last_draw_treasure.duplicate() var payload: Dictionary = (_bonus_pending.get("payload", {}) as Dictionary).duplicate(true) payload["candidates"] = cands payload["aux_detail"] = _last_draw_treasure.duplicate() bonus_choice_presented.emit(payload) # 同信号重 present(零自动推进契约不破——停点仍在途) _snapshot_breakpoint() return true if not _node_bonus_pending.is_empty(): if _attune_refresh_x > 0: _attune_refresh_x -= 1 else: _attune_refresh_y -= 1 var cands2: Array = _draw_bonus_candidates() _node_bonus_pending["candidates"] = cands2.duplicate(true) _node_bonus_pending["aux_detail"] = _last_draw_treasure.duplicate() return true return false # 替换在途守卫(2026-10-02 修复批——双渠道替换受理的共用前置谓词:停点态非空且 chosen=treasure_item) func _replace_in_transit(pending: Dictionary) -> bool: return not pending.is_empty() and String(pending.get("chosen", "")) == "treasure_item" # 双渠道道具件掷定共享体(2026-10-02 修复批——_pick_treasure_item/_apply_node_treasure 复制债收口一步): # 解锁道具池空集守卫 + N 件掷定(先消费停点预掷 aux_detail,不足部分当场经 reward_pick 封闭流补掷—— # 取数序与拆分前逐位一致)。返回空数组 = 池空集(调用方按渠道名报错)。 # 保守不合处:通关渠道允许回退 _last_draw_treasure(allow_detail_fallback)、节点渠道不回退——掷定回退 # 语义渠道各异,参数化保留差异;持有容器与兑付收尾仍归调用方渠道。 func _draw_pending_treasure(pending: Dictionary, count: int, allow_detail_fallback: bool) -> Array: var pool: Array = _bonus_treasure_pool() if pool.is_empty(): return [] var picked: Array = [] var detail: Array = (pending.get("aux_detail", []) as Array).duplicate() if detail.is_empty() and allow_detail_fallback: detail = _last_draw_treasure.duplicate() for i in maxi(1, count): if i < detail.size(): picked.append(String(detail[i])) else: picked.append(_roll_bonus_treasure_item(pool)) return picked # 替换受理分发(单入口——节点停点在途优先按库存句柄,否则按通关停点的携带序号) func resolve_bonus_replace(discard: int) -> bool: if not _node_bonus_pending.is_empty() and _node_bonus_pending.has("incoming"): return _resolve_node_replace(discard) return resolve_treasure_replace(discard) func _finish_node_bonus(picked: String, value: int, tier: String = "", relic_id: String = "") -> void: _node_bonus_pending = {} var payload := { "context": "reward_node", "picked": picked, "tier": tier, "value": value, "level_index": level_index, } payload.merge(_relic_payload_keys(relic_id)) bonus_resolved.emit(payload) # 令牌池洗牌不放回抽 3(TP0 v2.2——Fisher-Yates 全库洗牌后按序收集;**同 kind 去重每 kind 至多 # 一枚**〔先到先得,档位取该 kind 首枚——「三选一=三种不同选项」体验延续,与货架抽签同规则〕; # kind 出现率∝剩余令牌数〔档位配额即分布——无独立权重 knob〕;抽签不摘牌、兑付才摘牌=未选 # 自动回池;池耗即枯竭——余库不足 N kind 时出齐剩余,空库 = 零候选合法态)。候选 = 令牌字典 # {"kind","tier"}。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同候选;道具件掷定前置 # 随池种退役拆除(_last_draw_treasure 恒空集,机械惰性承载)。 func _draw_bonus_candidates() -> Array: _last_draw_treasure = [] var pool := _bonus_deck.duplicate() var rg: RandomNumberGenerator = rng.open_stream("reward_pick") for i in pool.size(): var j := rg.randi_range(i, pool.size() - 1) var tmp = pool[i] pool[i] = pool[j] pool[j] = tmp var out: Array = [] var seen: Array = [] for tok in pool: var ident := _token_identity(tok) if seen.has(ident): continue seen.append(ident) var cand := {"kind": String((tok as Dictionary).get("kind", "")), "tier": String((tok as Dictionary).get("tier", ""))} if ident.begins_with("relic:"): cand["relic_id"] = String((tok as Dictionary).get("relic_id", "")) out.append(cand) if out.size() >= int(db.adventure.feature_candidate_count): break return out func _roll_from(rg: RandomNumberGenerator) -> String: var pool: Array = _bonus_treasure_pool() return String(pool[rg.randi_range(0, pool.size() - 1)]) # 三选一道具件随机池(与投放门控同源:aux 活行 ∩ 解锁门控集——空集 = 道具件不入牌库) func _bonus_treasure_pool() -> Array: var out: Array = [] var live: Dictionary = db.treasure_live_ids() for iid in live.keys(): if _treasure_unlocked.has(String(iid)): out.append(String(iid)) return out func _roll_bonus_treasure_item(pool: Array) -> String: var rg: RandomNumberGenerator = rng.open_stream("reward_pick") return String(pool[rg.randi_range(0, pool.size() - 1)]) func _carry_records() -> Array: var out: Array = [] for i in _bonus_treasure_carry.size(): out.append({"index": i, "item_id": _bonus_treasure_carry[i]}) return out # Boss 钥石逐杀掉落(迭代 10——渠道改 Boss/精英击杀掉落;首杀/复杀两档读 meta.tres boss_keystone; # 首杀判据 = 存档 boss_first_kill 标记位〔断点未注入 = 恒首杀——模拟/冒烟零扰动;标记仍在收束 # apply_adventure_summary 逐通关关次置位〕;末关 Boss 照常入账;明细随摘要 additive 键供结算面归因) func _boss_keystone_drop(li: int) -> void: var first := true if _save_manager != null: first = not _save_manager.boss_first_kill_marked(li) var row: Dictionary = db.meta_boss_keystone_row(li) var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0)) _keystone_drops.append({"level": li, "first": first, "amount": amount}) keystone += amount # 主动放弃(仅非战斗期受理——战斗域单事务在飞;与失败同型结算,已获大奖励不追回。 # MVP 无界面入口,语义先行预留;结算受理只算一次——非 running 态拒绝) func abandon() -> bool: if state != "running": push_error("[冒险结构] abandon 拒绝——state=%s(非进行中不可放弃)" % state) return false if _runner != null and _runner.current_battle() != null: push_error("[冒险结构] abandon 拒绝——战斗进行中(战斗域单事务在飞)") return false _settle("abandoned") return true # ---- 关卡推进(私有——序列编排单一出口)---- func _start_current_level() -> bool: _runner = LevelRunner.new(db, rng) # core↔core 接线只许方法 Callable(ADR-0001 S6——lambda 捕获 self 会延长 RefCounted 生命周期乃至成环) _runner.node_entered.connect(_forward_node_entered) _runner.choices_presented.connect(_forward_choices_presented) _runner.step_row.connect(_forward_step_row) _runner.round_started.connect(_forward_round_started) _runner.board_changed.connect(_forward_board_changed) _runner.wallet_changed.connect(_forward_wallet_changed) _runner.level_finished.connect(_on_level_finished) _runner.set_hero_override(int(_override[0]), int(_override[1])) # 断点续玩分支(迭代 8 卡①):模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带 # 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。 var resume_state: Dictionary = {} var template_pick: int = 0 if _first_script else -1 # 首次脚本 = 每关固定模板 0 零抽签(v0.4 拍板点①);否则逐关抽签 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_* 叠加序第二项)。 # 停点前移批 2026-09-23:defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在 # 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。 # 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。 var ok: bool = _runner.start_level_external(level_index, ledger, bonus, purchased, Callable(self, "apply_rest"), _base_override, true, template_pick, resume_state) if ok: _runner.set_attunement(_attunement) # 迭代 11 第①批 T5——同调装配下传(enc 携带进战斗结算) _runner.set_attune_charges(attune_charges) # 迭代 11 第①批 T6——充能账本下传(能力件骑库存面注入) _runner.set_relics(_relics) # 迭代 12 T1——特性持有集下传(begin_battle enc.relics 求值面) _runner.set_tree_rules(_tree_rules) # 迭代 12 T10——树 rule 键集下传(A/B 面消费) if _shop_mode == "pool": # 商店共享池注入(迭代 11 第②批 T3——库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动) _runner.set_shop_pool(_bonus_deck, Callable(self, "_consume_pool_purchase")) # 三选一道具件跨关携带注入(迭代 10)——迭代 11 第①批 T7 退役:aux 卡出池后 carry 恒空集, # 注入面随停点渠道退役拆除(容器字段保留=替换子交互机械惰性承载,零投放零行为) level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) # 入口停点断点(node_id=-1)与 defer_entry 同态(node_index=-1+单候选)——走入口呈现; # 中途停点(node_id>=0)走停点呈现(restore_treasure_items 先于呈现=C 区库存就位)。 if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0: _runner.present_entry_choice() else: _runner.restore_treasure_items(_resume_bp.get("aux_items", []) as Array) _runner.present_resumed_choice() _resume_bp = {} return ok # 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次) func _on_level_finished(payload: Dictionary) -> void: if state != "running": push_error("[冒险结构] 重复关卡结束宣布拒绝——state=%s(账本与摘要零变化)" % state) return _last_level_payload = payload.duplicate(true) var result := String(payload.get("result", "")) if result == "defeat": ledger["hero_hp"] = 0 # 倒下记 0(生命账本战后回写口径) _settle("failed") return if result != "victory": push_error("[冒险结构] 关卡结束结果出域: %s" % result) return _last_treasure_cleared = int(payload.get("aux_cleared", 0)) # additive(迭代 7 T4——末关 summary 取数面) # Boss 钥石逐杀掉落(迭代 10——渠道改击杀掉落,末关照常入账;首杀/复杀读 meta.tres boss_keystone) _boss_keystone_drop(level_index) if level_index < levels_total: # 通关兑付(非末关恰一次;迭代 10 三层奖励框架):部分回血先行 → 加权洗牌掷 3 → 三选一停点 #(顺序即契约——回血按当刻账本与上限结算,选择停点零自动推进) var hp_before := int(ledger.get("hero_hp", 0)) var max_eff := effective_max_hp() # T17 修复:回血封顶口径含调试浮窗 pct(同战斗内上限) var heal := mini(int(floor(float(max_eff) * float(int(db.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before) ledger["hero_hp"] = hp_before + heal # 置位先于 emit(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位) _pending_next_level = true _reset_refresh_x() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转) var pick_payload: Dictionary = { "level_index": level_index, "candidates": _draw_bonus_candidates(), "aux_detail": _last_draw_treasure.duplicate(), # 掷定前置(追加批二——道具件具体道具随候选抽定,卡片直显) "heal_gained": heal, "hp_before": hp_before, "hp_after": int(ledger.get("hero_hp", 0)), "keystone_gained": int(_keystone_drops.back().get("amount", 0)) if not _keystone_drops.is_empty() else 0, "keystone_total": keystone, "gp": int(ledger.get("gp", 0)), "aux_cleared": int(payload.get("aux_cleared", 0)), # additive 扩签(迭代 7 T4——关卡末清空反馈取数) } _bonus_pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true), "aux_detail": _last_draw_treasure.duplicate(), "payload": pick_payload.duplicate(true)} bonus_choice_presented.emit(pick_payload) _snapshot_breakpoint() # 选择停点可中断(契约 5——续玩不重抽不静默丢失) return # 末关通关:不发三选一与部分回血(没有下一战可受益)、Boss 钥石照常入账——冒险完成结算 _settle("completed") func _settle(result: String) -> void: # 冒险摘要五件事:打穿没有 / 通关几关 / 止步第几关 / 钥石入账合计 / 本局主种子 summary = { "result": result, "cleared": result == "completed", "levels_cleared": levels_total if result == "completed" else level_index - 1, "stopped_at": 0 if result == "completed" else level_index, "keystone_total": keystone, "master_seed": master_seed, "aux_cleared_last": _last_treasure_cleared, # additive 扩签(迭代 7 T4——末关清空反馈;非末关走 bonus 载荷) "keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(迭代 10——逐杀掉落明细;settle 入账明细/复盘归因数据源) "pool_stock": _pool_stock_snapshot(), # additive 扩签(迭代 11 第②批 T5——终局池读数:清账前快照,商店买走 vs 奖励领走 vs 枯竭的终局面) "relics": _relics.duplicate(), # additive 扩签(迭代 12 T1——特性终局持有集:清账前快照,成型判读取数面) } # 败退明细(数值验证工具取数面 2026-09-22——tools/balance_verifier.py 止步节点分布数据源; # 仅失败局附加,键缺省 = 未败退,既有五件事消费方零感知) if result == "failed" and not _last_level_payload.is_empty(): summary["defeat"] = { "level_index": level_index, "node_id": int(_last_level_payload.get("node_id", -1)), "node_type": String(_last_level_payload.get("node_type", "")), } if _runner != null: _runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现) _reset_bonus_state() # 三选一停点态收束清零(结算受理只算一次语义——重复宣布零变化) if _save_manager != null: _save_manager.clear_breakpoint() # 三结局清断点(迭代 8 卡①/T3——退出冒险=放弃流程+清除断点) _save_manager.write_to_disk() state = "settling" adventure_finished.emit(summary) state = "idle" # ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明)---- func _forward_node_entered(node: Dictionary, info: Dictionary) -> void: node_entered.emit(node, info) func _forward_choices_presented(candidates: Array) -> void: choices_presented.emit(candidates) _snapshot_breakpoint() # 停点快照(迭代 8 卡①——选路停点=唯一可恢复停点形态;战斗中不存) # ---- 断点快照(迭代 8 卡①——七字段拍板面+恢复必需附随件)---- # 七字段(拍板面):关卡序号/节点位/生命/属性加成区/GP 钱包/商店已购档/已抽模板; # 附随件(续玩正确性必需,随批注记):主种子+取数计数器(rng 快照契约回填——续接原时间线)、 # 钥石累计(大奖励中途值——终局摘要五件事不失真)、已到节点序(呈现层图条重建)、道具库存 # (奖励节点已选件——防静默丢失)。注入缺省 null = 整面静默(模拟/冒烟零扰动)。 func _snapshot_breakpoint() -> void: if _save_manager == null or state != "running": return var treasure_snapshot: Array = [] if _runner != null and _runner.treasure_inventory != null: treasure_snapshot = _runner.treasure_inventory.snapshot_inventory() # 三选一停点续玩分支 _runner 为 null(关卡已收束、下一关未开)——节点域字段按缺省落档, # 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照,迭代 10) var bp: Dictionary = { "level_index": level_index, "node_id": _runner.current_node_id() if _runner != null else -1, "hero_hp": int(ledger.get("hero_hp", 0)), "bonus": { "atk": int(bonus.get("atk", 0)), "max_hp": int(bonus.get("max_hp", 0)), "armor_class": int(bonus.get("armor_class", 0)), "ap": int(bonus.get("ap", 0)), }, "gp": int(ledger.get("gp", 0)), "shop_purchased": purchased.duplicate(true), "shop_shelf": _runner.shop_bp_shelf() if _runner != null else [], # 迭代 11 第②批 T2——P2 甲货架入断点(离开店/非店停点 = 空架合法态) "shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 迭代 11 第②批 T2——本次进店已购次数(A 链定价输入,P2 甲扩列) "attunement_charges": attune_charges.duplicate(true), # 迭代 11 第①批 T6——同调主动件充能余量随断点(save v4 域) "template_pick": _runner.active_template_index if _runner != null else -1, "master_seed": master_seed, "rng_counter": rng.counter(), "keystone": keystone, "visited_ids": _runner.visited_ids() if _runner != null else [], "aux_items": treasure_snapshot, "bonus_taken": _bonus_taken.duplicate(true), # 三选一件获取次数(kind 维度派生账本——断点附随件与读数承载) "bonus_deck": _bonus_deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) "relics": _relics.duplicate(), # 特性持有集(迭代 12 T1——局内随机渠道获得态续玩防丢失) "bonus_pending": _bonus_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约) "bonus_result": _bonus_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付) "bonus_aux_carry": _bonus_treasure_carry.duplicate(true), # 道具件跨关携带(注入前中断防静默丢失) "keystone_drops": _keystone_drops.duplicate(true), # 逐杀掉落明细(摘要归因续接——迭代 10) } _save_manager.set_breakpoint(bp) _save_manager.write_to_disk() func _forward_step_row(row: Dictionary) -> void: step_row.emit(row) func _forward_round_started(ri: int) -> void: round_started.emit(ri) func _forward_board_changed() -> void: board_changed.emit() func _forward_wallet_changed(event: String, gp_val: int, extra: Dictionary) -> void: wallet_changed.emit(event, gp_val, extra)