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

741 lines
41 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 bonus_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 bonus_resolved(result: Dictionary) # 三选一兑付完成(pick_bonus/替换受理后恰一次)——{picked, value, hp_before, hp_after, level_index}
signal aux_replace_presented(items: Array, incoming: Dictionary) # 同调位满位替换子交互(pick_bonus 道具件满位时)——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] # TunePanel 百分比通道 [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_aux_cleared := 0 # additive(迭代 7 T4——最近一次关卡末清空计数,summary 取数面)
var _aux_unlocked := {} # aux 解锁门控集(迭代 7 T5——params.aux_unlocked 接收面)
var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省)
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
# ---- 三选一停点态(迭代 10 奖励重构批)----
var _bonus_taken := {} # 件获取次数计数(attack/max_hp/armor/ap——次数上限判定不可由加算区累加值反推,牌库剔除态依赖)
var _bonus_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点
var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付)
var _bonus_aux_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗)
var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源)
var _last_draw_aux: Array[String] = [] # 最近一次抽签掷定的道具件(掷定前置——卡片直显具体道具;追加批二)
var _node_bonus_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽)
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_bonus_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)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入)
_aux_unlocked = params.get("aux_unlocked", {}) if params.get("aux_unlocked", {}) is Dictionary else {} # additive(迭代 7 T5)
ledger["hero_hp"] = int(db.hero.max_hp) + int(_base_override["max_hp"]) # 生命账本置满(表值+永久强化 flat——加算区刚归零)
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_bonus_state()
var bp_taken: Dictionary = bp.get("bonus_taken", {}) as Dictionary
for id in _bonus_taken.keys():
_bonus_taken[id] = maxi(0, int(bp_taken.get(id, 0)))
for v in (bp.get("bonus_aux_carry", []) as Array):
_bonus_aux_carry.append(String(v))
_keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true)
_base_override = _normalize_override(params.get("hero_override", {}))
_first_script = false # 续玩模板已随快照强制(已抽模板零重抽)——脚本模式不重放
_aux_unlocked = params.get("aux_unlocked", {}) if params.get("aux_unlocked", {}) is Dictionary else {}
# 三选一停点续玩分支(迭代 10——选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付)
var bp_pending: Dictionary = bp.get("bonus_pending", {}) as Dictionary
var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary
if not bp_pending.is_empty():
_pending_next_level = true
_bonus_pending = {"candidates": (bp_pending.get("candidates", []) as Array).duplicate(true)}
if bp_pending.has("aux_detail"):
_bonus_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"):
_bonus_pending["chosen"] = "aux_item"
var incoming: Array = (bp_pending.get("incoming", []) as Array).duplicate(true)
_bonus_pending["incoming"] = incoming
state = "running"
bonus_choice_presented.emit(payload)
# 替换在途续发(呈现层直落替换子态——持有件记录按恢复后 carry 重建)
aux_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"
bonus_choice_presented.emit(payload)
return true
if not bp_result.is_empty():
_pending_next_level = true
_bonus_result = bp_result.duplicate(true)
state = "running"
bonus_resolved.emit(_bonus_result.duplicate(true))
return true
_resume_bp = bp.duplicate(true)
state = "running"
return _start_current_level()
# 三选一停点态归零(冒险开始/结算收束共用)
func _reset_bonus_state() -> void:
_bonus_taken = {"attack": 0, "max_hp": 0, "armor": 0, "ap": 0}
_bonus_pending = {}
_bonus_result = {}
_bonus_aux_carry = []
_keystone_drops = []
_node_bonus_pending = {}
# TunePanel 百分比通道(测试面板覆盖——每关新 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)
# 断点承载注入(迭代 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 _bonus_pending.is_empty():
push_error("[冒险结构] continue_to_next_level 拒绝——三选一停点未兑付(零自动推进契约)")
return false
_pending_next_level = false
_bonus_result = {}
level_index += 1
return _start_current_level()
# ---- 提升三选一(迭代 10 奖励重构批——选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)----
# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):id ∈ 停点候选。属性件写加算区+计数、AP 件写第四轴、
# GP 件走钱包「奖励入账」入口、道具件受同调位约束(满位转替换子交互)。
func pick_bonus(id: String) -> bool:
if state != "running" or _bonus_pending.is_empty():
push_error("[冒险结构] pick_bonus 拒绝——无待选停点(state=%s)" % state)
return false
var candidates: Array = _bonus_pending.get("candidates", []) as Array
if not candidates.has(id):
push_error("[冒险结构] pick_bonus 拒绝——件不在候选: %s" % id)
return false
var value: int = int(db.adventure.bonus_value(id))
var hp_before := int(ledger.get("hero_hp", 0))
if id == "aux_item":
return _pick_aux_item(value) # 道具件分派通关渠道(持有容器 = 跨关携带——与节点渠道分轨)
if not _apply_bonus_pick(id, value):
push_error("[冒险结构] pick_bonus 拒绝——未知件: %s" % id)
return false
_finish_bonus_pick(id, value, hp_before)
return true
# 三选一件兑付共享体(2026-10-02 修复批——通关/奖励节点双渠道 match 双胞胎收口):
# 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包。道具件不入此体——两渠道持有容器
# 语义不同(跨关携带 vs 关卡域库存),分派回调用方渠道处理;未知件返回 false 由调用方按渠道名报错。
func _apply_bonus_pick(id: String, value: int) -> bool:
match id:
"attack":
bonus["atk"] = int(bonus.get("atk", 0)) + value
_bonus_taken["attack"] = int(_bonus_taken.get("attack", 0)) + 1
"max_hp":
bonus["max_hp"] = int(bonus.get("max_hp", 0)) + value
_bonus_taken["max_hp"] = int(_bonus_taken.get("max_hp", 0)) + 1
"armor":
bonus["armor_class"] = int(bonus.get("armor_class", 0)) + value
_bonus_taken["armor"] = int(_bonus_taken.get("armor", 0)) + 1
"ap":
bonus["ap"] = int(bonus.get("ap", 0)) + value
_bonus_taken["ap"] = int(_bonus_taken.get("ap", 0)) + 1
"gp":
if _runner != null:
_runner.grant_bonus_gp(value)
_:
return false
return true
# 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_aux_replace)。
# 掷定前置(追加批二):具体道具随候选抽定(_last_draw_aux / _bonus_pending.aux_detail)——本函数消费预掷值,
# 断点续玩/无预掷值时回退当场掷定(流序不同但消费数不变,确定性由调用序保证)。
# 持有容器 = 跨关携带 _bonus_aux_carry(与节点渠道的关卡域库存分轨——容器差异保守不合,注记在案)。
func _pick_aux_item(count: int) -> bool:
var picked: Array = _draw_pending_aux(_bonus_pending, count, true)
if picked.is_empty():
push_error("[冒险结构] pick_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
return false
var hp_before := int(ledger.get("hero_hp", 0))
if AuxItems.can_accept(picked.size(), _bonus_aux_carry.size(), db): # 同调位守卫单点(2026-10-02)
for iid in picked:
_bonus_aux_carry.append(String(iid))
_finish_bonus_pick("aux_item", picked.size(), hp_before)
return true
# 满位替换子交互:现有持有件 + 新件 N+1 选 N(丢旧收新 / 放弃新件两路,终态恒 ≤ 同调位)
_bonus_pending["chosen"] = "aux_item"
_bonus_pending["incoming"] = picked.duplicate()
aux_replace_presented.emit(_carry_records(), {"index": -1, "item_id": String(picked[0]), "count": picked.size()})
return true
# 替换子交互受理(discard_index = -1 放弃新件不获取;否则丢该件腾位收新)——终态恒 ≤ 同调位
# 持有容器 = 跨关携带(序号寻址);与节点渠道(库存句柄寻址)的丢弃/授予/收尾三步保守不合。
func resolve_aux_replace(discard_index: int) -> bool:
if not _replace_in_transit(_bonus_pending):
push_error("[冒险结构] resolve_aux_replace 拒绝——无替换在途")
return false
var incoming: Array = _bonus_pending.get("incoming", []) as Array
var hp_before := int(ledger.get("hero_hp", 0))
if discard_index >= 0:
if discard_index >= _bonus_aux_carry.size():
push_error("[冒险结构] resolve_aux_replace 拒绝——序号不在持有集: %d" % discard_index)
return false
_bonus_aux_carry.remove_at(discard_index)
for iid in incoming:
_bonus_aux_carry.append(String(iid))
# 放弃新件(discard_index < 0)= 原持有集不动、新件不获取
_finish_bonus_pick("aux_item", incoming.size(), hp_before)
return true
# 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)
func _finish_bonus_pick(picked: String, value: int, hp_before: int) -> void:
_bonus_pending = {}
_bonus_result = {
"picked": picked,
"value": value,
"hp_before": hp_before,
"hp_after": int(ledger.get("hero_hp", 0)),
"level_index": level_index,
}
bonus_resolved.emit(_bonus_result.duplicate(true))
_snapshot_breakpoint()
# ---- 奖励节点三选一(迭代 10 追加批——用户拍板:与通关三选一共享同池同权重同次数上限)----
# 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载,
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。
# 掷定前置(追加批二):返回 dict 带 aux_detail(道具件具体道具)供卡片直显。
func node_bonus_candidates() -> Dictionary:
var cands: Array = _draw_bonus_candidates()
return {"candidates": cands, "aux_detail": _last_draw_aux.duplicate()}
func present_node_bonus(candidates: Array, aux_detail: Array = []) -> void:
_node_bonus_pending = {"candidates": candidates.duplicate(true), "aux_detail": aux_detail.duplicate(true)}
# 点选兑付(节点域——无部分回血无钥石;件获取计数与牌库剔除与通关三选一同账)
func apply_node_bonus(id: String) -> bool:
if _node_bonus_pending.is_empty():
push_error("[冒险结构] apply_node_bonus 拒绝——无待选停点")
return false
var candidates: Array = _node_bonus_pending.get("candidates", []) as Array
if not candidates.has(id):
push_error("[冒险结构] apply_node_bonus 拒绝——件不在候选: %s" % id)
return false
var value: int = int(db.adventure.bonus_value(id))
if id == "aux_item":
return _apply_node_aux(value) # 道具件分派节点渠道(持有容器 = 关卡域库存——与通关渠道分轨)
if not _apply_bonus_pick(id, value):
push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s" % id)
return false
_finish_node_bonus(id, value)
return true
# 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用);
# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨,容器语义保守不合)。
# 掷定前置:消费 _node_bonus_pending.aux_detail 预掷值(缺省回退当场掷定)。
func _apply_node_aux(count: int) -> bool:
var picked: Array = _draw_pending_aux(_node_bonus_pending, count, false)
if picked.is_empty():
push_error("[冒险结构] apply_node_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
return false
var records: Array = []
if _runner != null and _runner.aux_inventory != null:
records = _runner.aux_inventory.holding_records()
var holding := records.size() + _bonus_aux_carry.size()
if AuxItems.can_accept(picked.size(), holding, db): # 同调位守卫单点(2026-10-02)
if _runner != null:
for iid in picked:
var rec: Dictionary = _runner.grant_aux_item(String(iid), "reward_node")
if rec.is_empty():
push_warning("[冒险结构] 奖励节点道具授予被拒(防御兜底留痕): %s" % String(iid))
_finish_node_bonus("aux_item", picked.size())
return true
_node_bonus_pending["chosen"] = "aux_item"
_node_bonus_pending["incoming"] = picked.duplicate()
aux_replace_presented.emit(records, {"handle": -1, "item_id": String(picked[0]), "count": picked.size()})
return true
func _resolve_node_replace(discard_handle: int) -> bool:
if not _replace_in_transit(_node_bonus_pending):
push_error("[冒险结构] resolve_node_replace 拒绝——无替换在途")
return false
var incoming: Array = _node_bonus_pending.get("incoming", []) as Array
if discard_handle >= 0:
if _runner == null or _runner.aux_inventory == null or not _runner.aux_inventory.discard_item(discard_handle):
push_error("[冒险结构] resolve_node_replace 拒绝——句柄不在持有集: %d" % discard_handle)
return false
for iid in incoming:
_runner.grant_aux_item(String(iid), "reward_node")
# 放弃新件(discard_handle < 0)= 原持有集不动、新件不获取
_finish_node_bonus("aux_item", incoming.size())
return true
# 替换在途守卫(2026-10-02 修复批——双渠道替换受理的共用前置谓词:停点态非空且 chosen=aux_item)
func _replace_in_transit(pending: Dictionary) -> bool:
return not pending.is_empty() and String(pending.get("chosen", "")) == "aux_item"
# 双渠道道具件掷定共享体(2026-10-02 修复批——_pick_aux_item/_apply_node_aux 复制债收口一步):
# 解锁道具池空集守卫 + N 件掷定(先消费停点预掷 aux_detail,不足部分当场经 reward_pick 封闭流补掷——
# 取数序与拆分前逐位一致)。返回空数组 = 池空集(调用方按渠道名报错)。
# 保守不合处:通关渠道允许回退 _last_draw_aux(allow_detail_fallback)、节点渠道不回退——掷定回退
# 语义渠道各异,参数化保留差异;持有容器与兑付收尾仍归调用方渠道。
func _draw_pending_aux(pending: Dictionary, count: int, allow_detail_fallback: bool) -> Array:
var pool: Array = _bonus_aux_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_aux.duplicate()
for i in maxi(1, count):
if i < detail.size():
picked.append(String(detail[i]))
else:
picked.append(_roll_bonus_aux_item(pool))
return picked
# 替换受理分发(单入口——节点停点在途优先按库存句柄,否则按通关停点的携带序号)
func resolve_bonus_replace(discard: int) -> bool:
if not _node_bonus_pending.is_empty() and _node_bonus_pending.has("incoming"):
return _resolve_node_replace(discard)
return resolve_aux_replace(discard)
func _finish_node_bonus(picked: String, value: int) -> void:
_node_bonus_pending = {}
bonus_resolved.emit({
"context": "reward_node",
"picked": picked,
"value": value,
"level_index": level_index,
})
# 加权洗牌不放回掷 3(E-S weight-key:每件 key = U(0,1)^(1/权重) 取前 3——单次零重复、逐件按权重出
# 概率;达次数上限件先剔牌库〔AP 用尽 2 次/属性件达 3 次永不再现;GP/道具件无次数上限,牌库恒非空〕;
# 道具件解锁池空集〔白板/门控态〕不入牌库。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同三候选;
# 道具件具体道具在本调用内掷定(_last_draw_aux——掷定前置,卡片直显,追加批二)。
func _draw_bonus_candidates() -> Array:
var deck: Array = []
_last_draw_aux = []
for id in db.adventure.BONUS_IDS:
var cap := int(db.adventure.bonus_cap(String(id)))
if cap != db.adventure.BONUS_CAP_UNLIMITED and int(_bonus_taken.get(String(id), 0)) >= cap:
continue
if String(id) == "aux_item" and _bonus_aux_pool().is_empty():
continue
deck.append(String(id))
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
var keyed: Array = []
for id in deck:
var w := float(maxi(1, int(db.adventure.bonus_weight(id))))
keyed.append({"id": id, "key": pow(rg.randf(), 1.0 / w)})
keyed.sort_custom(func(a, b): return float(a["key"]) > float(b["key"]))
var out: Array = []
for i in mini(int(db.adventure.bonus_candidate_count), keyed.size()): # 候选数读表(2026-10-02 魔法数结构化——默认 3 = 现行行为)
var cid := String(keyed[i]["id"])
out.append(cid)
if cid == "aux_item":
var roll_rg: RandomNumberGenerator = rng.open_stream("reward_pick")
for j in maxi(1, int(db.adventure.bonus_aux_item_count)):
_last_draw_aux.append(_roll_from(roll_rg))
return out
func _roll_from(rg: RandomNumberGenerator) -> String:
var pool: Array = _bonus_aux_pool()
return String(pool[rg.randi_range(0, pool.size() - 1)])
# 三选一道具件随机池(与投放门控同源:aux 活行 ∩ 解锁门控集——空集 = 道具件不入牌库)
func _bonus_aux_pool() -> Array:
var out: Array = []
var live: Dictionary = db.aux_live_ids()
for iid in live.keys():
if _aux_unlocked.has(String(iid)):
out.append(String(iid))
return out
func _roll_bonus_aux_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_aux_carry.size():
out.append({"index": i, "item_id": _bonus_aux_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:
# 三选一道具件跨关携带注入(迭代 10——上关兑付的道具件进本关库存〔奖励只朝下一战生效〕;
# 注入即移交——carry 清空,本关末随关卡域库存清空)
for iid in _bonus_aux_carry:
_runner.grant_aux_item(iid, "bonus_pick")
_bonus_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_aux_items 先于呈现=C 区库存就位)。
if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0:
_runner.present_entry_choice()
else:
_runner.restore_aux_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_aux_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 := int(db.hero.max_hp) + int(_base_override["max_hp"]) + int(bonus.get("max_hp", 0))
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
var pick_payload: Dictionary = {
"level_index": level_index,
"candidates": _draw_bonus_candidates(),
"aux_detail": _last_draw_aux.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——关卡末清空反馈取数)
}
_bonus_pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true),
"aux_detail": _last_draw_aux.duplicate(),
"payload": pick_payload.duplicate(true)}
bonus_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_aux_cleared, # additive 扩签(迭代 7 T4——末关清空反馈;非末关走 bonus 载荷)
"keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(迭代 10——逐杀掉落明细;settle 入账明细/复盘归因数据源)
}
# 败退明细(数值验证工具取数面 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_bonus_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 aux_items: Array = []
if _runner != null and _runner.aux_inventory != null:
aux_items = _runner.aux_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),
"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": aux_items,
"bonus_taken": _bonus_taken.duplicate(true), # 三选一件获取次数(上限判定不可由加算区反推——迭代 10)
"bonus_pending": _bonus_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约)
"bonus_result": _bonus_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付)
"bonus_aux_carry": _bonus_aux_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)