Files
gplan-2/game/core/level_runner.gd
T
DKChan 9ee3bea194 refactor(迭代35卡②T2·二口+T3 S1/S2/S6/S8): 消费侧切源落定+断言面换代主体——① level_runner 图源换代(_generator 取代模板解析,_assign_generated_graph 读 level_gen_params 参数档产图并过 LevelTable.validate_graph,违规起关拒绝零兜底)+_entry_nodes 多入口首跳+_node_id 句柄单空间+enc node_depth 取节点 depth 值;② adventure_runner/main/save_manager 模板链路退役(first_script 参数与 template_pick 计算全删,断点 template_pick 键换生成图代差标记键 graph_gen_v2,缺键旧断点 resume 显式拒绝禁静默回放);③ level.tres 三行 templates 键清出(15 张模板数据出表,行级字段留,表尾注释改指参数档+生成器);④ sim/probe 随动(run_level/run_adventure 尾参退役、MC 出数字段 level_templates→level_maps 值=关卡#主种子、探针注释性质化、boss_depth 取节点 depth 键);⑤ 断言面换代:node_map_templates 宿主整段重写为生成图确定性套(纯函数复现/换种子变图/零计数器号位依赖/单一图源/种子 42 两开局同图/断点重生成同图/缺代差键拒绝/表无 templates/收束置位三条保留),breakpoint_resume 新增 P7 六断言(快照无已抽模板键、代差标记、停点句柄与已到序往返、续玩同图、旧断点拒绝、同种子重开局同图),S8 单测随动 first_script 键清退+断点夹具换代+meta 参数面 extra 键样换 treasure_unlocked(语义保持),S2 生成图取数面三宿主替换(level_active_nodes 走参数档+种子 42,消费点深度键换 depth,round_flow 死件 _boss_depth 随删),adventure_feats 覆盖义务段改真实解析链判据(敌侧落带≥请求深度+板侧命中+非空扫描面)、奖励节点段换「图侧含件+真实选路点选呈停兑付」双断言(旧单链必经形状前提随模板消亡,非放宽:经停即呈候选由真实 pick_next_node 入口证明)、商店余额段按每关恰一店投影改三关读数
2026-10-24 18:59:57 +08:00

1000 lines
46 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 关卡域生命周期编排者:节点图行进、HP 跨战斗携带、商店房与恢复点兑付、关卡末收束(垂直切片验证:一关内数值提升可感知)。
## Core 层零 UI 依赖(headless 可跑完整关卡);零自动推进——兑付确认后经 choices_presented,推进只走 pick_next_node。
## 种子域:单关模式 start_level 自播种;冒险模式 start_level_external 不重播种(主种子恰一次归 AdventureRunner)。
## hero_atk_pct/hero_hp_pct 为测试专用覆盖——经 encounter 显式传入战斗,零表写,db.hero 运行期只读。
## 信号顺序契约:呈现层须在 level_started 回调重取 runner 实例,choices_presented 才不会被空防御丢弃。
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 level_counters: Dictionary[Variant, Variant] = {} # 关卡桶(lvl_ 前缀计数器;每关随 LevelRunner new 天然空=按关清零,替代旧硬编码清除名单)
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 封装收口):settlement 读取经 current_battle() 成文出口
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(TreasureItems;跨战斗延续,
var _treasure_unlocked := {} # aux 解锁门控集(空集=零投放)
var _attunement: Array[Variant] = [] # 同调装配集(adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动)
# 关卡末清空+快照计数入 level_finished 载荷)
# 两根类型轴(adventure 参数面快照经 set_hero_axes 下传;空串=未注入→锻造配置默认值回退。
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
var _hero_attack_type := ""
var _hero_armor_type := ""
# ---- 商店共享池模式态(缺省 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 检查回调(AdventureRunner._curio_cap_ok;cap 满剔出货架候选视图)
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
# ---- 关卡相位轴(显式 Phase enum:内部 enum + 守卫换算,对齐 行动经济 Phase 先例;
# 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 _generator # 参数化长图生成器(惰性建——NodeMapGenerator 持注入的 RngService)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
# 进行中谓词(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)先加
# → 调试浮窗百分比后乘(调试数值工具恒乘最后)
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 三层可加:甲型基础 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))
# 行动点加成生效值(统一冒险加算区第四轴 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 = {}) -> bool:
# 单关测试模式:私有账本、内置恢复数学、自调 new_adventure(漂移①
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
# base_override_in = 永久强化 flat 注入尾参(缺省 {} 全零白板,
# 契约同 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 = []
gp = 0 # 冒险钱包清零开局(局内货币生命周期起点)
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
for k in base_override.keys():
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
hero_hp = effective_max_hp()
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
if not _assign_generated_graph(row, seed_val):
_phase = Phase.ENDED
return false
_phase = Phase.RUNNING
var entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
_phase = Phase.ENDED
return false
return _enter_node(entries[0])
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
# base_override = 永久强化 flat 共享引用(缺省 {} 全零白板,与 B1 空参数路径同值)。
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
# 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, 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
if not _assign_generated_graph(row, rng.master_seed):
return false
node_index = -1
level_result = ""
_phase = Phase.IDLE
_candidates = []
visited = []
_phase = Phase.RUNNING
# 断点续玩路径(defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
# 零 RNG 消耗(图由主种子+参数档重生成同图、节点状态纯重建不 _enter_node——当场战斗从头打)。
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
if not resume_state.is_empty():
return _restore_to_stop(int(resume_state.get("node_id", -1)),
resume_state.get("visited_ids", []) as Array)
var entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
_phase = Phase.ENDED
return false
if defer_entry:
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1)——候选=全部入口(多入口首跳即选路)
_candidates = entries
return true
return _enter_node(entries[0])
# 入口停点呈现(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
# ---- 断点续玩(停点粒度重建)----
# 停点重建(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 entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
_phase = Phase.ENDED
return false
_phase = Phase.AWAITING_CHOICE
_candidates = entries
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
# 断点道具库存回填(奖励节点已选道具随停点快照回局;幂等,先清后入受理序重排)
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) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
# 参数档按关卡序号解析(消费侧职责——生成器不认关卡号),种子取本局口径:单关模式用传入种子、
# 冒险模式用主种子(产图与全局计数器号位无关,断点在停点重生成同图)。产图先过消费侧独立校验
# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。
func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool:
var level_index: int = int(row.get("level_index", 0))
var params: Dictionary = db.level_gen_params_row(level_index)
if params.is_empty():
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
return false
if _generator == null:
_generator = NodeMapGenerator.new(rng)
var graph: Dictionary = _generator.generate(params, seed_val)
if graph.is_empty() or _generator.is_fatal():
push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index)
return false
var bad := LevelTable.validate_graph(graph["nodes"] as Array)
if not bad.is_empty():
for e in bad:
push_error("[关卡节点地图] level %d 生成图违规: %s" % [level_index, String(e["message"])])
return false
var merged := row.duplicate(true)
merged["nodes"] = graph["nodes"]
level_row.assign(merged)
return true
func nodes() -> Array:
return level_row.get("nodes", [])
func current_node() -> Dictionary:
if node_index < 0 or node_index >= nodes().size():
return {}
return nodes()[node_index]
# ---- 图版行进面(生成图恒带显式出边;单链退化形态 = 出度 ≤1 的图,无数组序隐式边)----
# 节点身份=生成图句柄 id(图内唯一)。定位信息另算(enc 携带关卡序号 + 节点深度),
# 禁再以深度当身份(句柄/位置两轴语义单一)。
func _node_id(node: Dictionary) -> int:
return int(node.get("id", -1))
# 入口节点集 = 全部无入边节点(个数=参数档 entry_count;多入口时首跳即选路)
func _entry_nodes() -> Array[Dictionary]:
var inbound := {}
for node in nodes():
for tid in _out_ids(node):
inbound[int(tid)] = true
var out: Array[Dictionary] = []
for node in nodes():
if not inbound.has(_node_id(node)):
out.append(node)
return out
# 出边句柄表(生成图恒显式 next)
func _out_ids(node: Dictionary) -> Array:
return node.get("next", [])
# 出边候选(节点引用数组——选路口/呈现层共用读面)
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
# ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)----
# 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪
# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码);
# 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。
# 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。
func type_visible_ids() -> Array[int]:
var out: Array[int] = []
var layers := _bfs_layers()
var anchor := 0
var cur := current_node()
if not cur.is_empty():
anchor = int(layers.get(_node_id(cur), 0))
var n: int = int(db.level.info_visible_layers)
for node in nodes():
var nid := _node_id(node)
if String(node.get("type", "")) == "boss" \
or (layers.has(nid) and absi(int(layers[nid]) - anchor) <= n):
out.append(nid)
for cid in next_candidate_ids():
if not out.has(int(cid)):
out.append(int(cid))
return out
# 入口 BFS 层表(id → 层深;入口层 0、出边逐层 +1,与 LevelMapScreen._assign_layers 同源)。
# 不可达节点不落表(加载期孤儿校验应已拦截)。
func _bfs_layers() -> Dictionary:
var layers := {}
var entries := _entry_nodes()
if entries.is_empty():
return layers
var queue: Array[Dictionary] = []
for entry in entries:
layers[_node_id(entry)] = 0
queue.append(entry)
var i := 0
while i < queue.size():
var cur: Dictionary[Variant, Variant] = queue[i]
i += 1
var d := int(layers[_node_id(cur)])
for nxt in _out_nodes(cur):
var nid := _node_id(nxt)
if not layers.has(nid):
layers[nid] = d + 1
queue.append(nxt)
return layers
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":
# 奖励点内容未定(可选类道具不在池、功能道具渠道未定——
# 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进)
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":
# 商店房兑付:到达即开店(覆盖层归呈现层),购买出账/
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
if _shop_pool_mode:
_shop_open_pool() # 进店抽 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 # 库存注入(授予路由+use_treasure 编排消费面)
if _settlement != null:
_settlement.set_treasure_unlocked(_treasure_unlocked) # 解锁门控下传(装板摇号前)
# core↔core 接线只许方法 Callable(静态规则 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(首板安装)并重建棋盘——后发会导致首战棋盘不渲染
node_entered.emit(node, {})
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("depth", 0)),
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
"hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(),
"attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退)
"attunement": _attunement,
"ledger": run_ledger, # 共享四账单类下传(单引用键)
"level_counters": level_counters, # 关卡桶下传(同关跨战共享引用、随本类 new 天然空)
"feats": _feats,
"tree_rules": _tree_rules,
"battle_index": _battle_index(),
"voucher_rap": _voucher_rap,
"voucher_time_sand": _voucher_time_sand,
"node_type": String(node.get("type", ""))}):
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("depth", -1)))
return false
_voucher_rap = 0 # 节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清)
_voucher_time_sand = 0
return true
func _forward_step_row(row: Dictionary) -> void:
step_row.emit(row)
# battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕;
# 按已到节点 type=battle 计数,与句柄 id / 节点深度两轴解耦〔身份与位置互不兼任〕;
# 开局姿态组「每关首轮窗」条件面)
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() # 关卡末库存清空(无论输赢,幂等)
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()
# 恢复数学单一路径(漂移③清偿;算术宿主 = 本静态方法,
# 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 解锁门控集注入(AdventureRunner 自 params 传递)
func set_treasure_unlocked(ids: Dictionary) -> void:
_treasure_unlocked = ids
if _settlement != null:
_settlement.set_treasure_unlocked(ids)
# 同调装配集注入(冒险开始参数面;AdventureRunner 唯一调用方)
func set_attunement(ids: Array) -> void:
_attunement = ids.duplicate()
# 特性持有集注入(AdventureRunner._start_current_level 唯一调用方;begin_battle
# enc.feats 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效
# (attune_charges 共享账本同款);空集=零扰动)
var _feats: Array[Variant] = []
func set_feats(ids: Array) -> void:
_feats = ids
# 特性持有集读面(HUD C 区特性段数据源)
func feats_held() -> Array:
return _feats.duplicate()
# 树 rule 键集注入(begin_battle enc.tree_rules 携带;Fork 激活侧 effects)
var _tree_rules: Array[Variant] = []
func set_tree_rules(rules: Array) -> void:
_tree_rules = rules.duplicate()
# 两根类型轴注入(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
# 共享四本账注入(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
# 凭证件三账注入(储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性,
# battle begin 消费后归零;缺省 0=零扰动)
var _voucher_rap := 0
var _voucher_time_sand := 0
func set_voucher_grants(rap: int, time_sand: int) -> void:
_voucher_rap = maxi(0, rap)
_voucher_time_sand = maxi(0, time_sand)
# 同调主动件充能账骑库存面注入(账本住
# RunLedger;差额注入防续玩双份——resume 路径 restore_inventory
# 已含断点内 attune 记录)
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 袋件入账(钱包收口的「奖励入账」入口;复用战斗完成同一入账原语,
# 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 键随离开落空架合法态)
_shop_visit_buys = 0
_shop_paid_refreshes = 0 # MC-1
return _present_choices()
# ---- GP 冒险钱包(入账/出账/收束清零三收口)----
# 战斗完成入账(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", ""))))
# 散折价单点(财富之道·散 economy_spend 激活时商品购买价 ×80% provisional,
# floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折)
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 # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝+静默留痕)
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()
# ---- 商店共享池模式(legacy 固定三件路径保留为 off 对照承载)----
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
# 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。
# 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 # 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"} # 专长凭证占位档名 = excellent
# 商店渠道无专长直接投放(渠道白名单:商店渠道只出属性值提升+资源,专长在非 Boss
# 渠道只以专长凭证形态出现——奇物.md v2.7/A4 断言;专长商店入口=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):免费次数(天赋增益位,基础
# knob=0=「基础免费 0 次」)用尽后 GP 付费刷新;刷新=货架整架重抽(shop_draw 恰一新 scope,
# 与进店/买后重抽同签——重抽同源共享池);池空/非池模式=不可刷新合法态。
func shop_refresh_free_left() -> int:
# 刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用,
# 与基础 knob 同生命周期;未装配=基值)
return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
# 刷新执照装配合计(装配集 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 冒险钱包单口支出(三选一 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