652 lines
37 KiB
GDScript
652 lines
37 KiB
GDScript
## 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)
|
||
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 _hero: HeroLedger # 主角实体账(生命/钱包/加算区/类型轴/特性集/钥石/真视——拆件批 T1 建件)
|
||
var adventure_counters: Dictionary[Variant, Variant] = {} # 冒险桶(效果引擎 adv_ 计数器宿主字典——跨战持久)
|
||
var run_ledger := RunLedger.new() # 充能账瘦壳(attune_charges——整类退役归姊妹副卡)
|
||
var level_index := 0 # 当前关卡序号(1 起)
|
||
var levels_total := 0 # 序列总关数(读 level 表行数)
|
||
var master_seed := 0 # 本局主种子(摘要第五件事)
|
||
var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成)
|
||
var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离)
|
||
var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认
|
||
var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面)
|
||
var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面)
|
||
var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面)
|
||
var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner)
|
||
var _save_manager = null # 断点承载注入(null = 不写断点;模拟/冒烟缺省)
|
||
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
|
||
# ---- 三选一停点(编排迁 CurioStop 件——停点三态/刷新三源/凭证出箱/信号面)----
|
||
var _curio: CurioDeck = null # 令牌池件(_init 建;纯池——多重集/抽签/cap/凭证判定唯一宿主)
|
||
var _stop: CurioStop = null # 停点件(_init 建;每冒险一次——关卡换实例不换件)
|
||
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 _tree_rules: Array[Variant] = [] # 树 rule 键集(Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面)
|
||
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
|
||
_hero = HeroLedger.new(db, {})
|
||
_curio = CurioDeck.new(db, rng)
|
||
_stop = CurioStop.new(db, _curio, _hero, _snapshot_breakpoint)
|
||
|
||
|
||
# 件出口(组件交出非委托壳——呈现/测量面取主角账单点;拆件批接口块)
|
||
func hero() -> HeroLedger:
|
||
return _hero
|
||
|
||
|
||
# 件出口(停点件——呈现/测量面取停点编排单点;拆件批接口块)
|
||
func stop() -> CurioStop:
|
||
return _stop
|
||
|
||
|
||
# ---- 冒险生命周期 ----
|
||
|
||
# 冒险开始(主种子注入恰一次——全冒险唯一 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)
|
||
_hero.rebase_override(params.get("hero_override", {})) # 永久强化 flat 注入(规范化归主角账)
|
||
_hero.reset_adventure_accounts() # 账面重置(钱包/加算区/已购档/钥石/日记/特性清零+生命置满基础上限)
|
||
_hero.attack_type = String(params.get("attack_type", "")) # 两根类型轴快照(契约 8 锁死:开局后不随存档选中变化)
|
||
_hero.armor_type = String(params.get("armor_type", ""))
|
||
summary = {}
|
||
_reset_curio_state()
|
||
levels_total = db.level_count() # 表访问收口(关数读 level 表行数)
|
||
level_index = 1
|
||
_pending_next_level = false
|
||
_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
|
||
_stop.set_attunement(_attunement) # 停点件 X 基数查询消费面(活引用)
|
||
_stop.set_refresh_y(maxi(0, int(params.get("attune_refresh_y", 0)))) # additive(Y 冒险周期池)
|
||
_seed_attune_charges() # 冒险开始充能账本按装备主动件重置(每冒险充能语义)
|
||
adventure_counters = {} # 冒险桶清零(效果引擎 adv_ 计数器宿主——冒险开始归零)
|
||
# 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓:
|
||
# 装配集 kind 分发扫描;只入存量账、临时账不动=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"):
|
||
_stop.add_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():
|
||
for r in feat_seed:
|
||
_hero.feats.append(String(r))
|
||
_state = State.RUNNING
|
||
return _start_current_level()
|
||
|
||
|
||
# 断点续玩(「继续上局」入口;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, params)
|
||
_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():
|
||
_pending_next_level = true
|
||
_state = State.RUNNING
|
||
_stop.restore_pending(bp_pending) # 选择停点直回(续玩不重跑关卡——信号经停点件中继)
|
||
return true
|
||
if not bp_result.is_empty():
|
||
_pending_next_level = true
|
||
_stop.restore_resolved(bp_result)
|
||
_state = State.RUNNING
|
||
_stop.reemit_resolved() # 已兑付不重兑付只重发(信号面归停点件)
|
||
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, params: Dictionary) -> void:
|
||
_hero.restore_fragment(bp, params)
|
||
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
|
||
# 特性持有集还原(缺键 = 旧版断点防御空集——主角账字段单点)
|
||
_hero.feats = []
|
||
for r in (bp.get("relics", []) as Array):
|
||
_hero.feats.append(String(r))
|
||
|
||
# 续玩参数面回填(同 start_adventure 参数面;生命账本越界钳制在此落——clamp 类)
|
||
func _resume_params(bp: Dictionary, params: Dictionary) -> void:
|
||
_hero.rebase_override(params.get("hero_override", {})) # 续玩参数面同 start_adventure(规范化归主角账)
|
||
_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 后乘调试浮窗 pct(保守口径不含加算区——与原钳制式逐字同,加算区恢复后其余战斗照常)。
|
||
_hero.hero_hp = mini(_hero.hero_hp, maxi(1,
|
||
int(round(float(int(db.hero.max_hp) + int(_hero.base_override_flat().get("max_hp", 0))) * (100.0 + float(_hero.debug_hp_pct())) / 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
|
||
_seed_attune_charges() # 冒险开始充能账本按装备主动件重置(每冒险充能语义)
|
||
_stop.restore_fragment(bp, params) # 停点件片段回填(凭证计数/刷新存量/Y 池——Y 缺键回退参数面 params_fallback 类)
|
||
_tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # A/B 面 rule 键集(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传)
|
||
|
||
|
||
# 续玩计数器回填(冒险域计数——凭证计数/刷新存量/Y 池随停点件片段回填)
|
||
func _resume_counters(bp: Dictionary) -> void:
|
||
# adventure 域计数器回填(缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先——桶归宿主字典)
|
||
var bp_counters = _bp_read("adventure_counters", bp)
|
||
if bp_counters is Dictionary:
|
||
adventure_counters = (bp_counters as Dictionary).duplicate(true)
|
||
|
||
|
||
func _reset_curio_state() -> void:
|
||
_curio.reset() # 池与件获取计数(池半归件;停点态归停点件下行清理)
|
||
_stop.reset_adventure_state()
|
||
_hero.feats = []
|
||
adventure_counters = {}
|
||
|
||
|
||
# 调试浮窗百分比通道(测试工具覆盖——每关新 runner 都要应用;正式侧归元进度。单点归主角账)
|
||
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
|
||
_hero.set_debug_override(atk_pct, hp_pct)
|
||
if _runner != null:
|
||
_runner.set_hero_override(atk_pct, hp_pct)
|
||
|
||
|
||
# 冒险账本侧生效上限(公式单点归主角账——置满/回血封顶与战斗内上限同式同源)
|
||
func effective_max_hp() -> int:
|
||
return _hero.effective_max_hp()
|
||
|
||
|
||
# 断点承载注入(组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默)
|
||
func set_save_manager(sm: SaveManagerCore) -> void:
|
||
_save_manager = sm
|
||
|
||
|
||
# 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取)
|
||
func runner() -> LevelRunner:
|
||
return _runner
|
||
|
||
|
||
# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶)。算术宿主 = HeroLedger.rest_heal(公式单点)
|
||
func apply_rest(pct: int, hp: int, max_hp: int) -> int:
|
||
return HeroLedger.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 _stop.curio_stop_pending():
|
||
push_error("[冒险结构] continue_to_next_level 拒绝——三选一停点未兑付(零自动推进契约)")
|
||
return false
|
||
_pending_next_level = false
|
||
_stop.clear_resolved()
|
||
level_index += 1
|
||
return _start_current_level()
|
||
|
||
|
||
# 停点空候选消化(池空=三选一环节整体跳过——呈现层跳开选择面;编排归停点件)
|
||
func skip_empty_curio() -> bool:
|
||
return _stop.skip_empty_curio()
|
||
|
||
|
||
# ---- 提升三选一(选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)----
|
||
|
||
# 候选令牌匹配(身份键恒等——属性加成型特性 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)
|
||
|
||
|
||
# 玩家点选兑付(state 守卫留应用层——编排归停点件;呈现层直调面 T5 改 stop())
|
||
func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool:
|
||
if state != "running":
|
||
push_error("[冒险结构] pick_curio 拒绝——state=%s(非 running 不可兑付)" % state)
|
||
return false
|
||
return _stop.pick_curio(kind, tier, feat_id)
|
||
|
||
|
||
# 特性持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写)
|
||
func feats_held() -> Array:
|
||
return _hero.feats_held()
|
||
|
||
|
||
# ---- 商店共享池消费面(纯池——库存=牌库剩余令牌)----
|
||
|
||
# 库存余量(池消耗即上限;奖励摘牌与商店购买同库承载,全冒险一次性)
|
||
func pool_stock(kind: String) -> int:
|
||
return _stop.pool_stock(kind)
|
||
|
||
|
||
# 终局池快照(_settle 清账前调用——复位会重置计数,摘要 additive 键须在此前取数)
|
||
func _pool_stock_snapshot() -> Dictionary:
|
||
return _stop.pool_stock_snapshot()
|
||
|
||
|
||
# ---- 奖励节点三选一(与关卡完成三选一共享同池同权重同次数上限——编排归停点件)----
|
||
|
||
# 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载,
|
||
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。
|
||
func node_curio_candidates() -> Dictionary:
|
||
return _stop.node_curio_candidates()
|
||
|
||
|
||
func present_node_curio(candidates: Array) -> void:
|
||
_stop.present_node_curio(candidates)
|
||
|
||
|
||
# 点选兑付(节点域——编排归停点件;呈现层直调面 T5 改 stop())
|
||
func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") -> bool:
|
||
return _stop.apply_node_curio(kind, tier, feat_id)
|
||
|
||
|
||
# 冒险开始充能账本重置(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 池 + 刷新存量账——呈现层余量读数口)
|
||
func attune_refresh_left() -> int:
|
||
return _stop.attune_refresh_left()
|
||
|
||
|
||
# 刷新存量账读数(T4——刷新券加仓余量单独读面;呈现层与断点消费)
|
||
func refresh_stock() -> int:
|
||
return _stop.refresh_stock()
|
||
|
||
|
||
# 节点停点在途态读回(组合根重 present 用——candidates 值拷贝)
|
||
func node_curio_pending_state() -> Dictionary:
|
||
return _stop.node_curio_pending_state()
|
||
|
||
|
||
# 三选一刷新(免费位 X/Y——组合根唯一调用口;无停点/无余量 false 零副作用)
|
||
func refresh_curio() -> bool:
|
||
return _stop.refresh_curio()
|
||
|
||
|
||
# 三选一 GP 付费刷新(对齐商店 MC-1——组合根先经 curio_stop_pending+钱包支出再调,本口只做计数与重抽)
|
||
func refresh_curio_paid() -> bool:
|
||
return _stop.refresh_curio_paid()
|
||
|
||
|
||
# 本次 GP 付费刷新价(与商店货架刷新同源——单点调参)
|
||
func curio_refresh_gp_cost() -> int:
|
||
return _stop.curio_refresh_gp_cost()
|
||
|
||
|
||
# 三选一停点在途谓词(付费路径扣费前置校验——无停点禁扣钱,防空转烧钱包)
|
||
func curio_stop_pending() -> bool:
|
||
return _stop.curio_stop_pending()
|
||
|
||
|
||
# —— 专长获取专长凭证四口径判定(实装归 CurioDeck 件:调用即一次展示判定与记账)——
|
||
func _feat_ticket_consider(rg: RandomNumberGenerator) -> bool:
|
||
return _curio.feat_ticket_consider(rg)
|
||
|
||
|
||
# 已拿上限判定(身份维度;缺省放行=白名单外 kind 兜底)
|
||
func _curio_cap_ok(kind: String, feat_id: String = "") -> bool:
|
||
return _curio.cap_ok(kind, feat_id)
|
||
|
||
|
||
# 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))
|
||
_hero.record_keystone_drop({"level": li, "first": first, "amount": 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:
|
||
# 关卡对象构造注入一次给齐(拆件批 T3——ctx 键集见 LevelRunner._init 头注;
|
||
# 凭证出箱在组包时点取走〔节点级一次性——未用即过期语义不变〕)
|
||
var outbox: Dictionary[Variant, Variant] = _stop.voucher_outbox()
|
||
var pool_wiring: Dictionary[Variant, Variant] = {"deck": _curio_deck, "consume": _stop.consume_pool_purchase,
|
||
"ticket": _feat_ticket_consider, "cap": _curio_cap_ok} if _shop_mode == "pool" else {}
|
||
var ctx: Dictionary[Variant, Variant] = {
|
||
"hero": _hero,
|
||
"ledger": run_ledger,
|
||
"adv_bucket": adventure_counters,
|
||
"attunement": _attunement,
|
||
"tree_rules": _tree_rules,
|
||
"shop_mode": _shop_mode,
|
||
"treasure_unlocked": _treasure_unlocked,
|
||
"hero_attack_type": _hero.attack_type,
|
||
"hero_armor_type": _hero.armor_type,
|
||
"voucher_rap": int(outbox.get("rap", 0)),
|
||
"voucher_time_sand": int(outbox.get("time_sand", 0)),
|
||
"pool_wiring": pool_wiring,
|
||
}
|
||
_runner = LevelRunner.new(db, rng, ctx)
|
||
_bind_level_runner()
|
||
_stop.rebind_level_runner(_runner, level_index) # 停点件每关注入时点(GP 件入账回调换代——防跨关调死对象)
|
||
# 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解),
|
||
# 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}
|
||
# 接口①开始关卡:生命经主角账共享交接(账面当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
|
||
# 主角账与冒险桶注入(set_hero——T1 过渡面,T3 收构造注入)。
|
||
# defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
|
||
# 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。
|
||
# 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。
|
||
var ok: bool = _runner.start_level_external(level_index, apply_rest, true, resume_state)
|
||
if ok:
|
||
_emit_level_started()
|
||
_present_level_entry(resume_state)
|
||
_resume_bp = {}
|
||
return ok
|
||
|
||
|
||
# 关卡行进器接线(每关新实例:结算受理连接 + 能力件骑库存注入——信号转发族已退役,
|
||
# 呈现/模拟器在 level_started 重取点直连 runner 信号〔is_connected 守卫恰好一次〕)
|
||
func _bind_level_runner() -> void:
|
||
# core↔core 接线只许方法 Callable(S6——lambda 捕获 self 会延长 RefCounted 生命周期乃至成环)
|
||
_runner.level_finished.connect(_on_level_finished)
|
||
_runner.choices_presented.connect(_on_level_choices_presented) # 选路停点快照触发(原转发兼快照双职的核心侧受理)
|
||
_runner.sync_attune_inventory() # 能力件骑库存面注入(构造注入同拍)
|
||
|
||
|
||
# 关卡开始广播(装配已随构造注入进 ctx——本函数只剩信号广播;信号顺序契约:
|
||
# 关卡开始信号先于入口候选呈现〔defer_entry 两步式以本发射为界〕)
|
||
func _emit_level_started() -> void:
|
||
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":
|
||
_hero.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:
|
||
# 关卡完成兑付(非末关恰一次;三层奖励框架):编排归停点件——推进位先于 emit 置位
|
||
#(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位);
|
||
# 停点件内部分回血先行 → 加权洗牌掷 3 → 三选一停点(顺序即契约——回血按当刻账本与上限结算,
|
||
# 选择停点零自动推进)
|
||
_pending_next_level = true
|
||
_stop.open_level_clear(payload)
|
||
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": _hero.keystone_gained(),
|
||
"master_seed": master_seed,
|
||
"aux_cleared_last": _last_treasure_cleared, # additive 扩签(末关清空反馈;非末关走 bonus 载荷)
|
||
"keystone_drops": _hero.keystone_drop_log(), # additive 扩签(逐杀掉落明细;settle 入账明细/复盘归因数据源)
|
||
"vision_used_delta": _hero.vision_used(), # additive 扩签(真视使用本局计数;settle 聚合入档)
|
||
"pool_stock": _pool_stock_snapshot(), # additive 扩签(终局池读数:清账前快照,商店买走 vs 奖励领走 vs 枯竭的终局面)
|
||
"relics": _hero.feats_held(), # 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
|
||
|
||
|
||
# 选路停点快照受理(level.choices_presented 直订——选路停点=唯一可恢复停点形态;战斗中不存)
|
||
func _on_level_choices_presented(_candidates: Array) -> void:
|
||
_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_* 附随件(选择停点/已兑付停点快照)
|
||
var hero_frag: Dictionary[Variant, Variant] = _hero.breakpoint_fragment() # 主角账片段(账本键+获得日记——T4 拼盘收口前先同源取数)
|
||
var stop_frag: Dictionary[Variant, Variant] = _stop.breakpoint_fragment() # 停点件片段(T4 拼盘收口前先同源取数)
|
||
return {
|
||
"level_index": level_index,
|
||
"node_id": _runner.current_node_id() if _runner != null else -1,
|
||
"hero_hp": int(hero_frag.get("hero_hp", 0)),
|
||
"bonus": hero_frag.get("bonus", {}),
|
||
"gp": int(hero_frag.get("gp", 0)),
|
||
"shop_purchased": hero_frag.get("shop_purchased", {}),
|
||
"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": int(hero_frag.get("keystone", 0)),
|
||
"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": hero_frag.get("relics", []), # 特性持有集(局内随机渠道获得态续玩防丢失)
|
||
"bonus_pending": stop_frag.get("bonus_pending", {}), # 三选一停点态(空 = 无停点——选择停点可中断契约)
|
||
"feat_ticket_pity": int(stop_frag.get("feat_ticket_pity", 0)), # T5 专长凭证保底计数(扩列向后兼容——旧档缺键缺省 0)
|
||
"feat_ticket_sold": int(stop_frag.get("feat_ticket_sold", 0)), # T5 专长凭证限购计数(同上)
|
||
"adventure_counters": adventure_counters.duplicate(true), # adventure 域计数器随断点(续玩跨战账不丢——桶归宿主字典)
|
||
"vision_counter_used": int(hero_frag.get("vision_counter_used", 0)), # 真视使用计数随断点(续玩跨战账不丢)
|
||
"refresh_stock": int(stop_frag.get("refresh_stock", 0)), # 刷新存量账随断点
|
||
"attune_refresh_y": int(stop_frag.get("attune_refresh_y", 0)), # Y 冒险周期池余量随断点(续玩不再自参数面整额重注入=堵「用完退菜单再续玩满血复活」可利用漏洞;扩列向后兼容——旧档缺键回退参数面)
|
||
"bonus_result": stop_frag.get("bonus_result", {}), # 已兑付待推进态(空 = 无——中断续玩不重兑付)
|
||
"keystone_drops": hero_frag.get("keystone_drops", []), # 逐杀掉落明细(摘要归因续接)
|
||
}
|
||
|
||
|
||
# 读侧单点(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))
|
||
|
||
|