# VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证 # Date: 2026-09-11(2026-09-17 分支节点图批:行进器升级图版推进——单链 = 出边恰一的退化形态) # 关卡节点地图(v1.0 图版落地——节点+连线,连线只朝前进方向,唯一入口,Boss 恒唯一终点): # 关卡域生命周期唯一编排者——HP 跨战斗携带、恢复点节点兑付、关卡末收束。 # 行进纪律(零自动推进):到达即兑付;兑付后确认才出现选路口——战斗胜利与奖励/恢复确认一律经 # choices_presented 进入选路态,玩家 pick_next_node 显式选下一跳(接口漂移清偿:战斗胜利不再自动推进、 # continue_after_node 泛化为兑付确认——选路确认归 pick_next_node)。 # 2026-09-15 格面资源批:可选类道具与库存整体删除——奖励点节点内容随功能道具渠道(P1,渠道未定)悬置为 # 直通节点(零兑付);战斗各自持有 地图战斗资源 容器(映射随战斗退役,无跨战斗状态可延续)。 # Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。 # 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归元进度,切片不落盘)。 # E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变, # 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。 # 对应 GDD:关卡节点地图 v1.0(图版结构——本文件为 P1 批实现位;命名规范见 CLAUDE.md) # GP+商店房实施批(2026-09-21):GP 冒险钱包(战斗完成入账 clamp ≥0 · 冒险收束清零)+ 统一冒险 # 加算区 bonus{atk,max_hp,armor_class}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 + # 加算区,调试浮窗百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认—— # 复用 continue_after_node 兑付确认纪律,零自动推进)。 # 冒险结构实现批(2026-09-21)——接口漂移清偿登记(06 清单「切片行进代码与定稿的接口漂移」①②③⑥⑦): # ① 种子域归冒险结构:start_level 旧路径(单关测试模式)保留自调 rng.new_adventure;新路径 # start_level_external(冒险模式)不重播种——主种子注入恰一次归 AdventureRunner.start_adventure。 # ② 生命账本归冒险结构:hero_hp/gp 改属性落 _ledger 字典——冒险模式经 start_level_external 注入 # AdventureRunner 共享账本(Dictionary 引用共享),单关模式 = 本实例私有账本;战斗结果上报语义 = # runner 经共享账本属性直写(接口②——战后生命终值已在本类 _on_battle_finished 回写账本)。 # ③ 恢复数学抽成单一路径 rest_heal_amount——冒险模式经 _rest_handler 回调(AdventureRunner.apply_rest, # 接口③治疗封顶回报执行体归冒险结构);单关模式默认内置。公式逐位一致,零行为变更。 # ⑥ 收束先清库后宣布——现行无关卡域库存(格面资源批 2026-09-15 可选类删除后为空集),战斗结束 # retire_mapping + despawn_all 先于 battle_finished 发出即事实清库,不另设空接口(假契约)。 # ⑦ 倒下的失败结算归冒险结构:本类只发 level_finished(每战结果上报语义),失败结算由 # AdventureRunner._on_level_finished 执行;GP 冒险收束清零同步归冒险结构(本类冒险模式不关钱包)。 class_name LevelRunner extends RefCounted signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp} · shop={gp} signal choices_presented(candidates: Array) # 选路口(兑付确认后——候选节点引用数组,零自动推进) signal step_row(row: Dictionary) # 转发当前 战斗结算 结算行 signal round_started(round_index: int) signal board_changed() signal level_finished(payload: Dictionary) # {result, hp, node_index, node_id, node_type} signal wallet_changed(event: String, gp: int, extra: Dictionary) # GP 钱包事件投影:gain{amount}/buy{product,price}/clear——试玩日志与商店覆盖层刷新源(GP+商店房实施批) var db: ConfigDb var rng: RngServiceCore var level_row: Dictionary[String, Variant] = {} var node_index := -1 # ---- 账本载体(冒险结构实现批——漂移②清偿)---- # 冒险模式 = AdventureRunner 共享字典(Dictionary 引用共享——hero_hp/gp 属性读写直接落冒险账本); # 单关模式 = 本实例私有账本(start_level 重建,既有单关语义逐位保留)。 var _ledger: Dictionary[Variant, Variant] = {"hero_hp": 0, "gp": 0} var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③) var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点) # hero_hp / gp 属性化(既有读写点零改动——值类型经属性落 _ledger) var hero_hp: int: get: return int(_ledger["hero_hp"]) set(v): _ledger["hero_hp"] = v var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要) get: return int(_ledger["gp"]) set(v): _ledger["gp"] = v var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零) var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零) # 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口 var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null) var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(迭代 7 T3——TreasureItems;跨战斗延续, var _treasure_unlocked := {} # aux 解锁门控集(迭代 7 T5——拍板③甲;空集=零投放) var _attunement: Array[Variant] = [] # 同调装配集(迭代 11 第①批 T5——adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动) # 关卡末清空+快照计数入 level_finished 载荷) # 两根类型轴(迭代 23 T4——adventure 参数面快照经 set_hero_axes 下传;空串=未注入→锻造配置默认值回退。 # 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照) var _hero_attack_type := "" var _hero_armor_type := "" # ---- 商店共享池模式态(迭代 11 第②批 T3——缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池换代)---- var _shop_pool_mode := false # pool 模式旗(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传) var _pool_deck: Array[Variant] = [] # 共享池令牌库共享引用(AdventureRunner._curio_deck——库存=剩余令牌数单点) var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_curio_pick 同口写加算区+账本+摘牌) var _pool_ticket := Callable() # 门票展示判定 handler(T5——AdventureRunner._feat_ticket_consider;节点与商店共享计数) var _pool_cap_check := Callable() # cap 检查回调(迭代 20 T2——AdventureRunner._curio_cap_ok;案②提前退役) var _shop_shelf: Array[Variant] = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡) var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零) var _shop_paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零) var hero_atk_pct := 0 # 测试面板覆盖值(切片专用) var hero_hp_pct := 0 # ---- 关卡相位轴(2026-10-02 修复批——布尔旗收拢:_running/_awaiting_choice 两旗 → 显式 Phase enum; # 对齐 行动经济 Phase 先例(action_economy.gd:内部 enum + 守卫换算,外部读面契约零变化)—— # level_result:String 与 awaiting_choice() 被 AdventureRunner/呈现层消费的语义逐字不变)---- enum Phase { IDLE, RUNNING, AWAITING_CHOICE, ENDED } var _phase: int = Phase.IDLE var level_result := "" # "" / "victory" / "defeat"(外部契约面——语义零变化) var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组) var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点) var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false) func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: db = db_ref rng = rng_ref # 进行中谓词(原 _running 语义——RUNNING 或 AWAITING_CHOICE 两相位合计;内部守卫专用) func _is_running() -> bool: return _phase == Phase.RUNNING or _phase == Phase.AWAITING_CHOICE # ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 encounter 传入战斗)---- func set_hero_override(atk_pct: int, hp_pct: int) -> void: hero_atk_pct = atk_pct hero_hp_pct = hp_pct # 叠加次序(元进度实现批钉死):表值 → 永久强化 flat(base_override)→ 加算区 flat(bonus)先加 # → 调试浮窗百分比后乘(调试数值工具恒乘最后——代拍板 6,与 GP 批既有次序兼容) func effective_atk() -> int: var base: int = int(db.hero.atk) + int(base_override.get("atk", 0)) + int(run_ledger.bonus.get("atk", 0)) return maxi(1, int(round(float(base) * (100.0 + float(hero_atk_pct)) / 100.0))) 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(hero_hp_pct)) / 100.0))) # 护甲等级生效值(入战 AC 三层可加——迭代 23 T4:甲型基础 AC〔forge.tres 选中甲路线 base_ac〕+ # 甲路线等级加值〔base_override.armor_class——hero_override 锻造分量〕+ 加算区 armor_class 轴; # 无调试浮窗百分比通道,纯 flat) func effective_ac() -> int: return maxi(0, _armor_base_ac() + int(base_override.get("armor_class", 0)) + int(run_ledger.bonus.get("armor_class", 0))) # 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线=轻甲 10, # 与 hero.tres 两轴删除前 armor_class=10 行为连续) func _armor_base_ac() -> int: var at: String = _hero_armor_type if _hero_armor_type != "" else db.forge_default_armor_type() var row: Dictionary[Variant, Variant] = db.forge_armor_route_by_type(at) if row.is_empty(): return int(db.hero.armor_class) # 表缺失防御兜底(现役基准) return int(row.get("base_ac", 0)) # 行动点加成生效值(迭代 10 奖励重构批——统一冒险加算区第四轴 ap;经战斗开始入参 hero_ap_bonus # 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕) func effective_ap_bonus() -> int: return maxi(0, int(run_ledger.bonus.get("ap", 0))) # 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留) func restore_hero_base() -> void: hero_atk_pct = 0 hero_hp_pct = 0 # ---- 关卡生命周期 ---- func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}, template_pick: int = -1) -> bool: # 单关测试模式(旧路径——既有语义一字不改):私有账本、内置恢复数学、自调 new_adventure(漂移① # 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure) # base_override_in = 永久强化 flat 注入尾参(换算率标定批 2026-09-23——缺省 {} 全零白板, # 与既有调用方逐位一致零行为变更;契约同 AdventureRunner.start_adventure params.hero_override: # 三键 {atk, max_hp, armor_class}、缺键/-1 = 0、负值 clamp 0。游戏直入口仍恒白板—— # 注入面只服务数值验证工具,非玩家可达路径) _external = false _rest_handler = rest_heal_amount _ledger = {"hero_hp": 0, "gp": 0} var row := _find_level_row(level_index) if row.is_empty(): push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index) return false node_index = -1 level_result = "" _phase = Phase.IDLE _candidates = [] visited = [] _graph_mode = false gp = 0 # 冒险钱包清零开局(GP+商店房实施批——局内货币生命周期起点) run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上) base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上) _shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计) for k in base_override.keys(): base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面) for node in nodes(): if node.has("next"): _graph_mode = true break hero_hp = effective_max_hp() rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构) level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7) _phase = Phase.RUNNING var entry := _entry_node() if entry.is_empty(): push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") _phase = Phase.ENDED return false return _enter_node(entry) # 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接; # 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased, # 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。 # base_override = 永久强化 flat 共享引用(元进度实现批——缺省 {} 全零白板,与 B1 空参数路径逐位一致)。 # 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)), # 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。 # 停点前移批 2026-09-23:defer_entry 尾参(缺省 false = 既有调用方零变更)——true 时不自动 # _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在 # level_started 之后调 present_entry_choice() 呈现、玩家 pick_next_node 显式进首战。拆两步的 # 信号顺序契约:呈现层须先经 level_started 重取 runner 实例,choices_presented 才不会被 # _on_choices_presented 的 runner 空防御静默丢弃。入口呈现零 RNG 消耗、pick 走同一个 # _enter_node(entry)——与旧自动进入逐位等价(测量面零漂移红线)。 func start_level_external(level_index: int, ledger: Dictionary, purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {}, defer_entry: bool = false, template_pick: int = -1, resume_state: Dictionary = {}) -> bool: var row := _find_level_row(level_index) if row.is_empty(): push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index) return false _external = true _ledger = ledger base_override = base_override_ref _shop_purchased = purchased_ref _rest_handler = rest_handler level_row.assign(_resolve_template_row(row, template_pick)) node_index = -1 level_result = "" _phase = Phase.IDLE _candidates = [] visited = [] _graph_mode = false for node in nodes(): if node.has("next"): _graph_mode = true break _phase = Phase.RUNNING # 断点续玩路径(迭代 8 卡①——defer_entry 之外的第三种停点入口):直接重建到停点所在节点, # 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。 # resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。 if not resume_state.is_empty(): return _restore_to_stop(int(resume_state.get("node_id", -1)), resume_state.get("visited_ids", []) as Array) var entry := _entry_node() if entry.is_empty(): push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") _phase = Phase.ENDED return false if defer_entry: _phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node) _candidates = [entry] return true return _enter_node(entry) # 入口停点呈现(停点前移批 2026-09-23——defer_entry=true 的第二步):发出入口候选供呈现层弹 # 选路层;非停点态(缺省自动进入路径)拒绝。零 RNG 消耗——呈现与行进取号解耦。 func present_entry_choice() -> bool: if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index != -1: push_error("[关卡节点地图] present_entry_choice 拒绝——非入口停点态(defer_entry 未开或已 pick)") return false choices_presented.emit(_candidates) return true # ---- 断点续玩(迭代 8 卡①——停点粒度重建)---- # 停点重建(start_level_external resume_state 路径的落点):node_id = 停点所在节点编号(-1 = 入口 # 停点),visited_ids = 已到节点编号序(呈现层图条数据源重建)。纯状态重建零 RNG 消耗; # 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。 func _restore_to_stop(node_id: int, visited_ids: Array) -> bool: if node_id < 0: # 入口停点:与 defer_entry 同款形态(node_index 保持 -1,单候选 = 入口节点) var entry := _entry_node() if entry.is_empty(): push_error("[关卡节点地图] 断点恢复失败——入口节点缺失") _phase = Phase.ENDED return false _phase = Phase.AWAITING_CHOICE _candidates = [entry] return true var stop: Dictionary[Variant, Variant] = {} for node in nodes(): if _node_id(node) == node_id: stop = node break if stop.is_empty(): push_error("[关卡节点地图] 断点恢复失败——停点节点编号 %d 不在本关图" % node_id) _phase = Phase.ENDED return false node_index = nodes().find(stop) visited = [] for vid in visited_ids: for node in nodes(): if _node_id(node) == int(vid): visited.append(node) break _phase = Phase.AWAITING_CHOICE _candidates = _out_nodes(stop) if _candidates.is_empty(): push_error("[关卡节点地图] 断点恢复失败——停点节点 %d 无出边(非合法停点)" % node_id) _phase = Phase.ENDED return false return true # 停点呈现(断点续玩路径——present_entry_choice 的中途节点版):信号顺序契约同入口 # (呈现层须先经 level_started 重取 runner 实例)。零 RNG 消耗。 func present_resumed_choice() -> bool: if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index < 0: push_error("[关卡节点地图] present_resumed_choice 拒绝——非中途停点态") return false choices_presented.emit(_candidates) return true # 断点道具库存回填(迭代 8 卡①——奖励节点已选道具随停点快照回局;幂等,先清后入受理序重排) func restore_treasure_items(items: Array) -> void: if treasure_inventory == null: treasure_inventory = TreasureItems.new(db) treasure_inventory.reset() treasure_inventory.restore_inventory(items) func _find_level_row(level_index: int) -> Dictionary: return db.level_row(level_index) # 表访问收口(2026-10-02——行集直扫归 LevelTable/ConfigDB 访问器) # ---- 模板池解析(关卡节点图生成 v0.4——M-3 迭代 5 T5)---- # row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费); # -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。 # 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数; # active_template_index 供断点快照备案(迭代 8 卡①——已抽模板续玩零重抽零 RNG 消耗)。 var active_template_name := "" var active_template_index := -1 # -1 = 非池形态(旧单图)/未解析 func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary: var templates: Array[Variant] = row.get("templates", []) active_template_name = "" active_template_index = -1 if templates.is_empty(): return row var idx := 0 if template_pick >= 0: idx = clampi(template_pick, 0, templates.size() - 1) else: var stream: RandomNumberGenerator = rng.open_stream("node_map") idx = stream.randi_range(0, templates.size() - 1) var tpl: Dictionary[Variant, Variant] = templates[idx] active_template_name = String(tpl.get("display_name", "")) active_template_index = idx var out := row.duplicate(true) out["nodes"] = tpl.get("nodes", []) return out func nodes() -> Array: return level_row.get("nodes", []) func current_node() -> Dictionary: if node_index < 0 or node_index >= nodes().size(): return {} return nodes()[node_index] # ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)---- # 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。 # 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。 func _node_id(node: Dictionary) -> int: if _graph_mode: return int(node.get("node_depth", -1)) return nodes().find(node) # 入口节点 = 无入边唯一节点(图合法性校验保证恰一) func _entry_node() -> Dictionary: var inbound := {} for node in nodes(): for tid in _out_ids(node): inbound[tid] = true for node in nodes(): if not inbound.has(_node_id(node)): return node return {} # 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态) func _out_ids(node: Dictionary) -> Array: if node.has("next"): return node.get("next", []) var nodes: Array[Variant] = nodes() var i := nodes.find(node) if i >= 0 and i < nodes.size() - 1: return [i + 1] return [] # 出边候选(节点引用数组——选路口/呈现层共用读面) func _out_nodes(node: Dictionary) -> Array[Dictionary]: var ids := {} for n in nodes(): ids[_node_id(n)] = n var out: Array[Dictionary] = [] for tid in _out_ids(node): if ids.has(tid): out.append(ids[tid]) return out # 当前选路候选(选路态读面——呈现层渲染下一跳;非选路态 = 空) func next_candidates() -> Array: return _candidates.duplicate() # 当前选路候选编号表(pick_next_node 的合法入参域——编号空间解析单一出口) func next_candidate_ids() -> Array: var out: Array[int] = [] for c in _candidates: out.append(_node_id(c)) return out # 当前节点编号(呈现层/日志读面——编号空间解析单一出口) func current_node_id() -> int: return _node_id(current_node()) # 任意节点编号 / 出边编号表(呈现层整图渲染读面——与行进同源,禁呈现层复算编号空间) func node_id_of(node: Dictionary) -> int: return _node_id(node) func node_out_ids(node: Dictionary) -> Array: return _out_ids(node) # 已到节点编号序列(只读投影——编号空间解析单一出口) func visited_ids() -> Array: var out: Array[int] = [] for v in visited: out.append(_node_id(v)) return out func awaiting_choice() -> bool: return _phase == Phase.AWAITING_CHOICE func _enter_node(node: Dictionary) -> bool: if not _is_running(): return false node_index = nodes().find(node) visited.append(node) match String(node.get("type", "")): "battle", "boss": return _start_battle(node) "reward": # 奖励点内容待拍板(格面资源批 2026-09-15:可选类道具删除,功能道具渠道 P1 未定—— # 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进) node_entered.emit(node, {}) return true "rest": var healed := _apply_rest() node_entered.emit(node, {"healed": healed, "hp": hero_hp, "max_hp": effective_max_hp()}) return true "shop": # 商店房兑付(GP+商店房实施批 2026-09-21):到达即开店(覆盖层归呈现层),购买出账/ # 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口 if _shop_pool_mode: _shop_open_pool() # 迭代 11 第②批 T3——进店抽 3(恰一 shop_draw scope;买后全重抽同签) node_entered.emit(node, {"gp": gp, "shelf": true}) return true node_entered.emit(node, {"gp": gp}) # legacy 固定三件(off 对照承载——零 RNG 零池消费) return true push_error("[关卡节点地图] 未知节点类型: %s" % str(node)) return false func _start_battle(node: Dictionary) -> bool: _settlement = BattleOrchestrator.new(db, rng) if treasure_inventory == null: treasure_inventory = TreasureItems.new(db) # 惰性建——关卡域单实例(跨战斗延续) _settlement.treasure_inventory = treasure_inventory # 迭代 7 T3——库存注入(授予路由+use_treasure 编排消费面) if _settlement != null: _settlement.set_treasure_unlocked(_treasure_unlocked) # 迭代 7 T5——解锁门控下传(装板摇号前) # core↔core 接线只许方法 Callable(ADR-0001 静态规则 S6——lambda 捕获 self 会延长 # RefCounted 生命周期乃至成环:settlement 的信号持捕获 runner 的 lambda,runner 又持 settlement) _settlement.step_row.connect(_forward_step_row) _settlement.round_started.connect(_forward_round_started) _settlement.board_changed.connect(_forward_board_changed) _settlement.battle_finished.connect(_on_battle_finished) # node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的 # board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染(2026-09-11 playtest 缺陷①) node_entered.emit(node, {}) if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)), "hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(), "hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(), "attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 迭代 23 T4——两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退) "attunement": _attunement, "ledger": run_ledger, # 迭代 27 T3——共享四账单类下传(原四散 dict 引用键收口;裁点②甲) "feats": _feats, "tree_rules": _tree_rules, "battle_index": _battle_index(), "voucher_rap": _voucher_rap, "voucher_time_sand": _voucher_time_sand, "voucher_insurance": _voucher_insurance, "node_type": String(node.get("type", ""))}): push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1))) return false _voucher_rap = 0 # 迭代 20 T4——节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清) _voucher_time_sand = 0 _voucher_insurance = 0 return true func _forward_step_row(row: Dictionary) -> void: step_row.emit(row) # battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕; # 按已到节点 type=battle 计数,与图形态 node_depth/单链数组下标两编号空间解耦〔混编碰撞免疫〕; # 开局姿态组「每关首轮窗」条件面——迭代 21 T3) func _battle_index() -> int: var n := 0 for v in visited: if String((v as Dictionary).get("type", "")) == "battle": n += 1 return n func _forward_round_started(ri: int) -> void: round_started.emit(ri) func _forward_board_changed() -> void: board_changed.emit() func _on_battle_finished(payload: Dictionary) -> void: var node := current_node() var result: String = String(payload.get("result", "")) if result == "victory": hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含) _gain_wallet(int(payload.get("gp_dropped", 0))) # 战斗完成入账(clamp ≥0——破财可把 escrow 打负,钱包入账时钳制) if String(node.get("type", "")) == "boss" or result == "defeat": # Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(漂移⑦清偿——每战结果上报冒险结构, # 失败结算归 AdventureRunner;本类只发 level_finished) level_result = "victory" if result == "victory" else "defeat" var hp_left := hero_hp if result == "victory" else 0 if not _external: close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle _phase = Phase.ENDED _settlement = null var treasure_cleared := 0 if treasure_inventory != null: treasure_cleared = treasure_inventory.inventory_count() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数) treasure_inventory.reset() # 迭代 7 T3——关卡末库存清空(无论输赢,幂等) level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index, "node_id": _node_id(node), "node_type": String(node.get("type", "")), "aux_cleared": treasure_cleared}) # additive 扩签(不参与派生——node_map 扩签先例) return # 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口 _settlement = null _present_choices() # 恢复数学单一路径(漂移③清偿;2026-10-02 修复批——同式双落点收口:算术宿主 = 本静态方法, # AdventureRunner.apply_rest 同式委托,字面量双写退役)。floor(上限 × 比例 ÷ 100) 后封顶缺口; # 冒险结构 GDD 公式逐位一致 static func rest_heal(pct: int, hp: int, max_hp: int) -> int: return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp) func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int: return rest_heal(pct, hp, max_hp) # 公有成文出口保持(start_level 内置恢复执行体经 Callable 引用) func _apply_rest() -> int: var pct: int = int(level_row.get("rest_heal_pct", 0)) # 接口③治疗封顶回报:单关模式 = 内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest #(执行归冒险结构——回报比例、由其按封顶公式回写共享账本后再由本类叠加) var actual: int = int(_rest_handler.call(pct, hero_hp, effective_max_hp())) hero_hp += actual return actual # ---- 选路(零自动推进的停点语义:兑付确认 → 候选呈现 → 显式 pick)---- # 兑付确认后呈现选路口(战斗胜利 / 奖励·恢复确认统一入口——单候选同样确认,Boss 外无免确认通道) func _present_choices() -> bool: var cands := _out_nodes(current_node()) if cands.is_empty(): push_error("[关卡节点地图] 当前节点无出边且非 Boss(图合法性校验应已拦截)") return false _phase = Phase.AWAITING_CHOICE _candidates = cands choices_presented.emit(cands) return true # 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生) func pick_next_node(node_id: int) -> bool: if _phase != Phase.AWAITING_CHOICE: return false for cand in _candidates: if _node_id(cand) == node_id: _phase = Phase.RUNNING _candidates = [] return _enter_node(cand) return false # ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)---- func current_battle(): return _settlement # aux 解锁门控集注入(迭代 7 T5——AdventureRunner 自 params 传递) func set_treasure_unlocked(ids: Dictionary) -> void: _treasure_unlocked = ids if _settlement != null: _settlement.set_treasure_unlocked(ids) # 同调装配集注入(迭代 11 第①批 T5——冒险开始参数面;AdventureRunner 唯一调用方) func set_attunement(ids: Array) -> void: _attunement = ids.duplicate() # 特性持有集注入(迭代 12 T1——AdventureRunner._start_current_level 唯一调用方;begin_battle # enc.feats 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效 # (attune_charges 共享账本同款);空集=零扰动) var _feats: Array[Variant] = [] func set_feats(ids: Array) -> void: _feats = ids # 特性持有集读面(迭代 12 呈现补齐——HUD C 区特性段数据源) func feats_held() -> Array: return _feats.duplicate() # 树 rule 键集注入(迭代 12 T10——begin_battle enc.tree_rules 携带;Fork 激活侧 effects) var _tree_rules: Array[Variant] = [] func set_tree_rules(rules: Array) -> void: _tree_rules = rules.duplicate() # 两根类型轴注入(迭代 23 T4——adventure 参数面快照;空串=未注入→锻造配置默认值回退。 # 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照) func set_hero_axes(attack_type: String, armor_type: String) -> void: _hero_attack_type = attack_type _hero_armor_type = armor_type # 共享四本账注入(迭代 27 T3——P1-4 收口:RunLedger 单类下传,替代原 bonus/attune_charges/ # vision_counter/adventure_counters 四散引用;战斗侧只走账类原语,战末 export/开局 seed # 时点语义不变〔裁点②甲〕) var run_ledger: RunLedger = RunLedger.new() func set_run_ledger(l: RunLedger) -> void: run_ledger = l # 凭证件三账注入(迭代 20 T4——储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性, # battle begin 消费后归零;缺省 0=零扰动) var _voucher_rap := 0 var _voucher_time_sand := 0 var _voucher_insurance := 0 func set_voucher_grants(rap: int, time_sand: int, insurance: int) -> void: _voucher_rap = maxi(0, rap) _voucher_time_sand = maxi(0, time_sand) _voucher_insurance = maxi(0, insurance) # 同调主动件充能账骑库存面注入(迭代 11 第①批 T6 原语义 + 迭代 27 T3 换代——账本住 # RunLedger;能力件骑库存面注入保留:差额注入防续玩双份——resume 路径 restore_inventory # 已含断点内 attune 记录。原 set_attune_charges/set_vision_counter/set_adventure_counters # 三散 setter 随单类下传承接) func sync_attune_inventory() -> void: var charges: Dictionary[Variant, Variant] = run_ledger.attune_charges if treasure_inventory == null: treasure_inventory = TreasureItems.new(db) for iid in charges: var existing := 0 for r in treasure_inventory.snapshot_inventory(): if String(r.item_id) == String(iid) and String(r.source_class) == "attunement" and not bool(r.used): existing += 1 for i in range(maxi(0, int(charges[iid]) - existing)): treasure_inventory.add_item(String(iid), "attunement", Vector2i(-1, -1), 0) # 三选一 GP 袋件入账(迭代 10——钱包收口扩「奖励入账」入口;复用战斗完成同一入账原语, # wallet_changed 载荷带 source 供呈现归因) func grant_curio_gp(amount: int) -> void: gp = maxi(0, gp + amount) wallet_changed.emit("gain", gp, {"amount": amount, "source": "curio_pick"}) # ---- 玩家输入转发(呈现层只与 runner 对话)---- # 奖励/恢复/商店房兑付确认(呈现层在覆盖层上点击"继续/离开"后调用;自动模拟在 node_entered 回调内立即调用) # 泛化语义(接口漂移清偿):确认 ≠ 推进——确认后进选路口,下一跳归 pick_next_node func continue_after_node() -> bool: var t := String(current_node().get("type", "")) if t != "reward" and t != "rest" and t != "shop": return false if t == "shop": _shop_shelf = [] # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态——迭代 11 第②批 T2) _shop_visit_buys = 0 _shop_paid_refreshes = 0 # MC-1 return _present_choices() # ---- GP 冒险钱包(GP+商店房实施批 2026-09-21:入账/出账/收束清零三收口)---- # 战斗完成入账(gp_dropped 载荷——clamp ≥0;破财可把战斗域 escrow 打负,钱包不透支) func _gain_wallet(amount: int) -> void: gp = maxi(0, gp + amount) wallet_changed.emit("gain", gp, {"amount": amount}) # 冒险收束清零(通关/倒下/放弃——局内货币生命周期终点,不入存档不入摘要;public——冒险模式由 # AdventureRunner._settle 在冒险收束时调用,单关模式 = 关卡收束即冒险收束) func close_wallet() -> void: gp = 0 wallet_changed.emit("clear", 0, {}) # ---- 商店房兑付(GP+商店房实施批:呈现层只读投影 + 购买出账)---- # 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式) func shop_products() -> Array: var out: Array[Dictionary] = [] for prow in db.shop_products(): out.append({ "product_id": String(prow.get("product_id", "")), "display_name": String(prow.get("display_name", "")), "attr": String(prow.get("attr", "")), "flat_value": int(prow.get("flat_value", 0)), "price": current_price(prow), "purchased": purchased_tier(String(prow.get("product_id", ""))), }) return out # 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数 # (散折价随批:economy_spend 激活时商品标价 ×80%——见 _spend_discounted) func current_price(prow: Dictionary) -> int: return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(int(level_row.level_index)) \ + int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", "")))) # 散折价单点(迭代 21 T3——财富之道·散 economy_spend 激活时商品购买价 ×80% provisional, # floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price # + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折〔卡片§11②裁决〕; # 折率归 M4 数值批) func _spend_discounted(price: int) -> int: if _tree_rules.has("economy_spend"): return maxi(1, int(floor(float(price) * 0.8))) return price func purchased_tier(product_id: String) -> int: return int(_shop_purchased.get(product_id, 0)) # 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用): # 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1 func buy_product(product_id: String) -> bool: var prow: Dictionary[Variant, Variant] = db.shop_product_row(product_id) if prow.is_empty(): push_error("[关卡节点地图] 商店房未知商品: %s" % product_id) return false var attr := String(prow.get("attr", "")) if not run_ledger.bonus.has(attr): push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr]) return false var price := current_price(prow) if gp < price: push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [product_id, price, gp]) return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝;2026-10-02 补静默留痕) gp -= price var flat := int(prow.get("flat_value", 0)) run_ledger.bonus[attr] = int(run_ledger.bonus[attr]) + flat if attr == "max_hp": hero_hp = mini(hero_hp + flat, effective_max_hp()) # 购血:上限与当前生命同步 +Δ(封顶新上限) _shop_purchased[product_id] = purchased_tier(product_id) + 1 wallet_changed.emit("buy", gp, {"product": product_id, "price": price}) return true func player_request_refresh() -> bool: if _settlement == null: return false return _settlement.player_request_refresh() # ---- 商店共享池模式(迭代 11 第②批 T3——P1 共享池/P4 ceil;legacy 固定三件路径上方原样保留为 off 对照承载)---- # 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 + # 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。 # 迭代 20 T2——cap 检查回调(案②提前退役:cap 满 kind 剔出货架候选视图)。 func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable(), cap_check: Callable = Callable()) -> void: _pool_deck = deck_ref _pool_consume = consume_handler _pool_ticket = ticket_consider # T5——门票展示判定(AdventureRunner._feat_ticket_consider;同流取数复用 shop_draw scope) _pool_cap_check = cap_check _shop_pool_mode = true # 进店开店(_enter_node shop 分支 pool 模式):进店购买次计数清零 + 抽 3 落架(恰一 shop_draw scope) func _shop_open_pool() -> void: _shop_visit_buys = 0 _shop_paid_refreshes = 0 # MC-1——进店付费刷新计数清零 _shop_draw_shelf() # 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余 # 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率 # 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡, # 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架 # 合法态(池空硬闸——买光即枯竭既定后果)。 func _shop_draw_shelf() -> void: _shop_shelf = [] var sellable: Array[Variant] = [] for row in db.shop_pool_rows(): var shelf_kind := String(row.get("kind_id", "")) if _pool_cap_check.is_valid() and not bool(_pool_cap_check.call(shelf_kind, "")): continue # 迭代 20 T2 案②——cap 满提前退役(货架候选视图剔除;牌库本体不动) for t in _pool_deck: var tok: Dictionary[Variant, Variant] = t as Dictionary if String(tok.get("kind", "")) == shelf_kind: sellable.append({"kind": shelf_kind, "tier": String(tok.get("tier", ""))}) if sellable.is_empty(): return var stream: RandomNumberGenerator = rng.open_stream("shop_draw") for i in sellable.size(): var j := stream.randi_range(i, sellable.size() - 1) var tmp = sellable[i] sellable[i] = sellable[j] sellable[j] = tmp var seen: Array[Variant] = [] for tok in sellable: var k := String((tok as Dictionary).get("kind", "")) if seen.has(k): continue seen.append(k) _shop_shelf.append(tok) if _shop_shelf.size() >= 3: break # T5 门票注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。 # 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移) if _pool_ticket.is_valid() and not _shop_shelf.is_empty() and bool(_pool_ticket.call(stream)): _shop_shelf[_shop_shelf.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"} # 迭代 20 T2——档名四级换代(旧 rare 语义=优秀) # 商店保底退役(迭代 15 卡② T4——渠道轴定位裁决:商店渠道只出属性值提升+资源,专长在非 Boss # 渠道只以门票形态出现(奇物.md v2.7/A4 断言)。迭代 12 T6「货架零特性挤卡换吞噬流件」简化口径 # 与渠道白名单直接冲突(devour=规则改写型=专长系),随渠道分离退役;专长商店入口=T5 门票。 # 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0) func pool_stock(kind: String) -> int: var n := 0 for t in _pool_deck: if String((t as Dictionary).get("kind", "")) == kind: n += 1 return n # 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向; # A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外) # 基价 provisional 15 固定锚(台账披露待追认——shop.tres 词表扩列归数值批)。 func pool_price(kind: String) -> int: var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind) if row.is_empty(): if kind == "relic": return _spend_discounted(int(ceil(15.0 * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) * pow(db.shop_price_a(), float(_shop_visit_buys))))) return 999999 var base := float(maxi(1, int(row.get("base_price", 1)))) var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) \ * pow(db.shop_price_a(), float(_shop_visit_buys)) return _spend_discounted(int(ceil(price_f))) # 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。 # 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式维持批②裁定 tier-blind)。 func shop_shelf() -> Array: var out: Array[Dictionary] = [] for tok in _shop_shelf: var kind := String((tok as Dictionary).get("kind", "")) var tier := String((tok as Dictionary).get("tier", "")) var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind) var display := String(row.get("display_name", kind)) var value := int(db.adventure.curio_value(kind, tier)) if row.is_empty() and kind == "relic": var rrow: Dictionary[Variant, Variant] = db.feat_row(String(tok.get("feat_id", ""))) display = String(rrow.get("display_name", "特性")) value = 0 out.append({ "kind_id": kind, "tier": tier, "feat_id": String(tok.get("feat_id", "")), "display_name": display, "value": value, "price": pool_price(kind), "stock_left": pool_stock(kind), }) return out # 商店独立刷新(MC-1——P-C2 裁 2026-10-04 推翻 v1.5「无独立刷新」):免费次数(天赋增益位,基础 # knob=0——Q2 裁「基础免费 0 次」)用尽后 GP 付费刷新;刷新=货架整架重抽(shop_draw 恰一新 scope, # 与进店/买后重抽同签——重抽同源共享池);池空/非池模式=不可刷新合法态。 func shop_refresh_free_left() -> int: # 迭代 22 秘宝批 T3——刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用, # 与基础 knob 同生命周期;未装配=基值逐位同旧) return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus()) # 刷新执照装配合计(迭代 22 秘宝批 T3——装配集 kind 分发扫描;零装配=0) func _attune_shop_refresh_bonus() -> int: 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("shop_refresh_bonus"): total += int(arow.get("magnitude", 0)) return total func shop_refresh_cost() -> int: return int(ceil(float(db.adventure.shop_refresh_cost_base) * pow(db.adventure.shop_refresh_cost_curve, float(_shop_paid_refreshes)))) func shop_refresh() -> bool: if not _shop_pool_mode or _shop_shelf.is_empty(): return false if shop_refresh_free_left() <= 0: var cost := shop_refresh_cost() if gp < cost: return false gp -= cost _shop_paid_refreshes += 1 _shop_draw_shelf() return true # GP 冒险钱包单口支出(2026-10-06——三选一 GP 付费刷新与商店刷新共用同一钱包;余额不足 false 零副作用) func spend_gp(amount: int) -> bool: if amount <= 0 or gp < amount: return false gp -= amount return true # 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架 # 令牌本体(kind+tier——_apply_curio_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买 # 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作) func buy_pool_item(kind: String) -> bool: var shelf_tok := {} for tok in _shop_shelf: if String((tok as Dictionary).get("kind", "")) == kind: shelf_tok = tok break if not _shop_pool_mode or shelf_tok.is_empty(): push_error("[关卡节点地图] 商店购买拒绝——非池模式或件不在架: %s" % kind) return false var price := pool_price(kind) if gp < price: push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [kind, price, gp]) return false var tier := String(shelf_tok.get("tier", "")) if _pool_consume.is_null() or not bool(_pool_consume.call(kind, tier)): push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s|%s" % [kind, tier]) return false gp -= price _shop_visit_buys += 1 wallet_changed.emit("buy", gp, {"product": kind, "price": price}) _shop_draw_shelf() # 买 1 → 三件全重抽(恰一 shop_draw scope——同进店取号签) return true # 池模式谓词(呈现层组合根分发用——货架面 vs legacy 商品面) func is_shop_pool() -> bool: return _shop_pool_mode # 断点快照读面(P2 甲货架+账本入断点——AdventureRunner._snapshot_breakpoint 消费;离开店即空架) func shop_bp_shelf() -> Array: return _shop_shelf.duplicate() func shop_bp_visit_buys() -> int: return _shop_visit_buys