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

1072 lines
64 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
# Validation Question: 三关连探冒险(地精洞窟 → 幽暗地域 → 灰矮人矿城)能否以一条命走完——
# 主种子恰一次注入、生命/GP/加算区跨关同账、大奖励非末关恰兑付一次、三结局摘要五件事成立(P0)
# Date: 2026-09-21(冒险结构实现批——设计权威 design/gdd/systems/冒险循环/冒险结构.md)
# 冒险结构编排器(Core 层零 UI 依赖——headless 可跑完整冒险):一局的关卡序列、生命账本、GP 冒险
# 账本、统一冒险加算区、关卡大奖励、三结局结算与主种子接线的唯一所有者。
#
# 接口契约四件套落点(冒险结构.md「与关卡节点地图的边界」——本类是正式侧承载):
# ① 开始关卡 = start_level_external(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接);
# ② 战斗结果上报 = runner 经共享账本属性直写(LevelRunner._on_battle_finished 回写 hero_hp 到
# _ledger——零直调战斗域,战后生命终值随账本回流本类);
# ③ 治疗封顶回报 = apply_rest(恢复点节点兑付时 LevelRunner 经 _rest_handler 回调——按比例回血并
# 封顶的执行归本类);
# ④ 关卡结束 = _on_level_finished(LevelRunner.level_finished 到达——结果 + 生命终值 + 节点位置)。
#
# 接口漂移清偿登记(06 清单①②③⑥⑦——LevelRunner 侧同批登记):①种子域归本类
# (start_adventure 调 rng.new_adventure 全冒险恰一次;LevelRunner 冒险模式不重播种);
# ②生命账本归本类(共享 _ledger);③恢复兑付执行归本类(_rest_handler);⑥收束先清库后宣布——
# 现行无关卡域库存(格面资源批 2026-09-15 后为空集),战斗结束 retire_mapping + despawn_all 先于
# battle_finished 即事实清库,不另设空接口;⑦失败结算归本类(LevelRunner 只上报结果)。
#
# 收束次序登记(大奖励兑付时点——冒险结构.md「关卡大奖励」):关卡节点地图先清库、后宣布关卡
# 结束(次序不可换);大奖励在宣布之后、下一关开始之前兑付——本类 _on_level_finished 兑付、
# continue_to_next_level 开始下一关,两步间为呈现层大奖励屏(冒烟/自动路径立即确认)。
# 结算受理只算一次(契约):state != "running" 的关卡结束宣布与放弃请求一律拒绝并留诊断,
# 账本与摘要零变化。
# 停点前移批(2026-09-23,用户拍板「关卡开始即出节点图选路、选节点才进战斗」):每关开始 =
# 入口选路停点——_start_current_level 以 defer_entry 起关,level_started 之后才 present_entry_choice
# (信号顺序契约:呈现层先重取 runner 实例,选路口才不会被空防御丢弃);pick 入口候选走同一
# _enter_node(entry)、呈现零 RNG 消耗——与旧「start 直进首战」逐位等价,测量面零漂移。
# 关卡切换(continue_to_next_level)后新 LevelRunner 实例重新走 defer 流程;abandon 在入口停点
# 天然非战斗期(current_battle 为空)——受理语义与选路停点一致。
# 纪律:ADR-0001 S6——连 runner 信号一律 _forward_* 方法 Callable(禁 lambda 捕获 self 成环);
# Core 层禁 UI 引用;数值零字面量——level_clear_hp_bonus / keystone_per_level 只读 db.adventure,
# 基础上限只读 db.hero。
# 断点续玩(迭代 8 卡①):save_manager 经 set_save_manager 注入(缺省 null = 不写断点——模拟/
# 冒烟零触达零扰动);停点快照挂 choices_presented 转发点(选路停点=唯一可恢复停点形态,战斗中
# 不存);三结局 _settle 清断点;resume_adventure 经 rng.restore_adventure 回填「主种子+计数器」
# ——续玩从断点号位精确续接原随机时间线。
class_name AdventureRunner
extends RefCounted
signal level_started(level_index: int, display_name: String) # 每关开始(含冒险首关)——呈现层重取 runner()、刷新图条与层名行
signal curio_choice_presented(payload: Dictionary) # 提升三选一停点(非末关通关恰一次;迭代 10 大奖励重构——big_reward_paid 退役)——{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 treasure_replace_presented(items: Array, incoming: Dictionary) # 同调位满位替换子交互(pick_curio 道具件满位时)——items=现有持有件记录、incoming=新件记录
signal adventure_finished(summary: Dictionary) # 冒险结算(completed/failed/abandoned)——summary 五件事:打穿没有/通关几关/止步第几关/钥石合计/主种子(+additive keystone_drops 迭代 10)
# ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明;ADR-0001 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
var state := "idle" # idle / running / settling(冒险结构.md 三态)
var ledger := {"hero_hp": 0, "gp": 0} # 冒险账本(生命 + GP 钱包——经 start_level_external 注入 runner 共享)
var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 统一冒险加算区(商店购买与三选一属性件同一本账;四轴 +ap——迭代 10 奖励重构)
var purchased := {} # product_id → 已购档数(跨关延续、冒险收束清零——递增定价输入)
var level_index := 0 # 当前关卡序号(1 起)
var levels_total := 0 # 序列总关数(读 level 表行数)
var keystone := 0 # 钥石入账累计(摘要第三件事的账面)
var master_seed := 0 # 本局主种子(摘要第五件事)
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}
var _first_script := false # 首次脚本模式(关卡节点图生成 v0.4——true = 全关零抽签取池内模板 0;测量夹具恒 false) # 永久强化 flat 注入(元进度实现批——冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板)
var _last_treasure_cleared := 0 # additive(迭代 7 T4——最近一次关卡末清空计数,summary 取数面)
var _treasure_unlocked := {} # aux 解锁门控集(迭代 7 T5——params.treasure_unlocked 接收面)
var _attunement: Array = [] # 同调装配集(迭代 11 第①批 T5——params.attunement 接收面,start_level 外下传 LevelRunner)
var _attune_refresh_x := 0 # 刷新件余量(迭代 11 第①批 T5b P1 甲——每碰三选一重置=X 表值;优先消耗、不结转)
var _attune_refresh_y := 0 # Y 冒险周期池(开局参数面注入,用完即无——营地升级行表值)
var attune_charges := {} # 同调主动件冒险充能账本(迭代 11 第①批 T6——跨关卡共享 dict;settlement use_treasure 扣减直改同引用;断点随 attunement_charges 键入档)
var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省)
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
# ---- 三选一停点态(迭代 10 奖励重构批)----
var _curio_taken := {} # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载;池库存正主=_curio_deck)
var _curio_deck: Array = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账;迭代 12 T1 起规则改写型特性带=relic 令牌随表追加)
var _feats: Array[String] = [] # 特性持有集(迭代 12 T1——局内随机渠道获得、冒险级跨关携带、收束清零;🔴 层级纪律承载面:不入同调位不入局外树)
var _tree_rules: Array = [] # 树 rule 键集(迭代 12 T10——Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面)
var _curio_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点
var _curio_result := {} # 已兑付待推进态(curio_resolved 载荷副本——中断续玩不重兑付)
var _bonus_treasure_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗)
var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源)
var _last_draw_treasure: Array[String] = [] # 最近一次抽签掷定的道具件(掷定前置——卡片直显具体道具;追加批二)
var _node_curio_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽)
var _feat_ticket_pity := 0 # 门票保底计数(T5——展示次数:节点开面/货架抽签/刷新重展各计一次;触发清零)
var _feat_ticket_sold := 0 # 门票限购计数(T5——选中/买下即 +1;拿到即开不进持有集)
var _feat_ticket_appeared := 0 # 门票出现计数(T5——quota 上限面)
var _shop_mode := "legacy" # 商店模式(迭代 11 第②批 T3——"legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
# ---- 冒险生命周期 ----
# 冒险开始(主种子注入恰一次——全冒险唯一 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}
bonus = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0}
purchased = {}
keystone = 0
summary = {}
_reset_curio_state()
levels_total = db.level_count() # 表访问收口(2026-10-02——行集直扫退役)
level_index = 1
_pending_next_level = false
_base_override = _normalize_override(params.get("hero_override", {}))
_first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入)
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 迭代 11 第②批 T3——缺省 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(迭代 11 第①批 T5)
_attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池)
_seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义) # additive(迭代 7 T5)
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive(迭代 11 第①批 T5)
_attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池)
_seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义)
# 特性开局注入(迭代 12 T7——MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派
# 各 2 件档一成型——测量面专用;真实局规则改写型特性只走局内随机渠道,红线②层级纪律不破〕)
var feat_seed: Array = 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 [] # 迭代 12 T10——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() # 生命账本置满(生效上限口径——T17 修复:含调试浮窗 pct,与战斗内同源)
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
# 断点续玩(迭代 8 卡①——「继续上局」入口;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
master_seed = int(bp.get("master_seed", 0))
rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0)))
ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))}
var bp_bonus: Dictionary = bp.get("bonus", {}) as Dictionary
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() # 表访问收口(2026-10-02——行集直扫退役)
level_index = int(bp.get("level_index", 1))
_pending_next_level = false
_reset_curio_state()
var bp_taken: Dictionary = bp.get("bonus_taken", {}) as Dictionary
for id in _curio_taken.keys():
_curio_taken[id] = maxi(0, int(bp_taken.get(id, 0)))
# 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径;
# 迭代 12 T1——relic 令牌 feat_id 随还原)
var bp_deck = bp.get("bonus_deck", null)
if bp_deck is Array:
var restored: Array = []
for t in (bp_deck as Array):
var tok: Dictionary = 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", ""))
restored.append(rtok)
_curio_deck = restored
# 特性持有集还原(迭代 12 T1——缺键 = 旧版断点防御空集)
_feats = []
for r in (bp.get("relics", []) as Array):
_feats.append(String(r))
for v in (bp.get("bonus_aux_carry", []) as Array):
_bonus_treasure_carry.append(String(v))
_keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true)
_base_override = _normalize_override(params.get("hero_override", {}))
# 生命账本越界钳制(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))))
_first_script = false # 续玩模板已随快照强制(已抽模板零重抽)——脚本模式不重放
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 迭代 11 第②批 T3——续玩参数面同 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(迭代 11 第①批 T5)
_attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池)
_seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义)
# 三选一停点续玩分支(迭代 10——选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付)
var bp_pending: Dictionary = bp.get("bonus_pending", {}) as Dictionary
_feat_ticket_pity = int(bp.get("feat_ticket_pity", 0)) # T5 扩列——旧档缺键缺省 0(存档兼容)
_feat_ticket_sold = int(bp.get("feat_ticket_sold", 0))
var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary
if not bp_pending.is_empty():
_pending_next_level = true
# 候选令牌化迁移(TP0——旧版断点候选为 kind 字符串,按普通档令牌防御归一;单版本周期口径)
var migrated: Array = []
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}
if bp_pending.has("aux_detail"):
_curio_pending["aux_detail"] = (bp_pending.get("aux_detail", []) as Array).duplicate(true) # 掷定前置随停点快照续接
var payload: Dictionary = (bp_pending.get("payload", {}) as Dictionary).duplicate(true)
if bp_pending.has("incoming"):
_curio_pending["chosen"] = "treasure_item"
var incoming: Array = (bp_pending.get("incoming", []) as Array).duplicate(true)
_curio_pending["incoming"] = incoming
state = "running"
curio_choice_presented.emit(payload)
# 替换在途续发(呈现层直落替换子态——持有件记录按恢复后 carry 重建)
treasure_replace_presented.emit(_carry_records(),
{"index": -1, "item_id": String(incoming[0]) if not incoming.is_empty() else "", "count": incoming.size()})
return true
state = "running"
curio_choice_presented.emit(payload)
return true
if not bp_result.is_empty():
_pending_next_level = true
_curio_result = bp_result.duplicate(true)
state = "running"
curio_resolved.emit(_curio_result.duplicate(true))
return true
_resume_bp = bp.duplicate(true)
# 迭代 11 第①批 T6——充能账本随断点回局(缺键 = 旧档/无断点,保持 _seed_attune_charges 种子态)
var bp_charges = bp.get("attunement_charges", null)
if bp_charges is Dictionary:
attune_charges = (bp_charges as Dictionary).duplicate(true)
state = "running"
return _start_current_level()
# 三选一停点态归零(冒险开始/结算收束共用;令牌池按表配额重建——池消耗即上限,账本随池重生)
func _reset_curio_state() -> void:
_curio_taken = {"attack": 0, "max_hp": 0, "armor": 0, "gp": 0, "ap": 0, "heal": 0}
_curio_deck = _build_curio_deck()
_feats = []
_curio_pending = {}
_curio_result = {}
_bonus_treasure_carry = []
_keystone_drops = []
_node_curio_pending = {}
# 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = CURIO_KINDS×TIERS 逐层,
# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻。迭代 12 T1:规则改写型特性带=relic 活行每件 1 张追加
# (rarity common/rare→稀有令牌档、rarest→罕见令牌档——relic 永不入普通档;空表=零追加零扰动))
func _build_curio_deck() -> Array:
var deck: Array = []
for kind in db.adventure.CURIO_KINDS:
for tier in db.adventure.TIERS:
for i in int(db.adventure.curio_quota(String(kind), String(tier))):
deck.append({"kind": String(kind), "tier": String(tier)})
for row in db.feat_rows():
var rtier := "rarest" if String(row.get("rarity", "")) == "rarest" else "rare"
deck.append({"kind": "relic", "tier": rtier, "feat_id": String(row.get("feat_id", ""))})
return deck
# 令牌唯一身份(抽签去重/兑付匹配共用——属性加成型特性=kind,规则改写型特性=feat_id;同档多张不同 relic 可同屏)
func _token_identity(tok: Dictionary) -> String:
if String(tok.get("kind", "")) == "relic":
return "relic:" + String(tok.get("feat_id", ""))
return String(tok.get("kind", ""))
# 调试浮窗百分比通道(测试工具覆盖——每关新 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)
# 冒险账本侧生效上限(T17 修复——账本置满/回血封顶与战斗内上限同式同源):
# (表值 + 永久强化 flat + 加算区 flat)后乘调试浮窗百分比——叠加次序与 LevelRunner.effective_max_hp
# 逐字一致。旧口径两处只算 flat 不乘 pct:调生命上限 26→104 时开局血仍 26(账本 26 / 战斗内 104)、
# 通关 20% 回血封顶按 26 算。pct=0(模拟/冒烟默认)时乘法恒等——MC 锚零漂移。
func effective_max_hp() -> int:
var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + int(bonus.get("max_hp", 0))
return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0)))
# 断点承载注入(迭代 8 卡①——组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默)
func set_save_manager(sm) -> void:
_save_manager = sm
# 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取)
func runner() -> LevelRunner:
return _runner
# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶)。算术宿主 = LevelRunner.rest_heal
#(2026-10-02 修复批——同式双落点字面量退役,与 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()
# ---- 提升三选一(迭代 10 奖励重构批——选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)----
# 候选令牌匹配(身份键恒等——属性加成型特性 kind+tier、规则改写型特性 feat_id;迭代 12 T1 规则改写型特性带)
func _find_token(candidates: Array, kind: String, tier: String, feat_id: String = "") -> Dictionary:
for t in candidates:
var tok: Dictionary = t as Dictionary
if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier:
continue
if kind == "relic" and String(tok.get("feat_id", "")) != feat_id:
continue
return tok
return {}
# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(身份键精确匹配——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 = _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 kind == "treasure_item":
return _pick_treasure_item(value) # 道具件分派通关渠道(持有容器 = 跨关携带——与节点渠道分轨;池种退役后不可达)
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 伴随键(迭代 12 T1——反馈行取数面;非 relic 件零新增键)
func _feat_payload_keys(feat_id: String) -> Dictionary:
if feat_id == "":
return {}
var row: Dictionary = db.feat_row(feat_id)
return {"feat_id": feat_id, "feat_name": String(row.get("display_name", "")), "feat_archetype": String(row.get("archetype", ""))}
# 三选一件兑付共享体(2026-10-02 修复批双胞胎收口;TP0 改令牌消费——摘牌单点;迭代 12 T1 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 == "refresh":
_attune_refresh_x += value # T6——刷新次数件兑付=X 余量增量(作用三选一界面非角色,不进加算区——registry refresh_boundary)
return true
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 == "relic":
if not _feats.has(feat_id):
_feats.append(feat_id)
_curio_taken["relic"] = int(_curio_taken.get("relic", 0)) + 1
return true
match kind:
"attack":
bonus["atk"] = int(bonus.get("atk", 0)) + value
_curio_taken["attack"] = int(_curio_taken.get("attack", 0)) + 1
"max_hp":
bonus["max_hp"] = int(bonus.get("max_hp", 0)) + value
_curio_taken["max_hp"] = int(_curio_taken.get("max_hp", 0)) + 1
"armor":
bonus["armor_class"] = int(bonus.get("armor_class", 0)) + value
_curio_taken["armor"] = int(_curio_taken.get("armor", 0)) + 1
"ap":
bonus["ap"] = int(bonus.get("ap", 0)) + value
_curio_taken["ap"] = int(_curio_taken.get("ap", 0)) + 1
"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——选中踢池单点;迭代 12 T1 relic 按 feat_id 精确摘;找不到 = false)
func _consume_deck_token(kind: String, tier: String, feat_id: String = "") -> bool:
for i in _curio_deck.size():
var tok: Dictionary = _curio_deck[i] as Dictionary
if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier:
continue
if kind == "relic" and String(tok.get("feat_id", "")) != feat_id:
continue
_curio_deck.remove_at(i)
return true
return false
# 特性持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写)
func feats_held() -> Array:
return _feats.duplicate()
# ---- 商店共享池消费面(迭代 11 第②批 T3——P1 共享池单点账本;TP0 令牌池换代——库存=牌库剩余令牌)----
# 库存余量(TP0:stock = 牌库内该 kind 剩余令牌数——「池消耗即上限」,种类次数上限退役;
# 奖励三选一兑付摘牌 与 商店购买消费 由同一 _curio_deck 承载——两渠道跨关共库,全冒险一次性)。
func pool_stock(kind: String) -> int:
var n := 0
for t in _curio_deck:
if String((t as Dictionary).get("kind", "")) == kind:
n += 1
return n
# 商店购买消费 handler(LevelRunner.buy_pool_item 经 Callable 调用——ADR-0001 S6 方法引用):
# 消费在架令牌本体(kind+tier 精确——货架卡即令牌,TP0 令牌级货架)→ _apply_curio_pick 同口
# 写加算区+账本+摘牌(与奖励三选一真单点——买一件=奖励面少一件的库存语义由同库成立)。
# 未知 kind 拒绝(货架词表封闭的前置防御)。
func _consume_pool_purchase(kind: String, tier: String) -> bool:
if kind == "feat_ticket":
return _redeem_feat_ticket() # T5——门票买下即开(零摘牌不进持有集;临时停点开专长三选一面)
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)))
# 终局池快照(_settle 清账前调用——_reset_curio_state 会重置 _curio_taken,摘要 additive 键
# pool_stock 须在此前取数;货架四种 stock=cap−taken,读数面 = 商店买走 vs 奖励领走 vs 枯竭)
func _pool_stock_snapshot() -> Dictionary:
var out := {}
for row in db.shop_pool_rows():
var kind := String(row.get("kind_id", ""))
out[kind] = pool_stock(kind)
return out
# 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_treasure_replace)。
# 掷定前置(追加批二):具体道具随候选抽定(_last_draw_treasure / _curio_pending.aux_detail)——本函数消费预掷值,
# 断点续玩/无预掷值时回退当场掷定(流序不同但消费数不变,确定性由调用序保证)。
# 持有容器 = 跨关携带 _bonus_treasure_carry(与节点渠道的关卡域库存分轨——容器差异保守不合,注记在案)。
func _pick_treasure_item(count: int) -> bool:
var picked: Array = _draw_pending_treasure(_curio_pending, count, true)
if picked.is_empty():
push_error("[冒险结构] pick_curio(treasure_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
return false
var hp_before := int(ledger.get("hero_hp", 0))
# 迭代 11 第①批 T7——同调位守卫随过渡键退役拆除(aux 卡出池后本路径不可达;能力件容量闸
# 正主 = 营地装配面 Attunement.slots 满位禁选)
for iid in picked:
_bonus_treasure_carry.append(String(iid))
_finish_curio_pick("treasure_item", picked.size(), hp_before)
return true
# 替换子交互受理(discard_index = -1 放弃新件不获取;否则丢该件腾位收新)——终态恒 ≤ 同调位
# 持有容器 = 跨关携带(序号寻址);与节点渠道(库存句柄寻址)的丢弃/授予/收尾三步保守不合。
func resolve_treasure_replace(discard_index: int) -> bool:
if not _replace_in_transit(_curio_pending):
push_error("[冒险结构] resolve_treasure_replace 拒绝——无替换在途")
return false
var incoming: Array = _curio_pending.get("incoming", []) as Array
var hp_before := int(ledger.get("hero_hp", 0))
if discard_index >= 0:
if discard_index >= _bonus_treasure_carry.size():
push_error("[冒险结构] resolve_treasure_replace 拒绝——序号不在持有集: %d" % discard_index)
return false
_bonus_treasure_carry.remove_at(discard_index)
for iid in incoming:
_bonus_treasure_carry.append(String(iid))
# 放弃新件(discard_index < 0)= 原持有集不动、新件不获取
_finish_curio_pick("treasure_item", incoming.size(), hp_before)
return true
# 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。
# picked = kind;tier 随载荷(TP0——大小档位读数面);relic 伴随键随载荷(迭代 12 T1——反馈行取数)
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()
# ---- 奖励节点三选一(迭代 10 追加批——用户拍板:与通关三选一共享同池同权重同次数上限)----
# 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载,
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。
# 掷定前置(追加批二):返回 dict 带 aux_detail(道具件具体道具)供卡片直显。
func node_curio_candidates() -> Dictionary:
var cands: Array = _draw_curio_candidates("reward_node")
return {"candidates": cands, "aux_detail": _last_draw_treasure.duplicate()}
func present_node_curio(candidates: Array, treasure_detail: Array = []) -> void:
_reset_refresh_x() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转)
_node_curio_pending = {"candidates": candidates.duplicate(true), "aux_detail": treasure_detail.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 = _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 kind == "treasure_item":
return _apply_node_treasure(value) # 道具件分派节点渠道(持有容器 = 关卡域库存——与通关渠道分轨;池种退役后不可达)
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
# 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用);
# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨,容器语义保守不合)。
# 掷定前置:消费 _node_curio_pending.aux_detail 预掷值(缺省回退当场掷定)。
func _apply_node_treasure(count: int) -> bool:
var picked: Array = _draw_pending_treasure(_node_curio_pending, count, false)
if picked.is_empty():
push_error("[冒险结构] apply_node_curio(treasure_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
return false
var records: Array = []
if _runner != null and _runner.treasure_inventory != null:
records = _runner.treasure_inventory.holding_records()
# 迭代 11 第①批 T7——守卫随过渡键退役拆除(CURIO_KINDS 已除名 treasure_item,本分支不可达;
# 替换子交互路由随容量闸一并退役——能力件容量闸正主 = 营地装配面满位禁选)
if _runner != null:
for iid in picked:
var rec: Dictionary = _runner.grant_treasure_item(String(iid), "reward_node")
if rec.is_empty():
push_warning("[冒险结构] 奖励节点道具授予被拒(防御兜底留痕): %s" % String(iid))
_finish_node_bonus("treasure_item", picked.size())
return true
func _resolve_node_replace(discard_handle: int) -> bool:
if not _replace_in_transit(_node_curio_pending):
push_error("[冒险结构] resolve_node_replace 拒绝——无替换在途")
return false
var incoming: Array = _node_curio_pending.get("incoming", []) as Array
if discard_handle >= 0:
if _runner == null or _runner.treasure_inventory == null or not _runner.treasure_inventory.discard_item(discard_handle):
push_error("[冒险结构] resolve_node_replace 拒绝——句柄不在持有集: %d" % discard_handle)
return false
for iid in incoming:
var rec: Dictionary = _runner.grant_treasure_item(String(iid), "reward_node")
if rec.is_empty():
push_warning("[冒险结构] 替换子道具授予被拒(防御兜底留痕): %s" % String(iid))
# 放弃新件(discard_handle < 0)= 原持有集不动、新件不获取
_finish_node_bonus("treasure_item", incoming.size())
return true
# 冒险开始充能账本重置(T6——装备 active_charged 件各回 charges_per_adventure;被动件不入账)
func _seed_attune_charges() -> void:
attune_charges = {}
for iid in _attunement:
var arow: Dictionary = db.treasure_item_row(String(iid))
if String(arow.get("form", "")) == "active_charged":
attune_charges[String(iid)] = maxi(0, int(arow.get("charges_per_adventure", 0)))
# ---- 三选一刷新(迭代 11 第①批 T5b——P1 甲 X/Y 双源:X=刷新件每碰三选一重给〔优先消耗、不结转〕、
# Y=营地升级行冒险周期池〔用完即无〕;重抽消费 reward_pick 既有洗牌流,池规则全保留——不放回/
# 达上限剔牌/掷定前置直显;停点可中断)----
func _attune_refresh_x_total() -> int:
return int(db.treasure_item_row("pick_refresh").get("magnitude", 0)) if _attunement.has("pick_refresh") else 0
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
# 节点停点在途态读回(组合根重 present 用——candidates/aux_detail 值拷贝)
func node_curio_pending_state() -> Dictionary:
return {
"candidates": (_node_curio_pending.get("candidates", []) as Array).duplicate(true),
"aux_detail": (_node_curio_pending.get("aux_detail", []) as Array).duplicate(true),
}
# 三选一刷新(组合根唯一调用口;无停点/无余量 false 零副作用)。通关停点重抽后重发
# curio_choice_presented(呈现层重建卡片+刷新余量);节点停点由组合根读 node_curio_pending_state 重 present。
func refresh_curio() -> bool:
if _attune_refresh_x <= 0 and _attune_refresh_y <= 0:
return false
if not _curio_pending.is_empty():
if _attune_refresh_x > 0:
_attune_refresh_x -= 1
else:
_attune_refresh_y -= 1
var cands: Array = _draw_curio_candidates("level_clear")
_curio_pending["candidates"] = cands.duplicate(true)
_curio_pending["aux_detail"] = _last_draw_treasure.duplicate()
var payload: Dictionary = (_curio_pending.get("payload", {}) as Dictionary).duplicate(true)
payload["candidates"] = cands
payload["aux_detail"] = _last_draw_treasure.duplicate()
curio_choice_presented.emit(payload) # 同信号重 present(零自动推进契约不破——停点仍在途)
_snapshot_breakpoint()
return true
if not _node_curio_pending.is_empty():
if _attune_refresh_x > 0:
_attune_refresh_x -= 1
else:
_attune_refresh_y -= 1
var cands2: Array = _draw_curio_candidates("reward_node")
_node_curio_pending["candidates"] = cands2.duplicate(true)
_node_curio_pending["aux_detail"] = _last_draw_treasure.duplicate()
return true
return false
# 替换在途守卫(2026-10-02 修复批——双渠道替换受理的共用前置谓词:停点态非空且 chosen=treasure_item)
func _replace_in_transit(pending: Dictionary) -> bool:
return not pending.is_empty() and String(pending.get("chosen", "")) == "treasure_item"
# 双渠道道具件掷定共享体(2026-10-02 修复批——_pick_treasure_item/_apply_node_treasure 复制债收口一步):
# 解锁道具池空集守卫 + N 件掷定(先消费停点预掷 aux_detail,不足部分当场经 reward_pick 封闭流补掷——
# 取数序与拆分前逐位一致)。返回空数组 = 池空集(调用方按渠道名报错)。
# 保守不合处:通关渠道允许回退 _last_draw_treasure(allow_detail_fallback)、节点渠道不回退——掷定回退
# 语义渠道各异,参数化保留差异;持有容器与兑付收尾仍归调用方渠道。
func _draw_pending_treasure(pending: Dictionary, count: int, allow_detail_fallback: bool) -> Array:
var pool: Array = _bonus_treasure_pool()
if pool.is_empty():
return []
var picked: Array = []
var detail: Array = (pending.get("aux_detail", []) as Array).duplicate()
if detail.is_empty() and allow_detail_fallback:
detail = _last_draw_treasure.duplicate()
for i in maxi(1, count):
if i < detail.size():
picked.append(String(detail[i]))
else:
picked.append(_roll_bonus_treasure_item(pool))
return picked
# 替换受理分发(单入口——节点停点在途优先按库存句柄,否则按通关停点的携带序号)
func resolve_curio_replace(discard: int) -> bool:
if not _node_curio_pending.is_empty() and _node_curio_pending.has("incoming"):
return _resolve_node_replace(discard)
return resolve_treasure_replace(discard)
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)
# 令牌池洗牌不放回抽 3(TP0 v2.2——Fisher-Yates 全库洗牌后按序收集;**同 kind 去重每 kind 至多
# 一枚**〔先到先得,档位取该 kind 首枚——「三选一=三种不同选项」体验延续,与货架抽签同规则〕;
# kind 出现率∝剩余令牌数〔档位配额即分布——无独立权重 knob〕;抽签不摘牌、兑付才摘牌=未选
# 自动回池;池耗即枯竭——余库不足 N kind 时出齐剩余,空库 = 零候选合法态)。候选 = 令牌字典
# {"kind","tier"}。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同候选;道具件掷定前置
# 随池种退役拆除(_last_draw_treasure 恒空集,机械惰性承载)。
# 渠道白名单过滤(迭代 15 卡② T4——渠道轴定位:关底 Boss 击败=通关渠道只出专长(feat 令牌);
# 奖励节点/商店只出属性值提升+资源(非 relic 令牌),专长在非 Boss 渠道只以门票形态出现(T5)。
# 过滤式而非物理拆池:deck 物理结构/存档键 bonus_deck 混编余库/消耗语义(选中踢池未选回池)原样,
# 白名单在候选视图层生效——配额结构锚 TIER_QUOTAS 70/25/3=98 零改动(专长令牌本系活行追加面,在锚外)。
func _draw_curio_candidates(channel: String = "reward_node") -> Array:
_last_draw_treasure = []
var pool := _curio_deck.duplicate()
if channel == "level_clear":
pool = pool.filter(func(tok): return String((tok as Dictionary).get("kind", "")) == "relic")
else:
pool = pool.filter(func(tok): return String((tok as Dictionary).get("kind", "")) != "relic")
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
for i in pool.size():
var j := rg.randi_range(i, pool.size() - 1)
var tmp = pool[i]
pool[i] = pool[j]
pool[j] = tmp
var out: Array = []
var seen: Array = []
for tok in pool:
var ident := _token_identity(tok)
if seen.has(ident):
continue
seen.append(ident)
var cand := {"kind": String((tok as Dictionary).get("kind", "")), "tier": String((tok as Dictionary).get("tier", ""))}
if ident.begins_with("relic:"):
cand["feat_id"] = String((tok as Dictionary).get("feat_id", ""))
out.append(cand)
if out.size() >= int(db.adventure.curio_candidate_count):
break
# T5 门票注入(非 Boss 渠道唯一专长入口——替换末位候选占一席,候选数不变;判定含 pity 清零记账)
if channel == "reward_node" and not out.is_empty() and _feat_ticket_consider(rg):
out[out.size() - 1] = {"kind": "feat_ticket", "tier": "rare"}
return out
# —— 专长获取门票(T5 迭代 15 卡②——非 Boss 渠道的专长入口;四口径契约④:拿到即开不可囤积/
# 罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出第 6 必出——计数=展示次数、刷新
# 重展计入、节点与商店共享、触发清零、限购满不再触发。调用即一次展示判定与记账)——
func _feat_ticket_consider(rg: RandomNumberGenerator) -> bool:
if _feat_ticket_sold >= int(db.adventure.feat_ticket_limit):
return false # 限购满——不再触发(保底冻结)
if _feat_ticket_appeared >= int(db.adventure.feat_ticket_quota):
return false # 配额满——本局不再出现
var offer: bool = _feat_ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance)
if offer:
_feat_ticket_pity = 0
_feat_ticket_appeared += 1
else:
_feat_ticket_pity += 1
return offer
# 门票兑付(拿到即开——停点候选替换为专长三选一;不进持有集/零摘牌;限购计数 +1):
# 节点停点=本地替换(呈现层读在途态重绘);店内无停点=临时停点 + payload.context 标记(呈现层
# 重 present 无胜利总结面,确认后只关面回店)。
func _redeem_feat_ticket() -> bool:
_feat_ticket_sold += 1
_feat_ticket_pity = 0
var cands: Array = _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 = {"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
func _roll_from(rg: RandomNumberGenerator) -> String:
var pool: Array = _bonus_treasure_pool()
return String(pool[rg.randi_range(0, pool.size() - 1)])
# 三选一道具件随机池(与投放门控同源:aux 活行 ∩ 解锁门控集——空集 = 道具件不入牌库)
func _bonus_treasure_pool() -> Array:
var out: Array = []
var live: Dictionary = db.treasure_live_ids()
for iid in live.keys():
if _treasure_unlocked.has(String(iid)):
out.append(String(iid))
return out
func _roll_bonus_treasure_item(pool: Array) -> String:
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
return String(pool[rg.randi_range(0, pool.size() - 1)])
func _carry_records() -> Array:
var out: Array = []
for i in _bonus_treasure_carry.size():
out.append({"index": i, "item_id": _bonus_treasure_carry[i]})
return out
# Boss 钥石逐杀掉落(迭代 10——渠道改 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 = 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)
# core↔core 接线只许方法 Callable(ADR-0001 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]))
# 断点续玩分支(迭代 8 卡①):模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带
# 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
var resume_state: Dictionary = {}
var template_pick: int = 0 if _first_script else -1 # 首次脚本 = 每关固定模板 0 零抽签(v0.4 拍板点①);否则逐关抽签
if not _resume_bp.is_empty():
resume_state = {"node_id": int(_resume_bp.get("node_id", -1)),
"visited_ids": _resume_bp.get("visited_ids", []) as Array}
template_pick = int(_resume_bp.get("template_pick", -1))
# 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
# _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。
# 停点前移批 2026-09-23:defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
# 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。
# 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。
var ok: bool = _runner.start_level_external(level_index, ledger, bonus, purchased,
Callable(self, "apply_rest"), _base_override, true, template_pick, resume_state)
if ok:
_runner.set_attunement(_attunement) # 迭代 11 第①批 T5——同调装配下传(enc 携带进战斗结算)
_runner.set_attune_charges(attune_charges) # 迭代 11 第①批 T6——充能账本下传(能力件骑库存面注入)
_runner.set_feats(_feats) # 迭代 12 T1——特性持有集下传(begin_battle enc.feats 求值面)
_runner.set_tree_rules(_tree_rules) # 迭代 12 T10——树 rule 键集下传(A/B 面消费)
if _shop_mode == "pool":
# 商店共享池注入(迭代 11 第②批 T3——库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动)
_runner.set_shop_pool(_curio_deck, Callable(self, "_consume_pool_purchase"), Callable(self, "_feat_ticket_consider"))
# 三选一道具件跨关携带注入(迭代 10)——迭代 11 第①批 T7 退役:aux 卡出池后 carry 恒空集,
# 注入面随停点渠道退役拆除(容器字段保留=替换子交互机械惰性承载,零投放零行为)
level_started.emit(level_index, String(_runner.level_row.get("display_name", "")))
# 入口停点断点(node_id=-1)与 defer_entry 同态(node_index=-1+单候选)——走入口呈现;
# 中途停点(node_id>=0)走停点呈现(restore_treasure_items 先于呈现=C 区库存就位)。
if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0:
_runner.present_entry_choice()
else:
_runner.restore_treasure_items(_resume_bp.get("aux_items", []) as Array)
_runner.present_resumed_choice()
_resume_bp = {}
return ok
# 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次)
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(迭代 7 T4——末关 summary 取数面)
# Boss 钥石逐杀掉落(迭代 10——渠道改击杀掉落,末关照常入账;首杀/复杀读 meta.tres boss_keystone)
_boss_keystone_drop(level_index)
if level_index < levels_total:
# 通关兑付(非末关恰一次;迭代 10 三层奖励框架):部分回血先行 → 加权洗牌掷 3 → 三选一停点
#(顺序即契约——回血按当刻账本与上限结算,选择停点零自动推进)
var hp_before := int(ledger.get("hero_hp", 0))
var max_eff := effective_max_hp() # T17 修复:回血封顶口径含调试浮窗 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() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转)
var pick_payload: Dictionary = {
"level_index": level_index,
"candidates": _draw_curio_candidates("level_clear"), # T4 渠道白名单——关底 Boss 击败=通关渠道只出专长
"aux_detail": _last_draw_treasure.duplicate(), # 掷定前置(追加批二——道具件具体道具随候选抽定,卡片直显)
"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 扩签(迭代 7 T4——关卡末清空反馈取数)
}
_curio_pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true),
"aux_detail": _last_draw_treasure.duplicate(),
"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 扩签(迭代 7 T4——末关清空反馈;非末关走 bonus 载荷)
"keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(迭代 10——逐杀掉落明细;settle 入账明细/复盘归因数据源)
"pool_stock": _pool_stock_snapshot(), # additive 扩签(迭代 11 第②批 T5——终局池读数:清账前快照,商店买走 vs 奖励领走 vs 枯竭的终局面)
"relics": _feats.duplicate(), # additive 扩签(迭代 12 T1——特性终局持有集:清账前快照,成型判读取数面)
}
# 败退明细(数值验证工具取数面 2026-09-22——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:
_runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现)
_reset_curio_state() # 三选一停点态收束清零(结算受理只算一次语义——重复宣布零变化)
if _save_manager != null:
_save_manager.clear_breakpoint() # 三结局清断点(迭代 8 卡①/T3——退出冒险=放弃流程+清除断点)
_save_manager.write_to_disk()
state = "settling"
adventure_finished.emit(summary)
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() # 停点快照(迭代 8 卡①——选路停点=唯一可恢复停点形态;战斗中不存)
# ---- 断点快照(迭代 8 卡①——七字段拍板面+恢复必需附随件)----
# 七字段(拍板面):关卡序号/节点位/生命/属性加成区/GP 钱包/商店已购档/已抽模板;
# 附随件(续玩正确性必需,随批注记):主种子+取数计数器(rng 快照契约回填——续接原时间线)、
# 钥石累计(大奖励中途值——终局摘要五件事不失真)、已到节点序(呈现层图条重建)、道具库存
# (奖励节点已选件——防静默丢失)。注入缺省 null = 整面静默(模拟/冒烟零扰动)。
func _snapshot_breakpoint() -> void:
if _save_manager == null or state != "running":
return
var treasure_snapshot: Array = []
if _runner != null and _runner.treasure_inventory != null:
treasure_snapshot = _runner.treasure_inventory.snapshot_inventory()
# 三选一停点续玩分支 _runner 为 null(关卡已收束、下一关未开)——节点域字段按缺省落档,
# 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照,迭代 10)
var bp: Dictionary = {
"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(bonus.get("atk", 0)),
"max_hp": int(bonus.get("max_hp", 0)),
"armor_class": int(bonus.get("armor_class", 0)),
"ap": int(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 [], # 迭代 11 第②批 T2——P2 甲货架入断点(离开店/非店停点 = 空架合法态)
"shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 迭代 11 第②批 T2——本次进店已购次数(A 链定价输入,P2 甲扩列)
"attunement_charges": attune_charges.duplicate(true), # 迭代 11 第①批 T6——同调主动件充能余量随断点(save v4 域)
"template_pick": _runner.active_template_index if _runner != null else -1,
"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(), # 特性持有集(迭代 12 T1——局内随机渠道获得态续玩防丢失)
"bonus_pending": _curio_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约)
"feat_ticket_pity": _feat_ticket_pity, # T5 门票保底计数(扩列向后兼容——旧档缺键缺省 0)
"feat_ticket_sold": _feat_ticket_sold, # T5 门票限购计数(同上)
"bonus_result": _curio_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付)
"bonus_aux_carry": _bonus_treasure_carry.duplicate(true), # 道具件跨关携带(注入前中断防静默丢失)
"keystone_drops": _keystone_drops.duplicate(true), # 逐杀掉落明细(摘要归因续接——迭代 10)
}
_save_manager.set_breakpoint(bp)
_save_manager.write_to_disk()
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)