Files
gplan-2/game/core/adventure_runner.gd
T
DKChan 072c19b4a5 feat(凭证件裁削): 卡② 同调电池删除+完美保险迁专长+配额净删落地(迭代 31 卡②)
用户 2026-10-18 逐裁四项落码:
- T1/T2 同调电池整件删除:CURIO_KINDS 摘 kind、三字段与三处分发表分支删净、
  runner 侧 _battery_pending/断点键/getter/choose_battery_target 全清(呈现层零接线=有账本无入口)。
- T3 完美保险出池:kind/字段/分发摘除,_pending_insurance 与 level_runner 注入通道
  (voucher_insurance 键、set_voucher_grants 第三参)整链拆除,battle_orchestrator._fx_insurance 删。
- T4 引擎扩签 forgive_seal_miss(EFFECT_KINDS 31→32)+两账相减:新增 _perfect_forgiven 单向累加、
  谓词抽成 perfect_sweep_earned()(有效误标=误标总数−豁免数),_perfect_hit_false 保持纯净=契约 2;
  seal_missed 发射时机与消费点零改动=契约 3(危险直觉·守同事件订阅面在验收组里对拍)。
- T5 feats.tres 第 30 行完美保险专长:rarity rare/cap 省略/domain=battle 显式写出(战斗域首例)/
  desc「本场首次封印落空,不计入完美封印判定」;限次由行 when counter(insurance_used) lt 1 承载,
  do 自带 counter_id 记账由授予段消费点落账(代拍板 3 选型:单行落表,理由见实现报告)。
- T6 配额净删:TIER_QUOTAS rare 9→7、令牌池总量 104→102,validate() 硬断言过;smoke 配额锚/牌库
  构成/专长行数随批换代。
- T7 验收:feat_vouchers_test 39 项全绿(出池反例/断点缺键兼容/账本保留/配额锚/装载/限次一次/
  计数纯净/两账相减真值表/跨战清零/事件不吞)+forge_table 词表闭集断言 31→32。
  变异验证 6 条:5 条证红;「domain battle→adventure」在本 rig 不可观测(未红),已如实登记。
- 存量缺陷顺带修:旧 _emit_row("insurance_used","",…) 空来源会触发行来源断言(旧路径无消费点故从未走红),
  新订阅面给出 source_class=seal_miss。

