## VERTICAL SLICE - NOT FOR PRODUCTION ## 冒险结构编排器:三关序列、生命/GP 账本、三选一停点与三结局结算唯一所有者,headless 可跑完整冒险(垂直切片验证:一条命走完三关);令牌池与凭证判定归 CurioDeck 件。 ## Core 层禁 UI 引用;数值零字面量只读 db;core↔core 连信号一律方法 Callable,禁 lambda 捕获 self(RefCounted 成环)。 ## 主种子恰一次注入归本类(LevelRunner 不重播种);set_save_manager 缺省 null = 断点面整体静默(模拟/冒烟零扰动)。 ## 呈现层须在 level_started 后重取 runner()(每关新建实例)——先重取再开选路口,否则被空防御丢弃。 class_name AdventureRunner extends RefCounted signal level_started(level_index: int, display_name: String) ## 每关开始(含冒险首关)——呈现层重取 runner()、刷新图条与层名行 signal curio_choice_presented(payload: Dictionary) ## 专长三选一停点(非末关的关卡完成恰一次;通道白名单后候选恒专长+回血在载荷)——{level_index, candidates, heal_gained, hp_before, hp_after, keystone_gained, keystone_total, gp, aux_cleared} signal curio_resolved(result: Dictionary) ## 三选一兑付完成(pick_curio/替换受理后恰一次)——{picked, value, hp_before, hp_after, level_index} signal adventure_finished(summary: Dictionary) ## 冒险结算(completed/failed/abandoned)——summary 五件事:打穿没有/完成几关/止步第几关/钥石合计/主种子(+additive keystone_drops) # ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明;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 enum State { IDLE, RUNNING, SETTLING } # 会话状态机(内部 enum,外部 String 契约经 getter 词表) var _state: State = State.IDLE var state: String: # 外部契约面(只读——外部零写点实证;词表 "idle"/"running"/"settling") get: return State.keys()[_state].to_lower() var ledger := {"hero_hp": 0, "gp": 0} # 冒险账本(生命 + GP 钱包——经 start_level_external 注入 runner 共享) var run_ledger := RunLedger.new() # 共享四本账(attune_charges/vision_counter/adventure_counters/bonus 单类持账) var purchased := {} # product_id → 已购档数(跨关延续、冒险收束清零——递增定价输入) var level_index := 0 # 当前关卡序号(1 起) var levels_total := 0 # 序列总关数(读 level 表行数) var keystone := 0 # 钥石入账累计(摘要第三件事的账面) var master_seed := 0 # 本局主种子(摘要第五件事) # 两根类型轴冒险开始快照(契约 8:营地选中态经参数面注入、冒险开始后锁死; # 公有=参数面快照的可断言面〔契约 9 写口唯一,本字段只读不改〕。空串=未注入→消费面回退锻造配置默认值) var hero_attack_type := "" var hero_armor_type := "" 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} # 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板) var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面) var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面) var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner) var _attune_refresh_x := 0 # 刷新件余量(每碰三选一重置=X 表值;优先消耗、不结转) var _attune_refresh_y := 0 # Y 冒险周期池(开局参数面注入,用完即无——营地升级行表值) var _curio_paid_refreshes := 0 # 本次停点已付费刷新次数(对齐商店 MC-1——GP 刷新价曲线输入,随停点创建清零=与商店进店清零同构) # ---- 凭证件三本账(奇物.md 凭证件表 5 件;F-1 刷新写错账修复承载面)---- var _refresh_stock := 0 # 刷新存量账(冒险级——刷新券加仓;**不经 _attune_refresh_x**:X 每停点重置会吞存量=写错账缺陷 F-1) var _pending_rap := 0 # 储备金袋待注入(节点级——下一战 RAP +N,战首注入后清零) var _time_sand_queue := 0 # 时沙待注入(回合级——下一战首轮 AP +N,战首入队后清零) var _save_manager = null # 断点承载注入(null = 不写断点;模拟/冒烟缺省) var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) # ---- 三选一停点态 ---- var _curio: CurioDeck = null # 令牌池件(_init 建;多重集/抽签/cap/凭证判定唯一宿主) var _curio_taken: Dictionary[Variant, Variant]: # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载) get: return _curio.taken set(v): _curio.taken = v var _curio_deck: Array[Variant]: # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};商店货架同库共账) get: return _curio.deck set(v): _curio.deck = v var _feats: Array[String] = [] # 特性持有集(局内随机渠道获得、冒险级跨关携带、收束清零;🔴 层级纪律承载面:不入同调位不入局外树) var _tree_rules: Array[Variant] = [] # 树 rule 键集(Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面) var _curio_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点 var _curio_result := {} # 已兑付待推进态(curio_resolved 载荷副本——中断续玩不重兑付) var _keystone_drops: Array[Variant] = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源) var _node_curio_pending := {} # 奖励节点三选一停点态(共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽) var _feat_ticket_pity: int: # 专长凭证保底计数(T5——展示次数:节点开面/货架抽签/刷新重展各计一次;触发清零) get: return _curio.ticket_pity set(v): _curio.ticket_pity = v var _feat_ticket_sold: int: # 专长凭证限购计数(T5——选中/买下即 +1;拿到即开不进持有集) get: return _curio.ticket_sold set(v): _curio.ticket_sold = v var _shop_mode := "legacy" # 商店模式("legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool) func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: db = db_ref rng = rng_ref _curio = CurioDeck.new(db, rng) _curio.bind_handlers(_apply_curio_pick, _redeem_feat_ticket) # ---- 冒险生命周期 ---- # 冒险开始(主种子注入恰一次——全冒险唯一 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} run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} purchased = {} keystone = 0 summary = {} _reset_curio_state() levels_total = db.level_count() # 表访问收口(关数读 level 表行数) level_index = 1 _pending_next_level = false _base_override = _normalize_override(params.get("hero_override", {})) hero_attack_type = String(params.get("attack_type", "")) # 两根类型轴快照(契约 8 锁死:开局后不随存档选中变化) hero_armor_type = String(params.get("armor_type", "")) _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动) _treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {} _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive _attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(Y 冒险周期池) _seed_attune_charges() # 冒险开始充能账本按装备主动件重置(每冒险充能语义) run_ledger.vision_counter = {"used": 0} # 真视使用计数冒险开始归零(跨局累计归生涯档) # 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓: # 装配集 kind 分发扫描;只入存量账 _refresh_stock、临时账不动=V8 契约;resume 不重注入〔存量随断点已在〕) for iid_v2 in _attunement: var rp_row: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v2)) if (rp_row.get("effect_kinds", []) as Array).has("refresh_stock_grant"): _refresh_stock += int(rp_row.get("magnitude", 0)) # 特性开局注入(MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派 # 各 2 件档一成型——测量面专用;真实局规则改写型特性只走局内随机渠道,红线②层级纪律不破〕) var feat_seed: Array[Variant] = 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 [] # A/B 面 rule 键集 if not feat_seed.is_empty(): _feats = [] for r in feat_seed: _feats.append(String(r)) ledger["hero_hp"] = effective_max_hp() # 生命账本置满(生效上限口径:含调试浮窗 pct,与战斗内同源) _state = 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 # 断点续玩(「继续上局」入口;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 if int(bp.get("graph_gen_v2", 0)) != 1: # 旧代差断点(模板池时代的 node_id 与生成图句柄空间可碰撞错位)——显式拒绝, # 禁按新语义静默回放;旧模板图已随模板池退役,无迁移动作可做(凭空捏图=兜底图红线)。 push_error("[冒险结构] resume_adventure 拒绝——断点缺生成图代差标记(graph_gen_v2),属换代前旧断点") return false master_seed = int(bp.get("master_seed", 0)) rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0))) _resume_ledger(bp) _resume_curio_state(bp) _resume_params(bp, params) _resume_counters(bp) var bp_pending: Dictionary[Variant, Variant] = bp.get("bonus_pending", {}) as Dictionary var bp_result: Dictionary[Variant, Variant] = bp.get("bonus_result", {}) as Dictionary if not bp_pending.is_empty(): _resume_pending(bp_pending) return true if not bp_result.is_empty(): _pending_next_level = true _curio_result = bp_result.duplicate(true) _state = State.RUNNING curio_resolved.emit(_curio_result.duplicate(true)) return true _resume_bp = bp.duplicate(true) # 充能账本随断点回局(缺键 = 旧档/无断点,保持 _seed_attune_charges 种子态) var bp_charges = _bp_read("attunement_charges", bp) if bp_charges is Dictionary: run_ledger.attune_charges = (bp_charges as Dictionary).duplicate(true) _state = State.RUNNING return _start_current_level() # 续玩账面回填(账本/加算区/已购档/钥石/关序——段序与原读侧一致;bonus 子键 clamp 类夹 ≥0) func _resume_ledger(bp: Dictionary) -> void: ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))} var bp_bonus: Dictionary[Variant, Variant] = bp.get("bonus", {}) as Dictionary run_ledger.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() # 表访问收口(关数读 level 表行数) level_index = int(bp.get("level_index", 1)) _pending_next_level = false # 续玩三选一态回填(池与件计数归件、停点态归本类;dual_merge/keep_on_null 策略见 BREAKPOINT_FIELDS) func _resume_curio_state(bp: Dictionary) -> void: _reset_curio_state() var bp_taken: Dictionary[Variant, Variant] = bp.get("bonus_taken", {}) as Dictionary for id in _curio_taken.keys(): _curio_taken[id] = maxi(0, int(bp_taken.get(id, 0))) for id in bp_taken.keys(): _curio_taken[String(id)] = maxi(0, int(bp_taken.get(id, 0))) # 身份维键(relic:)随快照还原 # 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径; # relic 令牌 feat_id 随还原) var bp_deck = _bp_read("bonus_deck", bp) if bp_deck is Array: var restored: Array[Variant] = [] for t in (bp_deck as Array): var tok: Dictionary[Variant, Variant] = t as Dictionary var rtok := {"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))} if String(rtok["kind"]) == "relic": rtok["feat_id"] = String(tok.get("feat_id", "")) # relic 令牌 feat_id 随还原 restored.append(rtok) _curio_deck = restored # 特性持有集还原(缺键 = 旧版断点防御空集) _feats = [] for r in (bp.get("relics", []) as Array): _feats.append(String(r)) _keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true) # 续玩参数面回填(同 start_adventure 参数面;生命账本越界钳制在此落——clamp 类) func _resume_params(bp: Dictionary, params: Dictionary) -> void: _base_override = _normalize_override(params.get("hero_override", {})) hero_attack_type = String(params.get("attack_type", "")) # 续玩参数面同 start_adventure(账号层选中态随 resume 重注入) hero_armor_type = String(params.get("armor_type", "")) # 生命账本越界钳制(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)))) _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy) _treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {} _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive # Y 余量优先读断点(花掉不回血=堵「用完退菜单再续玩满血复活」可利用漏洞);缺键=旧版断点, # 回退参数面整额注入(旧档行为零变化)——params_fallback 类 _attune_refresh_y = _bp_read_int("attune_refresh_y", bp, 0, params) _seed_attune_charges() # 冒险开始充能账本按装备主动件重置(每冒险充能语义) _tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # A/B 面 rule 键集(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传) # 续玩计数器回填(凭证计数/冒险域计数/刷新存量/真视计数) func _resume_counters(bp: Dictionary) -> void: _feat_ticket_pity = int(bp.get("feat_ticket_pity", 0)) # T5 扩列——旧档缺键缺省 0(存档兼容) _feat_ticket_sold = int(bp.get("feat_ticket_sold", 0)) # adventure 域计数器回填(缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先) var bp_counters = _bp_read("adventure_counters", bp) if bp_counters is Dictionary: run_ledger.adventure_counters = (bp_counters as Dictionary).duplicate(true) _refresh_stock = _bp_read_int("refresh_stock", bp, 0) # 旧档缺键缺省 0(clamp 类) run_ledger.vision_counter = {"used": _bp_read_int("vision_counter_used", bp, 0)} # 真视计数随断点回填(clamp 类) # 续玩选择停点回填(token_migration 类:旧版断点候选为 kind 字符串,按普通档令牌防御归一) func _resume_pending(bp_pending: Dictionary) -> void: _pending_next_level = true var migrated: Array[Variant] = [] for c in (bp_pending.get("candidates", []) as Array): if c is Dictionary: # T4——feat_id 随迁移(关卡完成渠道候选=纯专长令牌,身份键 relic|tier|feat_id 三段全要) var mig := {"kind": String((c as Dictionary).get("kind", "")), "tier": String((c as Dictionary).get("tier", ""))} if String(mig["kind"]) == "relic": mig["feat_id"] = String((c as Dictionary).get("feat_id", "")) migrated.append(mig) else: migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON}) _curio_pending = {"candidates": migrated} var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true) _state = State.RUNNING curio_choice_presented.emit(payload) func _reset_curio_state() -> void: _curio.reset() # 池与件获取计数(池半归件;停点态归本类下行清理) _feats = [] _curio_pending = {} _curio_result = {} _keystone_drops = [] _node_curio_pending = {} run_ledger.adventure_counters = {} _refresh_stock = 0 _pending_rap = 0 _time_sand_queue = 0 # 调试浮窗百分比通道(测试工具覆盖——每关新 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) # 冒险账本侧生效上限(账本置满/回血封顶与战斗内上限同式同源): # (表值 + 永久强化 flat + 加算区 flat)后乘调试浮窗百分比——叠加次序与 LevelRunner.effective_max_hp # 逐字一致。pct=0(模拟/冒烟默认)时乘法恒等——MC 锚零漂移。 func effective_max_hp() -> int: var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + int(run_ledger.bonus.get("max_hp", 0)) return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0))) # 断点承载注入(组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默) func set_save_manager(sm: SaveManagerCore) -> void: _save_manager = sm # 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取) func runner() -> LevelRunner: return _runner # 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶)。算术宿主 = LevelRunner.rest_heal #(与 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 _curio_pending.is_empty(): push_error("[冒险结构] continue_to_next_level 拒绝——三选一停点未兑付(零自动推进契约)") return false _pending_next_level = false _curio_result = {} level_index += 1 return _start_current_level() # 停点空候选消化(池空=三选一环节整体跳过:候选 0 件时停点视同已处理, # 呈现层跳开选择面直接走后续推进。仅空候选合法;非空停点拒绝——零自动推进契约不破,正常 # 消化仍只走 pick_curio/apply_node_curio 兑付。触发源=渠道白名单过滤后池空(关卡 Boss 击破=关卡完成 # 渠道只出专长,专长零配置/旧档 deck 无 relic 余库即空集)。) func skip_empty_curio() -> bool: if _curio_pending.is_empty(): return false if not (_curio_pending.get("candidates", []) as Array).is_empty(): return false _curio_pending = {} return true # ---- 提升三选一(选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)---- # 候选令牌匹配(身份键恒等——属性加成型特性 kind+tier、规则改写型特性 feat_id) func _find_token(candidates: Array, kind: String, tier: String, feat_id: String = "") -> Dictionary: return _curio.find_token(candidates, kind, tier, feat_id) # 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(身份键精确匹配——relic 件 # 另须 feat_id)。属性件写加算区、AP 件写第四轴、GP 件走钱包「奖励入账」入口、恢复件当即回血、 # relic 件入特性持有集(🔴 层级纪律:只走局内随机渠道)、道具件受同调位约束。 # 消费=选中令牌踢池(未选回池由「抽签不摘牌、兑付才摘牌」语义承载——TP0 v2.2)。 func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: if state != "running" or _curio_pending.is_empty(): push_error("[冒险结构] pick_curio 拒绝——无待选停点(state=%s)" % state) return false var candidates: Array[Variant] = _curio_pending.get("candidates", []) as Array if _find_token(candidates, kind, tier, feat_id).is_empty(): push_error("[冒险结构] pick_curio 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, feat_id]) return false if kind == "feat_ticket": return _redeem_feat_ticket() # T5 拿到即开——停点候选替换为专长三选一(零摘牌/不进持有集) var value: int = int(db.adventure.curio_value(kind, tier)) var hp_before := int(ledger.get("hero_hp", 0)) if not _apply_curio_pick(kind, tier, value, feat_id): push_error("[冒险结构] pick_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id]) return false _finish_curio_pick(kind, value, hp_before, tier, feat_id) return true # 兑付载荷 relic 伴随键(反馈行取数面;非 relic 件零新增键) func _feat_payload_keys(feat_id: String) -> Dictionary: if feat_id == "": return {} var row: Dictionary[Variant, Variant] = db.feat_row(feat_id) return {"feat_id": feat_id, "feat_name": String(row.get("display_name", "")), "feat_archetype": String(row.get("archetype", ""))} # 三选一件兑付共享体(TP0——令牌消费、摘牌单点,含 relic 分支): # 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包、恢复件当即回血(封顶生效上限—— # 与账本置满/关卡完成回血同式 effective_max_hp)、relic 件入持有集(散件单体保底——效果接线面 # 按挂点消费,档位质变由 FeatsEngine 求值)。兑付即从 _curio_deck 摘除该令牌(选中踢池; # 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。 func _apply_curio_pick(kind: String, tier: String, value: int, feat_id: String = "") -> bool: if kind == "relic" and db.feat_row(feat_id).is_empty(): push_error("[冒险结构] relic 兑付失败——表无此件: %s" % feat_id) return false # 先校验后摘牌(令牌零丢失) if not _consume_deck_token(kind, tier, feat_id): push_error("[冒险结构] 令牌消费失败——牌库无此令牌: %s|%s|%s" % [kind, tier, feat_id]) return false if kind == "refresh": _refresh_stock += value # T4 F-1——刷新券入刷新存量账(冒险级;**不写 _attune_refresh_x**:X 每停点 _reset_refresh_x 重置会吞存量;摘牌先于落账=选中即消尽) _curio_taken["refresh"] = int(_curio_taken.get("refresh", 0)) + 1 return true if kind == "rap": _pending_rap += value # T4——储备金袋:下一战 RAP +N(节点级,战首注入后清零) _curio_taken["rap"] = int(_curio_taken.get("rap", 0)) + 1 return true if kind == "time_sand": _time_sand_queue += value # T4——时沙:下一战首轮 AP +N(回合级池增量,不改上限) _curio_taken["time_sand"] = int(_curio_taken.get("time_sand", 0)) + 1 return true if kind == "relic": if not _feats.has(feat_id): _feats.append(feat_id) _curio_taken["relic"] = int(_curio_taken.get("relic", 0)) + 1 _curio_taken["relic:" + feat_id] = int(_curio_taken.get("relic:" + feat_id, 0)) + 1 # 身份维已拿计数(cap 消费面) return true # 加算形态路由(match 收敛进效果类型:属性值提升四类走 # adventure_table.CURIO_APPLY_ROUTES 声明路由(bonus 落点键),加新加算类令牌零代码) var sink := db.adventure.curio_apply_route(kind) if sink != "": run_ledger.bonus[sink] = int(run_ledger.bonus.get(sink, 0)) + value _curio_taken[kind] = int(_curio_taken.get(kind, 0)) + 1 return true # 通道面(副作用形态——显式分支) match kind: "gp": if _runner != null: _runner.grant_curio_gp(value) _curio_taken["gp"] = int(_curio_taken.get("gp", 0)) + 1 "heal": ledger["hero_hp"] = mini(int(ledger.get("hero_hp", 0)) + value, effective_max_hp()) _curio_taken["heal"] = int(_curio_taken.get("heal", 0)) + 1 _: return false return true # 从牌库摘除一枚令牌(TP0——选中踢池单点;relic 按 feat_id 精确摘;找不到 = false) func _consume_deck_token(kind: String, tier: String, feat_id: String = "") -> bool: return _curio.consume_deck_token(kind, tier, feat_id) # 特性持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写) func feats_held() -> Array: return _feats.duplicate() # ---- 商店共享池消费面(共享池单点账本;TP0——库存=牌库剩余令牌)---- # 库存余量(池消耗即上限;奖励摘牌与商店购买同库承载,全冒险一次性) func pool_stock(kind: String) -> int: return _curio.pool_stock(kind) # 商店购买消费 handler(LevelRunner.buy_pool_item 经 Callable 调用——回 _apply_curio_pick 同口) func _consume_pool_purchase(kind: String, tier: String) -> bool: return _curio.consume_pool_purchase(kind, tier) # 终局池快照(_settle 清账前调用——复位会重置计数,摘要 additive 键须在此前取数) func _pool_stock_snapshot() -> Dictionary: return _curio.stock_snapshot() # 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。 # picked = kind;tier 随载荷(TP0——大小档位读数面);relic 伴随键随载荷(反馈行取数) func _finish_curio_pick(picked: String, value: int, hp_before: int, tier: String = "", feat_id: String = "") -> void: _curio_pending = {} _curio_result = { "picked": picked, "tier": tier, "value": value, "hp_before": hp_before, "hp_after": int(ledger.get("hero_hp", 0)), "level_index": level_index, } _curio_result.merge(_feat_payload_keys(feat_id)) curio_resolved.emit(_curio_result.duplicate(true)) _snapshot_breakpoint() # ---- 奖励节点三选一(与关卡完成三选一共享同池同权重同次数上限)---- # 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载, # 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。 func node_curio_candidates() -> Dictionary: var cands: Array[Variant] = _draw_curio_candidates("reward_node") return {"candidates": cands} func present_node_curio(candidates: Array) -> void: _reset_refresh_x() # 碰三选一重置 X 余量(不结转) _curio_paid_refreshes = 0 # 付费刷计数随停点清零(对齐商店进店清零) _node_curio_pending = {"candidates": candidates.duplicate(true)} # 点选兑付(节点域——无部分回血无钥石;令牌消费与牌库摘牌与关卡完成三选一同库;relic 同权入持有集) func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: if kind == "feat_ticket": return _redeem_feat_ticket() # T5 拿到即开——节点停点候选替换为专长三选一(呈现层本地重绘) if _node_curio_pending.is_empty(): push_error("[冒险结构] apply_node_curio 拒绝——无待选停点") return false var candidates: Array[Variant] = _node_curio_pending.get("candidates", []) as Array if _find_token(candidates, kind, tier, feat_id).is_empty(): push_error("[冒险结构] apply_node_curio 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, feat_id]) return false var value: int = int(db.adventure.curio_value(kind, tier)) if not _apply_curio_pick(kind, tier, value, feat_id): push_error("[冒险结构] apply_node_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id]) return false _finish_node_bonus(kind, value, tier, feat_id) return true # 冒险开始充能账本重置(T6——装备 active_charged 件各回 charges_per_adventure;被动件不入账) func _seed_attune_charges() -> void: run_ledger.attune_charges = {} for iid in _attunement: var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid)) if String(arow.get("form", "")) == "active_charged": run_ledger.attune_charges[String(iid)] = maxi(0, int(arow.get("charges_per_adventure", 0))) # ---- 三选一刷新(X/Y 双源:X=刷新件每碰三选一重给〔优先消耗、不结转〕、 # Y=营地升级行冒险周期池〔用完即无〕;重抽消费 reward_pick 既有洗牌流,池规则全保留——不放回/ # 达上限剔牌/掷定前置直显;停点可中断)---- func _attune_refresh_x_total() -> int: # kind 分发查询(装配集内 effect_kinds 含 bonus_refresh # 的行 magnitude 合计;刷新件类加件零 id 分支) var total := 0 for iid_v in _attunement: var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v)) if (arow.get("effect_kinds", []) as Array).has("bonus_refresh"): total += int(arow.get("magnitude", 0)) return total 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 + _refresh_stock # 刷新存量账读数(T4——刷新券加仓余量单独读面;呈现层与断点消费) func refresh_stock() -> int: return _refresh_stock # 节点停点在途态读回(组合根重 present 用——candidates 值拷贝) func node_curio_pending_state() -> Dictionary: return { "candidates": (_node_curio_pending.get("candidates", []) as Array).duplicate(true), } # 三选一刷新(免费位 X/Y——组合根唯一调用口;无停点/无余量 false 零副作用)。关卡完成停点重抽后重发 # curio_choice_presented(呈现层重建卡片+刷新余量);节点停点由组合根读 node_curio_pending_state 重 present。 func refresh_curio() -> bool: return _refresh_curio_common(false) # 三选一 GP 付费刷新(对齐商店 MC-1——免费位 X/Y 用尽后转付费,同旋钮同曲线同钱包; # 组合根先经 curio_stop_pending+钱包支出再调,本口只做计数与重抽)。池规则全保留——不放回/达上限剔牌/掷定前置直显。 func refresh_curio_paid() -> bool: return _refresh_curio_common(true) # 本次 GP 付费刷新价(与商店货架刷新同源:ceil(基价 × 曲线^本次停点已付费刷)——读同两个调参旋钮,单点调参) func curio_refresh_gp_cost() -> int: return int(ceil(float(db.adventure.shop_refresh_cost_base) * pow(db.adventure.shop_refresh_cost_curve, float(_curio_paid_refreshes)))) # 三选一停点在途谓词(付费路径扣费前置校验——无停点禁扣钱,防空转烧钱包) func curio_stop_pending() -> bool: return not _curio_pending.is_empty() or not _node_curio_pending.is_empty() func _refresh_curio_common(paid: bool) -> bool: if not curio_stop_pending(): return false if paid: _curio_paid_refreshes += 1 else: # 免费位消耗序:X(刷新件每停点重给,优先消耗)→ Y(营地冒险周期池)→ 存量账(刷新券加仓, # 跨停点持久) if _attune_refresh_x <= 0 and _attune_refresh_y <= 0 and _refresh_stock <= 0: return false if _attune_refresh_x > 0: _attune_refresh_x -= 1 elif _attune_refresh_y > 0: _attune_refresh_y -= 1 else: _refresh_stock -= 1 if not _curio_pending.is_empty(): var cands: Array[Variant] = _draw_curio_candidates("level_clear") _curio_pending["candidates"] = cands.duplicate(true) var payload: Dictionary[Variant, Variant] = (_curio_pending.get("payload", {}) as Dictionary).duplicate(true) payload["candidates"] = cands payload["refreshed"] = true # 重发载荷带标——呈现层直落选择面,不再重开胜利总结面 curio_choice_presented.emit(payload) # 同信号重 present(零自动推进契约不破——停点仍在途) _snapshot_breakpoint() return true if not _node_curio_pending.is_empty(): var cands2: Array[Variant] = _draw_curio_candidates("reward_node") _node_curio_pending["candidates"] = cands2.duplicate(true) return true return false func _finish_node_bonus(picked: String, value: int, tier: String = "", feat_id: String = "") -> void: _node_curio_pending = {} var payload := { "context": "reward_node", "picked": picked, "tier": tier, "value": value, "level_index": level_index, } payload.merge(_feat_payload_keys(feat_id)) curio_resolved.emit(payload) # 三选一候选抽签(实装归 CurioDeck 件;消费 reward_pick 封闭流——一次停点恰一 scope) func _draw_curio_candidates(channel: String = "reward_node") -> Array: return _curio.draw_candidates(channel) # 已拿上限判定(身份维度;缺省放行=白名单外 kind 兜底) func _curio_cap_ok(kind: String, feat_id: String = "") -> bool: return _curio.cap_ok(kind, feat_id) # —— 专长获取专长凭证四口径判定(实装归 CurioDeck 件:调用即一次展示判定与记账)—— func _feat_ticket_consider(rg: RandomNumberGenerator) -> bool: return _curio.feat_ticket_consider(rg) # 专长凭证兑付(拿到即开——停点候选替换为专长三选一;不进持有集/零摘牌;限购计数 +1): # 节点停点=本地替换(呈现层读在途态重绘);店内无停点=临时停点 + payload.context 标记(呈现层 # 重 present 无胜利总结面,确认后只关面回店)。 func _redeem_feat_ticket() -> bool: _feat_ticket_sold += 1 _feat_ticket_pity = 0 var cands: Array[Variant] = _draw_curio_candidates("level_clear") # 专长池(与关卡完成渠道同白名单) if cands.is_empty(): push_error("[冒险结构] 专长凭证兑付失败——专长池空集") _feat_ticket_sold -= 1 return false if not _node_curio_pending.is_empty(): _node_curio_pending["candidates"] = cands.duplicate(true) return true var payload: Dictionary[Variant, Variant] = {"context": "feat_ticket", "candidates": cands.duplicate(true)} _curio_pending = {"candidates": cands.duplicate(true), "payload": payload.duplicate(true)} curio_choice_presented.emit(payload) _snapshot_breakpoint() return true # Boss 钥石逐杀掉落(掉落渠道 = 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[Variant, Variant] = 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) _bind_level_runner() # 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解), # resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。 var resume_state: Dictionary[Variant, Variant] = {} if not _resume_bp.is_empty(): resume_state = {"node_id": int(_bp_read("node_id", _resume_bp, -1)), "visited_ids": _bp_read("visited_ids", _resume_bp, []) as Array} # 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好); # _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。 # defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在 # 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。 # 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。 var ok: bool = _runner.start_level_external(level_index, ledger, purchased, apply_rest, _base_override, true, resume_state) if ok: _inject_level_params() _present_level_entry(resume_state) _resume_bp = {} return ok # 关卡行进器接线(每关新实例:信号转发 + 共享账下传) func _bind_level_runner() -> void: # 树限次计数按关清零现由 LevelRunner.level_counters 随每关 new 天然承载(旧硬编码清除名单函数已删) # core↔core 接线只许方法 Callable(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])) _runner.set_run_ledger(run_ledger) # 共享四账单类下传(run_ledger=四账类,ledger=关卡账) _runner.sync_attune_inventory() # 能力件骑库存面注入(随账下传同拍) # 关卡参数注入(装配集/特性/rule 键/类型轴/商店池/凭证件 + level_started 广播) func _inject_level_params() -> void: _runner.set_attunement(_attunement) # 同调装配下传(enc 携带进战斗结算) _runner.set_feats(_feats) # 特性持有集下传(begin_battle enc.feats 求值面) _runner.set_tree_rules(_tree_rules) # 树 rule 键集下传(A/B 面消费) _runner.set_hero_axes(hero_attack_type, hero_armor_type) # 两根类型轴快照下传(effective_ac 甲型基础 AC 层+enc 携带进 spawn) if _shop_mode == "pool": # 商店共享池注入(库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动。 # cap 检查回调四参注入) _runner.set_shop_pool(_curio_deck, _consume_pool_purchase, _feat_ticket_consider, _curio_cap_ok) # 凭证件三账注入(节点级/回合级一次性:注入即清零,未用即过期=寿命档语义) _runner.set_voucher_grants(_pending_rap, _time_sand_queue) _pending_rap = 0 _time_sand_queue = 0 # 三选一道具件跨关携带注入面不存在(aux 卡不在池种内→ carry 恒空集; # 容器字段保留=替换子交互惰性承载,零投放零行为) level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) # 入口呈现(入口停点与中途停点两态;restore_treasure_items 先于呈现=C 区库存就位) func _present_level_entry(resume_state: Dictionary) -> void: if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0: _runner.present_entry_choice() else: _runner.restore_treasure_items(_bp_read("aux_items", _resume_bp, []) as Array) _runner.present_resumed_choice() # 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次) 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(末关 summary 取数面) # Boss 钥石逐杀掉落(击杀掉落渠道,末关照常入账;首杀/复杀读 meta.tres boss_keystone) _boss_keystone_drop(level_index) if level_index < levels_total: # 关卡完成兑付(非末关恰一次;三层奖励框架):部分回血先行 → 加权洗牌掷 3 → 三选一停点 #(顺序即契约——回血按当刻账本与上限结算,选择停点零自动推进) var hp_before := int(ledger.get("hero_hp", 0)) var max_eff := effective_max_hp() # 回血封顶口径含调试浮窗 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() # 碰三选一重置 X 余量(不结转) _curio_paid_refreshes = 0 # 付费刷计数随停点清零(对齐商店进店清零) var pick_payload: Dictionary[Variant, Variant] = { "level_index": level_index, "candidates": _draw_curio_candidates("level_clear"), # T4 渠道白名单——关底 Boss 击败=关卡完成渠道只出专长 "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 扩签(关卡末清空反馈取数) } _curio_pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true), "payload": pick_payload.duplicate(true)} curio_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 扩签(末关清空反馈;非末关走 bonus 载荷) "keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(逐杀掉落明细;settle 入账明细/复盘归因数据源) "vision_used_delta": int(run_ledger.vision_counter.get("used", 0)), # additive 扩签(真视使用本局计数;settle 聚合入档) "pool_stock": _pool_stock_snapshot(), # additive 扩签(终局池读数:清账前快照,商店买走 vs 奖励领走 vs 枯竭的终局面) "relics": _feats.duplicate(), # additive 扩签(特性终局持有集:清账前快照,成型判读取数面) } # 败退明细(数值验证工具取数面——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: summary["gp_final"] = int(_runner.gp) # gp_final 清账前快照(终局面板取数源=runner 钱包单点) _runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现) _reset_curio_state() # 三选一停点态收束清零(结算受理只算一次语义——重复宣布零变化) if _save_manager != null: _save_manager.clear_breakpoint() # 三结局清断点(退出冒险=放弃流程+清除断点) _save_manager.write_to_disk() _state = State.SETTLING adventure_finished.emit(summary) _state = 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() # 停点快照(选路停点=唯一可恢复停点形态;战斗中不存) # ---- 断点快照(字段登记单源:写侧拼装与读侧回退策略同登记一表)---- # 键序即快照键序(27 顶层键——写侧/读侧/键集断言三面同源)。读侧回退策略词表: # plain 无特殊回退面(缺键取代码内缺省)/clamp 数值夹 ≥0/keep_on_null 缺键或类型不符保持既有态 # dual_merge 双向合并(bonus_taken)/params_fallback 缺键回退参数面(attune_refresh_y) # token_migration 旧版候选形态迁移(bonus_pending.candidates) # 注入缺省 null = 整面静默(模拟/冒烟零扰动)。 const BREAKPOINT_FIELDS := { "level_index": {"strategy": "plain"}, "node_id": {"strategy": "plain"}, "hero_hp": {"strategy": "clamp"}, "bonus": {"strategy": "clamp"}, "gp": {"strategy": "plain"}, "shop_purchased": {"strategy": "plain"}, "shop_shelf": {"strategy": "plain"}, "shop_visit_buys": {"strategy": "plain"}, "attunement_charges": {"strategy": "keep_on_null"}, "graph_gen_v2": {"strategy": "plain"}, "master_seed": {"strategy": "plain"}, "rng_counter": {"strategy": "plain"}, "keystone": {"strategy": "plain"}, "visited_ids": {"strategy": "plain"}, "aux_items": {"strategy": "plain"}, "bonus_taken": {"strategy": "dual_merge"}, "bonus_deck": {"strategy": "keep_on_null"}, "relics": {"strategy": "plain"}, "bonus_pending": {"strategy": "token_migration"}, "feat_ticket_pity": {"strategy": "plain"}, "feat_ticket_sold": {"strategy": "plain"}, "adventure_counters": {"strategy": "keep_on_null"}, "vision_counter_used": {"strategy": "clamp"}, "refresh_stock": {"strategy": "clamp"}, "attune_refresh_y": {"strategy": "params_fallback"}, "bonus_result": {"strategy": "plain"}, "keystone_drops": {"strategy": "plain"}, } func _snapshot_breakpoint() -> void: if _save_manager == null or state != "running": return var parts: Dictionary[Variant, Variant] = _bp_parts() var bp: Dictionary[Variant, Variant] = {} for key in BREAKPOINT_FIELDS.keys(): bp[key] = parts[key] _save_manager.set_breakpoint(bp) _save_manager.write_to_disk() # 断点写侧取值(键集以 BREAKPOINT_FIELDS 为准——未登记键即取值越界,fail-fast) func _bp_parts() -> Dictionary[Variant, Variant]: var treasure_snapshot: Array[Variant] = [] if _runner != null and _runner.treasure_inventory != null: treasure_snapshot = _runner.treasure_inventory.snapshot_inventory() # 三选一停点续玩分支 _runner 为 null(关卡已收束、下一关未开)——节点域字段按缺省落档, # 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照) return { "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(run_ledger.bonus.get("atk", 0)), "max_hp": int(run_ledger.bonus.get("max_hp", 0)), "armor_class": int(run_ledger.bonus.get("armor_class", 0)), "ap": int(run_ledger.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 [], # 货架入断点(离开店/非店停点 = 空架合法态) "shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 本次进店已购次数(A 链定价输入) "attunement_charges": run_ledger.attune_charges.duplicate(true), # 同调主动件充能余量随断点(save v4 域) "graph_gen_v2": 1, # 生成图换代代差标记(缺键 = 换代前旧断点 → resume 显式拒绝) "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": _curio_taken.duplicate(true), # 三选一件获取次数(kind 维度派生账本——断点附随件与读数承载) "bonus_deck": _curio_deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) "relics": _feats.duplicate(), # 特性持有集(局内随机渠道获得态续玩防丢失) "bonus_pending": _curio_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约) "feat_ticket_pity": _feat_ticket_pity, # T5 专长凭证保底计数(扩列向后兼容——旧档缺键缺省 0) "feat_ticket_sold": _feat_ticket_sold, # T5 专长凭证限购计数(同上) "adventure_counters": run_ledger.adventure_counters.duplicate(true), # adventure 域计数器随断点(续玩跨战账不丢) "vision_counter_used": int(run_ledger.vision_counter.get("used", 0)), # 真视使用计数随断点(续玩跨战账不丢) "refresh_stock": _refresh_stock, # 刷新存量账随断点 "attune_refresh_y": _attune_refresh_y, # Y 冒险周期池余量随断点(续玩不再自参数面整额重注入=堵「用完退菜单再续玩满血复活」可利用漏洞;扩列向后兼容——旧档缺键回退参数面) "bonus_result": _curio_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付) "keystone_drops": _keystone_drops.duplicate(true), # 逐杀掉落明细(摘要归因续接) } # 读侧单点(BREAKPOINT_FIELDS 策略标签分发;未登记键 fail-fast 不静默按 plain 兜)。 # 二次消费面(_start_current_level 经 _resume_bp 取 node_id/visited_ids/aux_items)同走此口。 func _bp_read(key: String, bp: Dictionary, fallback: Variant = null) -> Variant: if not BREAKPOINT_FIELDS.has(key): push_error("[冒险结构] 断点字段未登记(读侧): %s" % key) var v: Variant = bp.get(key, fallback) if _bp_strategy(key) == "keep_on_null" and v != null and not (v is Dictionary or v is Array): return null # 类型不符——保持既有态(调用方跳过覆盖) return v # 读侧策略标签(BREAKPOINT_FIELDS 读侧消费面单点) func _bp_strategy(key: String) -> String: return String((BREAKPOINT_FIELDS.get(key, {}) as Dictionary).get("strategy", "plain")) # 数值类读侧(clamp 夹 ≥0 / params_fallback 断点缺键回退参数面整额注入 / plain 直取) func _bp_read_int(key: String, bp: Dictionary, fallback: int, params: Dictionary = {}) -> int: match _bp_strategy(key): "clamp": return maxi(0, int(bp.get(key, fallback))) "params_fallback": return maxi(0, int(bp.get(key, int(params.get(key, fallback))))) _: return int(bp.get(key, fallback)) 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)