Files
gplan-2/game/core/adventure_runner.gd
T

990 lines
55 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
## 冒险结构编排器:三关序列、生命/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:<feat_id>)随快照还原
# 令牌池余库还原(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)