## 三选一停点件:停点编排唯一所有者(架构原则 9 本域承载)。 ## 停点三态(关卡完成三选一在途/已兑付待推进/奖励节点三选一在途);刷新账三源(X 刷新件每碰三选一 ## 重给/优先消耗不结转、Y 营地升级行冒险周期池/用完即无、刷新券存量账/冒险级跨停点持久——账面归 ## RefreshBank 件、编排留本件);兑付方法族(pick/refresh/redeem/skip);凭证出箱(RAP/时沙待注入 ## ——战首取走清零);三选一信号面自带。 ## 零自动推进停点语义承载:兑付只经 pick/apply 显式入口,停点在途时推进被拒(宿主关序受理读放行谓词)。 ## 生命周期=每冒险一次(AdventureRunner 冒险开始建;关卡换实例不换件——每关注入时点经 rebind_level_runner)。 ## 随机只经注入 CurioDeck 的 reward_pick 封闭流;兑付落账经注入主角账件(HeroStats/GainJournal) ## 单写原语;落盘经注入方法 Callable(core↔core 接线禁 lambda 捕获 self)。Core 层零 UI。 class_name CurioStop extends RefCounted 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} var db: ConfigDb var _deck: CurioDeck # 令牌池件(抽签/摘牌/cap/凭证判定——纯池) var _stats: HeroStats # 主角数值账(生命/特性——兑付回血与 relic 入集单写原语) var _journal: GainJournal # 加算日记件(兑付落账单写原语——加算区/钥石读面) var _snapshot_call := Callable() # 落盘回调(宿主断点快照受理——发射点后触发) var _grant_gp := Callable() # 当前关卡对象 grant_curio_gp 方法 Callable(GP 件入账维持现路径与 wallet_changed 发射序——每关注入时点更新,防调死对象) # ---- 停点三态 ---- var _pending := {} # 关卡完成三选一在途:{"candidates": Array, "payload": Dictionary}——空 = 无停点 var _resolved := {} # 已兑付待推进态(curio_resolved 载荷副本——中断续玩不重兑付) var _node_pending := {} # 奖励节点三选一停点态(共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽) # ---- 刷新账(三源账与付费计数归 RefreshBank 件——编排留本件:查停点态→bank 支出→重抽重发)---- var _bank: RefreshBank # 刷新账件(构造注入——零停点态依赖) # ---- 凭证出箱(奇物.md 凭证件表——节点级/回合级一次性,战首取走清零)---- var _pending_rap := 0 # 储备金袋待注入(下一战 RAP +N,战首注入后清零) var _time_sand_queue := 0 # 时沙待注入(下一战首轮 AP +N,战首入队后清零) var _level_index := 0 # 兑付载荷关卡序号(宿主每关注入——与 AdventureRunner.level_index 同源单写) func _init(db_ref: ConfigDb, deps: Dictionary, snapshot_call: Callable) -> void: db = db_ref _deck = deps.get("deck", null) as CurioDeck _stats = deps.get("stats", null) as HeroStats _journal = deps.get("journal", null) as GainJournal _bank = deps.get("bank", null) as RefreshBank _snapshot_call = snapshot_call # 每关注入时点(AdventureRunner._start_current_level 建件接线处调用):关卡序号快照+ # 当前关卡对象 GP 入账方法 Callable(第二关起绑新 runner——防跨关调死对象) func rebind_level_runner(runner: LevelRunner, level_index: int) -> void: _level_index = level_index if runner != null: _grant_gp = runner.grant_curio_gp # ---- 五组入口之一:关卡完成开停点(宿主结算受理调用——回血先行、抽签、发信号、落盘)---- # 关卡完成兑付(非末关恰一次;三层奖励框架):部分回血先行 → 加权洗牌掷 3 → 三选一停点 #(顺序即契约——回血按当刻账本与上限结算,选择停点零自动推进)。调用方(宿主关序)须先置 # 「已兑付待推进」关序位(信号同步派发——处理器内立即确认时推进位必须已就位)。 func open_level_clear(payload_base: Dictionary) -> void: var hp_before := _stats.hero_hp var max_eff := _stats.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) _stats.hero_hp = hp_before + heal _bank.reset_x() # 碰三选一重置 X 余量(不结转) _bank.reset_paid() # 付费刷计数随停点清零(对齐商店进店清零) var pick_payload: Dictionary[Variant, Variant] = { "level_index": _level_index, "candidates": _deck.draw_candidates("level_clear"), # T4 渠道白名单——关底 Boss 击败=关卡完成渠道只出专长 "heal_gained": heal, "hp_before": hp_before, "hp_after": _stats.hero_hp, "keystone_gained": int(_journal.keystone_drop_log().back().get("amount", 0)) if not _journal.keystone_drop_log().is_empty() else 0, "keystone_total": _journal.keystone_gained(), "gp": int(payload_base.get("gp", 0)), # 钱包读数由宿主随载荷_base 注入(停点件构造参只收 stats+journal——零钱包依赖) "aux_cleared": int(payload_base.get("aux_cleared", 0)), # additive 扩签(关卡末清空反馈取数) } _pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true), "payload": pick_payload.duplicate(true)} curio_choice_presented.emit(pick_payload) if _snapshot_call.is_valid(): _snapshot_call.call() # 选择停点可中断(契约 5——续玩不重抽不静默丢失) # ---- 五组入口之二:奖励节点三选一 ---- # 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载, # 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。 func node_curio_candidates() -> Dictionary: var cands := _deck.draw_candidates("reward_node") return {"candidates": cands} func present_node_curio(candidates: Array) -> void: _bank.reset_x() # 碰三选一重置 X 余量(不结转) _bank.reset_paid() # 付费刷计数随停点清零(对齐商店进店清零) _node_pending = {"candidates": candidates.duplicate(true)} # ---- 五组入口之三:兑付族(选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)---- # 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(身份键精确匹配——relic 件 # 另须 feat_id)。属性件写加算区、AP 件写第四轴、GP 件走钱包「奖励入账」入口、恢复件当即回血、 # relic 件入特性持有集(🔴 层级纪律:只走局内随机渠道)、道具件受同调位约束。 # 消费=选中令牌踢池(未选回池由「抽签不摘牌、兑付才摘牌」语义承载——TP0 v2.2)。 func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: if _pending.is_empty(): push_error("[停点件] pick_curio 拒绝——无待选停点") return false var candidates: Array[Variant] = _pending.get("candidates", []) as Array if _deck.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 := _stats.hero_hp 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 求值)。兑付即从牌库摘除该令牌(选中踢池; # 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 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 _deck.consume_deck_token(kind, tier, feat_id): push_error("[停点件] 令牌消费失败——牌库无此令牌: %s|%s|%s" % [kind, tier, feat_id]) return false if _apply_resource_pick(kind, value, feat_id): # 资源件段(refresh/rap/time_sand/relic) return true if _apply_route_pick(kind, value): # 路由加算段(CURIO_APPLY_ROUTES 声明路由) return true return _apply_channel_pick(kind, value) # 副作用通道段(gp/heal 显式分支——未知件 false) # 资源件段(refresh/rap/time_sand/relic——冒险级资源与凭证账面;非资源件 false 交回主路由) func _apply_resource_pick(kind: String, value: int, feat_id: String) -> bool: if kind == "refresh": _bank.add_refresh_stock(value) # T4 F-1——刷新券入刷新存量账(冒险级;**不写 X 临时账**:X 每停点 reset_x 重置会吞存量;摘牌先于落账=选中即消尽) _bump_taken("refresh") return true if kind == "rap": _pending_rap += value # T4——储备金袋:下一战 RAP +N(节点级,战首注入后清零) _bump_taken("rap") return true if kind == "time_sand": _time_sand_queue += value # T4——时沙:下一战首轮 AP +N(回合级池增量,不改上限) _bump_taken("time_sand") return true if kind == "relic": if not _stats.feats.has(feat_id): _stats.feats.append(feat_id) _bump_taken("relic") _bump_taken("relic:" + feat_id) # 身份维已拿计数(cap 消费面) return true return false # 路由加算段(match 收敛进效果类型:属性值提升四类走 adventure_table.CURIO_APPLY_ROUTES 声明 # 路由(bonus 落点键),加新加算类令牌零代码) func _apply_route_pick(kind: String, value: int) -> bool: var sink := db.adventure.curio_apply_route(kind) if sink == "": return false _journal.gain_bonus(sink, value, "curio_pick", kind) # 加算区单写原语(获得日记明细+汇总同拍) _bump_taken(kind) return true # 副作用通道段(副作用形态——显式分支:GP 件走关卡对象入账回调、恢复件当即回血) func _apply_channel_pick(kind: String, value: int) -> bool: match kind: "gp": if _grant_gp.is_valid(): _grant_gp.call(value) _bump_taken("gp") "heal": _stats.hero_hp = mini(_stats.hero_hp + value, _stats.effective_max_hp()) _bump_taken("heal") _: return false return true # 件获取次数计数递增(kind 维度派生账本——池件侧承载) func _bump_taken(kind: String) -> void: _deck.taken[kind] = int(_deck.taken.get(kind, 0)) + 1 # 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。 # picked = kind;tier 随载荷(TP0——大小档位读数面);relic 伴随键随载荷(反馈行取数) func _finish_curio_pick(picked: String, value: int, hp_before: int, tier: String = "", feat_id: String = "") -> void: _pending = {} _resolved = { "picked": picked, "tier": tier, "value": value, "hp_before": hp_before, "hp_after": _stats.hero_hp, "level_index": _level_index, } _resolved.merge(_feat_payload_keys(feat_id)) curio_resolved.emit(_resolved.duplicate(true)) if _snapshot_call.is_valid(): _snapshot_call.call() # 点选兑付(节点域——无部分回血无钥石;令牌消费与牌库摘牌与关卡完成三选一同库;relic 同权入持有集) func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: if kind == "feat_ticket": return _redeem_feat_ticket() # T5 拿到即开——节点停点候选替换为专长三选一(呈现层本地重绘) if _node_pending.is_empty(): push_error("[停点件] apply_node_curio 拒绝——无待选停点") return false var candidates: Array[Variant] = _node_pending.get("candidates", []) as Array if _deck.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 # 停点空候选消化(池空=三选一环节整体跳过:候选 0 件时停点视同已处理, # 呈现层跳开选择面直接走后续推进。仅空候选合法;非空停点拒绝——零自动推进契约不破,正常 # 消化仍只走 pick_curio/apply_node_curio 兑付。触发源=渠道白名单过滤后池空(关卡 Boss 击破=关卡完成 # 渠道只出专长,专长零配置/旧档 deck 无 relic 余库即空集)。) func skip_empty_curio() -> bool: if _pending.is_empty(): return false if not (_pending.get("candidates", []) as Array).is_empty(): return false _pending = {} return true # 专长凭证兑付(拿到即开——停点候选替换为专长三选一;不进持有集/零摘牌;限购计数 +1): # 节点停点=本地替换(呈现层读在途态重绘);店内无停点=临时停点 + payload.context 标记(呈现层 # 重 present 无胜利总结面,确认后只关面回店)。 func _redeem_feat_ticket() -> bool: _deck.ticket_sold += 1 # 限购计数(本体在池件——原宿主转发属性迁点) _deck.ticket_pity = 0 var cands := _deck.draw_candidates("level_clear") # 专长池(与关卡完成渠道同白名单) if cands.is_empty(): push_error("[停点件] 专长凭证兑付失败——专长池空集") _deck.ticket_sold -= 1 return false if not _node_pending.is_empty(): _node_pending["candidates"] = cands.duplicate(true) return true var payload: Dictionary[Variant, Variant] = {"context": "feat_ticket", "candidates": cands.duplicate(true)} _pending = {"candidates": cands.duplicate(true), "payload": payload.duplicate(true)} curio_choice_presented.emit(payload) if _snapshot_call.is_valid(): _snapshot_call.call() return true # ---- 五组入口之四:刷新编排(三源账与报价归 RefreshBank 件——本件只做停点态校验、 # 支出分派与重抽重发;重抽消费 reward_pick 既有洗牌流,池规则全保留——不放回/ # 达上限剔牌/掷定前置直显;停点可中断)---- # 节点停点在途态读回(组合根重 present 用——candidates 值拷贝) func node_curio_pending_state() -> Dictionary: return { "candidates": (_node_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) # 三选一停点在途谓词(付费路径扣费前置校验——无停点禁扣钱,防空转烧钱包) func curio_stop_pending() -> bool: return not _pending.is_empty() or not _node_pending.is_empty() # 放行谓词(宿主关序受理——已兑付待推进且无在途停点) func stop_resolved() -> bool: return not _resolved.is_empty() and _pending.is_empty() func _refresh_curio_common(paid: bool) -> bool: if not curio_stop_pending(): return false if paid: _bank.charge_paid() # 付费刷计数递增(报价曲线输入——账面归刷新账件) else: if not _bank.spend_free(): # 免费位消耗序 X→Y→存量(三源全空 false 零副作用——序与语义逐字归账件) return false if not _pending.is_empty(): var cands := _deck.draw_candidates("level_clear") _pending["candidates"] = cands.duplicate(true) var payload: Dictionary[Variant, Variant] = (_pending.get("payload", {}) as Dictionary).duplicate(true) payload["candidates"] = cands payload["refreshed"] = true # 重发载荷带标——呈现层直落选择面,不再重开胜利总结面 curio_choice_presented.emit(payload) # 同信号重 present(零自动推进契约不破——停点仍在途) if _snapshot_call.is_valid(): _snapshot_call.call() return true if not _node_pending.is_empty(): var cands2 := _deck.draw_candidates("reward_node") _node_pending["candidates"] = cands2.duplicate(true) return true return false func _finish_node_bonus(picked: String, value: int, tier: String = "", feat_id: String = "") -> void: _node_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) # 商店共享池消费单点(兑付单点——ShopSession.buy_pool_item 经注入 Callable 调用,回 _apply_curio_pick # 同口;专长凭证买下即开零摘牌)。未知 kind 拒绝(货架词表封闭的前置防御)。 func consume_pool_purchase(kind: String, tier: String) -> bool: if kind == "feat_ticket": return _redeem_feat_ticket() if not db.adventure.CURIO_KINDS.has(kind): push_error("[停点件] 商店购买消费拒绝——件不在池词表: %s" % kind) return false return _apply_curio_pick(kind, tier, int(db.adventure.curio_value(kind, tier))) # ---- 冒险级复位(宿主 _reset_curio_state 调用——停点半:池归 deck.reset、停点三态与刷新账清零)---- func reset_adventure_state() -> void: _pending = {} _resolved = {} _node_pending = {} _bank.reset_all() # 刷新三源与付费计数清零(账面归刷新账件) _pending_rap = 0 _time_sand_queue = 0 # 续玩选择停点回填(token_migration 类:旧版断点候选为 kind 字符串,按普通档令牌防御归一; # 发射回宿主信号面由调用方承接——本件只载态) func restore_pending(bp_pending: Dictionary) -> void: 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}) _pending = {"candidates": migrated} var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true) curio_choice_presented.emit(payload) # ---- 凭证出箱(战首取走清零——LevelRunner.set_voucher_grants 注入面同语义)---- func voucher_outbox() -> Dictionary: var out := {"rap": _pending_rap, "time_sand": _time_sand_queue} _pending_rap = 0 _time_sand_queue = 0 return out # 已兑付态清除(宿主 continue_to_next_level 推进受理时调用——重兑付防御复位面) func clear_resolved() -> void: _resolved = {} # 已兑付态注入+重发(宿主 resume 已兑付分支调用——置态后重发载荷,信号面归停点件: # 续玩不重兑付只重发;原 restore_resolved/reemit_resolved 两步收编一步,时序逐位同) func restore_resolved(payload: Dictionary) -> void: _resolved = payload.duplicate(true) curio_resolved.emit(_resolved.duplicate(true)) # ---- 断点自交片段(应用层拼盘口收件)---- # 片段键:bonus_pending/bonus_result/feat_ticket_pity/feat_ticket_sold #(feat_ticket_* 现役为宿主字段——随停点件迁移同片段承载;refresh_stock/attune_refresh_y 两键 # 归 RefreshBank 片段,宿主拼盘同一并集) func breakpoint_fragment() -> Dictionary[Variant, Variant]: return { "bonus_pending": _pending.duplicate(true), "bonus_result": _resolved.duplicate(true), "feat_ticket_pity": _deck.ticket_pity, "feat_ticket_sold": _deck.ticket_sold, } # 片段回填(凭证计数;停点在途态与已兑付态归宿主主分支 # restore_pending/restore_resolved 承接——token_migration 与信号发射时序在彼处) func restore_fragment(frag: Dictionary, _params: Dictionary) -> void: _deck.ticket_pity = int(frag.get("feat_ticket_pity", 0)) # T5 扩列——旧档缺键缺省 0(存档兼容) _deck.ticket_sold = int(frag.get("feat_ticket_sold", 0))