基线(卡① 合回后 master 4f953ce8):冒烟 1151/0、单元 34 套全绿、探针 --click 全绿(user:// 存档移开态)。
2026-10-19 04:45:49 +08:00

1060 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
## 冒险结构编排器:三关序列、生命/GP 账本、三选一停点与三结局结算唯一所有者,headless 可跑完整冒险(垂直切片验证:一条命走完三关)。
## 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) # 专长三选一停点(非末关通关恰一次;迭代 20 T5 换代正名——旧「提升三选一」退役,通道白名单后候选恒专长+回血在载荷)——{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 迭代 10)
# ---- 转发族(签名与 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 } # 会话状态机(迭代 27 T6——裸字符串 enum 化:内部 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() # 共享四本账(迭代 27 T3——P1-4 收口: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 # 本局主种子(摘要第五件事)
# 两根类型轴冒险开始快照(迭代 23 T4——契约 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}
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[Variant] = [] # 同调装配集(迭代 11 第①批 T5——params.attunement 接收面,start_level 外下传 LevelRunner)
var _attune_refresh_x := 0 # 刷新件余量(迭代 11 第①批 T5b P1 甲——每碰三选一重置=X 表值;优先消耗、不结转)
var _attune_refresh_y := 0 # Y 冒险周期池(开局参数面注入,用完即无——营地升级行表值)
var _curio_paid_refreshes := 0 # 本次停点已付费刷新次数(2026-10-06 用户令对齐商店 MC-1——GP 刷新价曲线输入,随停点创建清零=与商店进店清零同构)
# ---- 凭证件三本新账(迭代 20 T4——奇物.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 # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省)
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
# ---- 三选一停点态(迭代 10 奖励重构批)----
var _curio_taken := {} # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载;池库存正主=_curio_deck)
var _curio_deck: Array[Variant] = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账;迭代 12 T1 起规则改写型特性带=relic 令牌随表追加)
var _feats: Array[String] = [] # 特性持有集(迭代 12 T1——局内随机渠道获得、冒险级跨关携带、收束清零;🔴 层级纪律承载面:不入同调位不入局外树)
var _tree_rules: Array[Variant] = [] # 树 rule 键集(迭代 12 T10——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 := {} # 奖励节点三选一停点态(迭代 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}
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() # 表访问收口(2026-10-02——行集直扫退役)
level_index = 1
_pending_next_level = false
_base_override = _normalize_override(params.get("hero_override", {}))
hero_attack_type = String(params.get("attack_type", "")) # 迭代 23 T4——两根类型轴快照(契约 8 锁死:开局后不随存档选中变化)
hero_armor_type = String(params.get("armor_type", ""))
_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——冒险开始充能账本按装备主动件重置(每冒险充能语义)
run_ledger.vision_counter = {"used": 0} # 迭代 22 秘宝批 T3——真视使用计数冒险开始归零(跨局累计归生涯档)
# 刷新练习开局注入(迭代 22 秘宝批 T3——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))
# 特性开局注入(迭代 12 T7——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 [] # 迭代 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 = 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[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() # 表访问收口(2026-10-02——行集直扫退役)
level_index = int(bp.get("level_index", 1))
_pending_next_level = false
_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))) # 迭代 20 T2——身份维键(relic:<feat_id>)随快照还原
# 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径;
# 迭代 12 T1——relic 令牌 feat_id 随还原)
var bp_deck = bp.get("bonus_deck", null)
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", ""))
restored.append(rtok)
_curio_deck = restored
# 特性持有集还原(迭代 12 T1——缺键 = 旧版断点防御空集)
_feats = []
for r in (bp.get("relics", []) as Array):
_feats.append(String(r))
_keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true)
_base_override = _normalize_override(params.get("hero_override", {}))
hero_attack_type = String(params.get("attack_type", "")) # 迭代 23 T4——续玩参数面同 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))))
_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(bp.get("attune_refresh_y", int(params.get("attune_refresh_y", 0))))) # 2026-10-13 缺陷修批——Y 余量优先读断点(花掉不回血=堵「用完退菜单再续玩满血复活」可利用漏洞);缺键=旧版断点,回退参数面整额注入(旧档行为零变化)
_seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义)
_tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # 迭代 12 T10——A/B 面 rule 键集(尾裁微批b 2026-10-08 卡⓪受理闭环:续玩参数面同 start_adventure :135 同式,消费面 set_tree_rules 下传现成零改)
# 三选一停点续玩分支(迭代 10——选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付)
var bp_pending: Dictionary[Variant, Variant] = 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))
# 迭代 20 T6/T7——adventure 域计数器回填(缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先)
var bp_counters = bp.get("adventure_counters", null)
if bp_counters is Dictionary:
run_ledger.adventure_counters = (bp_counters as Dictionary).duplicate(true)
_refresh_stock = maxi(0, int(bp.get("refresh_stock", 0))) # 迭代 20 T4/T7——旧档缺键缺省 0
run_ledger.vision_counter = {"used": maxi(0, int(bp.get("vision_counter_used", 0)))} # 迭代 22 秘宝批 T3——真视计数随断点回填(缺键=旧断点缺省 0)
var bp_result: Dictionary[Variant, Variant] = bp.get("bonus_result", {}) as Dictionary
if not bp_pending.is_empty():
_pending_next_level = true
# 候选令牌化迁移(TP0——旧版断点候选为 kind 字符串,按普通档令牌防御归一;单版本周期口径)
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)
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)
# 迭代 11 第①批 T6——充能账本随断点回局(缺键 = 旧档/无断点,保持 _seed_attune_charges 种子态)
var bp_charges = bp.get("attunement_charges", null)
if bp_charges is Dictionary:
run_ledger.attune_charges = (bp_charges as Dictionary).duplicate(true)
_state = State.RUNNING
return _start_current_level()
# 三选一停点态归零(冒险开始/结算收束共用;令牌池按表配额重建——池消耗即上限,账本随池重生。
# 迭代 20 T6——adventure 域计数器账本随冒险开始清零;resume 路径调用后由断点附随件回填)
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 = {}
_keystone_drops = []
_node_curio_pending = {}
run_ledger.adventure_counters = {}
_refresh_stock = 0
_pending_rap = 0
_time_sand_queue = 0
# 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = CURIO_KINDS×TIERS 逐层,
# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻。迭代 12 T1:规则改写型特性带=relic 活行每件 1 张追加。
# 迭代 20 T2/T3 四级换代——relic 令牌档 = 行 rarity 直映射(四级词表内直用;旧三档值 transitional
# 回退映射 rarest→rare/其余→excellent,T3 行换代后回退分支自然休眠);relic 入普通档随新档位框架
# 解禁〔旧「永不入普通档」系 TP0 三档时代约束,四级框架未沿用——奇物.md 池结构节无此限制〕)
func _build_curio_deck() -> Array:
var deck: Array[Variant] = []
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 rraw := String(row.get("rarity", ""))
var rtier := rraw if db.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
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(run_ledger.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: SaveManagerCore) -> 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()
# 停点空候选消化(2026-10-06 用户令——池空=三选一环节整体跳过:候选 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
# ---- 提升三选一(迭代 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[Variant, Variant] = 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[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 伴随键(迭代 12 T1——反馈行取数面;非 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", ""))}
# 三选一件兑付共享体(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 == "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 # 迭代 20 T2 案②——身份维已拿计数(cap 消费面)
return true
# 加算形态路由(内容数据驱动扩展批 T5——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——选中踢池单点;迭代 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[Variant, Variant] = _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 调用——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
# 兑付收口(恰一次):清停点态 → 记已兑付态 → 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;本停点断点不载,
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。
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() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转)
_curio_paid_refreshes = 0 # 付费刷计数随停点清零(对齐商店进店清零——2026-10-06)
_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)))
# ---- 三选一刷新(迭代 11 第①批 T5b——P1 甲 X/Y 双源:X=刷新件每碰三选一重给〔优先消耗、不结转〕、
# Y=营地升级行冒险周期池〔用完即无〕;重抽消费 reward_pick 既有洗牌流,池规则全保留——不放回/
# 达上限剔牌/掷定前置直显;停点可中断)----
func _attune_refresh_x_total() -> int:
# 内容数据驱动扩展批 T4——按 id 特判拆除: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 池 + 刷新存量账——呈现层余量读数口;T4 F-1 修复扩第三源)
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 付费刷新(2026-10-06 用户令对齐商店 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(营地冒险周期池)→ 存量账(刷新券加仓,
# 迭代 20 T4 F-1 修复新账——跨停点持久)
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 # 重发载荷带标——呈现层直落选择面,不再重开胜利总结面(2026-10-06)
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)
# 令牌池洗牌不放回抽 3(TP0 v2.2——Fisher-Yates 全库洗牌后按序收集;**同 kind 去重每 kind 至多
# 一枚**〔先到先得,档位取该 kind 首枚——「三选一=三种不同选项」体验延续,与货架抽签同规则〕;
# kind 出现率∝剩余令牌数〔档位配额即分布——无独立权重 knob〕;抽签不摘牌、兑付才摘牌=未选
# 自动回池;池耗即枯竭——余库不足 N kind 时出齐剩余,空库 = 零候选合法态)。候选 = 令牌字典
# {"kind","tier"}。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同候选。
# 渠道白名单过滤(迭代 15 卡② T4——渠道轴定位:关底 Boss 击败=通关渠道只出专长(feat 令牌);
# 奖励节点/商店只出属性值提升+资源(非 relic 令牌),专长在非 Boss 渠道只以门票形态出现(T5)。
# 过滤式而非物理拆池:deck 物理结构/存档键 bonus_deck 混编余库/消耗语义(选中踢池未选回池)原样,
# 白名单在候选视图层生效——配额结构锚 TIER_QUOTAS 70/25/3=98 零改动(专长令牌本系活行追加面,在锚外)。
# 迭代 20 T2 案D 换代——曝光槽:非 Boss 渠道三选一 = **2 格全池 + 1 格优秀档以上子池**
# (rank≥excellent;子池空该格回退全池;池空直通同族兜底);凭证(feat_ticket)不进子池
# 〔独立计数体系〕;已拿上限 cap(案②提前退役):taken≥cap 的身份在候选视图剔除〔牌库本体不动,
# 拿了才计、刷出不计——_curio_cap_ok〕。
func _draw_curio_candidates(channel: String = "reward_node") -> Array:
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):
var k := String((tok as Dictionary).get("kind", ""))
return k != "relic" and k != "feat_ticket")
pool = pool.filter(func(tok):
var t: Dictionary[Variant, Variant] = tok as Dictionary
return _curio_cap_ok(String(t.get("kind", "")), String(t.get("feat_id", ""))))
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
if channel == "level_clear":
# 迭代 20 T5——Boss 渠道按档配比加权抽取(无放回加权抽:逐席按 boss_feat_weight 掷点,
# 身份去重照旧;rarity 语义约束兑付面——奇物.md v3.1,配比数值归调参批)
var out_w: Array[Variant] = []
var seen_w: Array[Variant] = []
var want_w := int(db.adventure.curio_candidate_count)
while out_w.size() < want_w and not pool.is_empty():
var total_w := 0
for tok_w in pool:
total_w += db.adventure.boss_feat_weight(String((tok_w as Dictionary).get("tier", "")))
if total_w <= 0:
break
var roll_w := rg.randi_range(1, total_w)
var pick_i := 0
for i_w in pool.size():
roll_w -= db.adventure.boss_feat_weight(String((pool[i_w] as Dictionary).get("tier", "")))
if roll_w <= 0:
pick_i = i_w
break
var tok_p: Dictionary[Variant, Variant] = pool[pick_i]
var ident_p := _token_identity(tok_p)
if seen_w.has(ident_p):
pool.remove_at(pick_i) # 同身份已出——剔牌重掷(无放回)
continue
seen_w.append(ident_p)
var cand_p := {"kind": String(tok_p.get("kind", "")), "tier": String(tok_p.get("tier", "")), "feat_id": String(tok_p.get("feat_id", ""))}
out_w.append(cand_p)
pool.remove_at(pick_i)
return out_w
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
# 子池(优秀档以上)独立洗牌——同 stream 续消费,确定性保持
var sub := pool.filter(func(tok):
return db.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT))
for i in sub.size():
var j2 := rg.randi_range(i, sub.size() - 1)
var tmp2 = sub[i]
sub[i] = sub[j2]
sub[j2] = tmp2
var out: Array[Variant] = []
var seen: Array[Variant] = []
# 收集器:全池序取 n_full 格(普通档打底)→ 子池序取 1 格(曝光槽)→ 子池空回退全池续取
var want := int(db.adventure.curio_candidate_count)
var n_full := maxi(1, want - 1) if want >= 2 else want
while out.size() < want:
var src: Array[Variant] = pool
if out.size() >= n_full and not sub.is_empty():
src = sub # 曝光槽位——优先子池
var placed := false
for tok in src:
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)
placed = true
break
if not placed:
if out.size() >= n_full and not sub.is_empty() and not pool.is_empty():
sub = [] # 子池枯竭——曝光槽回退全池续取
continue
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": "excellent"}
return out
# 已拿上限判定(迭代 20 T2 案②——身份维度:kind 走 curio_cap、relic 走 feats 行 cap 字段;
# taken 只在兑付时 +1〔拿了才计〕、刷出不计)。缺省放行=白名单外 kind 兜底。
func _curio_cap_ok(kind: String, feat_id: String = "") -> bool:
var cap := 99
if kind == "relic":
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
cap = int(row.get("cap", 1)) if not row.is_empty() else 1
return _curio_taken.get("relic:" + feat_id, 0) < cap
if not db.adventure.CURIO_KINDS.has(kind):
return true
cap = int(db.adventure.curio_cap(kind))
return int(_curio_taken.get(kind, 0)) < cap
# —— 专长获取门票(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[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 钥石逐杀掉落(迭代 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[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
# ---- 关卡推进(私有——序列编排单一出口)----
# 树限次计数按关重置(迭代 21 T3——危险直觉锋/守两组「每关限次」账本键:跨战持久由 adventure
# 域导出/回灌承载,此处随关清零闭合生命周期四态;键集=效果引擎树合成行声明的 counter_id;
# 独立成口=V10 断言面直呼)
func _reset_tree_limited_counters() -> void:
for cid in ["tree_sense_sharp_used", "tree_sense_guard_used"]:
run_ledger.adventure_counters.erase(cid)
func _start_current_level() -> bool:
_runner = LevelRunner.new(db, rng)
_reset_tree_limited_counters() # 树限次计数按关重置(V10 生命周期四态闭合——断言面直呼本口)
# 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]))
# 断点续玩分支(迭代 8 卡①):模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带
# 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
var resume_state: Dictionary[Variant, Variant] = {}
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——测量面逐位零漂移。
_runner.set_run_ledger(run_ledger) # 迭代 27 T3——共享四账单类下传(原四 setter/参数散传收口;run_ledger=四账类,ledger=关卡账沿用旧名)
_runner.sync_attune_inventory() # 迭代 11 第①批 T6 原语义——能力件骑库存面注入(随账下传同拍)
var ok: bool = _runner.start_level_external(level_index, ledger, purchased,
apply_rest, _base_override, true, template_pick, resume_state)
if ok:
_runner.set_attunement(_attunement) # 迭代 11 第①批 T5——同调装配下传(enc 携带进战斗结算)
_runner.set_feats(_feats) # 迭代 12 T1——特性持有集下传(begin_battle enc.feats 求值面)
_runner.set_tree_rules(_tree_rules) # 迭代 12 T10——树 rule 键集下传(A/B 面消费)
_runner.set_hero_axes(hero_attack_type, hero_armor_type) # 迭代 23 T4——两根类型轴快照下传(effective_ac 甲型基础 AC 层+enc 携带进 spawn)
if _shop_mode == "pool":
# 商店共享池注入(迭代 11 第②批 T3——库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动。
# 迭代 20 T2——cap 检查回调四参注入)
_runner.set_shop_pool(_curio_deck, _consume_pool_purchase, _feat_ticket_consider, _curio_cap_ok)
# 迭代 20 T4——凭证件三账注入(节点级/回合级一次性:注入即清零,未用即过期=寿命档语义)
_runner.set_voucher_grants(_pending_rap, _time_sand_queue)
_pending_rap = 0
_time_sand_queue = 0
# 三选一道具件跨关携带注入(迭代 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 余量(不结转)
_curio_paid_refreshes = 0 # 付费刷计数随停点清零(对齐商店进店清零——2026-10-06)
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 扩签(迭代 7 T4——关卡末清空反馈取数)
}
_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 扩签(迭代 7 T4——末关清空反馈;非末关走 bonus 载荷)
"keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(迭代 10——逐杀掉落明细;settle 入账明细/复盘归因数据源)
"vision_used_delta": int(run_ledger.vision_counter.get("used", 0)), # additive 扩签(迭代 22 秘宝批 T3——真视使用本局计数;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:
summary["gp_final"] = int(_runner.gp) # gp_final 清账前快照(迭代 27 T3——P1-3 GP 影子账收口:终局面板取数源,影子账随批退役)
_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 = 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() # 停点快照(迭代 8 卡①——选路停点=唯一可恢复停点形态;战斗中不存)
# ---- 断点快照(迭代 8 卡①——七字段拍板面+恢复必需附随件)----
# 七字段(拍板面):关卡序号/节点位/生命/属性加成区/GP 钱包/商店已购档/已抽模板;
# 附随件(续玩正确性必需,随批注记):主种子+取数计数器(rng 快照契约回填——续接原时间线)、
# 钥石累计(大奖励中途值——终局摘要五件事不失真)、已到节点序(呈现层图条重建)、道具库存
# (奖励节点已选件——防静默丢失)。注入缺省 null = 整面静默(模拟/冒烟零扰动)。
func _snapshot_breakpoint() -> void:
if _save_manager == null or state != "running":
return
var treasure_snapshot: Array[Variant] = []
if _runner != null and _runner.treasure_inventory != null:
treasure_snapshot = _runner.treasure_inventory.snapshot_inventory()
# 三选一停点续玩分支 _runner 为 null(关卡已收束、下一关未开)——节点域字段按缺省落档,
# 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照,迭代 10)
var bp: Dictionary[Variant, Variant] = {
"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 [], # 迭代 11 第②批 T2——P2 甲货架入断点(离开店/非店停点 = 空架合法态)
"shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 迭代 11 第②批 T2——本次进店已购次数(A 链定价输入,P2 甲扩列)
"attunement_charges": run_ledger.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 门票限购计数(同上)
"adventure_counters": run_ledger.adventure_counters.duplicate(true), # 迭代 20 T6/T7——adventure 域计数器随断点(续玩跨战账不丢)
"vision_counter_used": int(run_ledger.vision_counter.get("used", 0)), # 迭代 22 秘宝批 T3——真视使用计数随断点(续玩跨战账不丢)
"refresh_stock": _refresh_stock, # 迭代 20 T4/T7——刷新存量账随断点(F-1 新账)
"attune_refresh_y": _attune_refresh_y, # 2026-10-13 缺陷修批——Y 冒险周期池余量随断点(续玩不再自参数面整额重注入=堵「用完退菜单再续玩满血复活」可利用漏洞;扩列向后兼容——旧档缺键回退参数面)
"bonus_result": _curio_result.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)