P1-4 共享账本 Ledger 类化(裁点②甲 单类持四账): - game/core/run_ledger.gd: attune_charges/vision_counter/adventure_counters/bonus 四账 单类承载;战斗侧只拿 read/append 原语(attune_charge_has/set/consume、vision_count_add、 counters_seed_snapshot/merge_export、bonus_add_axis)——战末 export/开局 seed 时点语义不变 - AdventureRunner 持账(run_ledger)→ LevelRunner set_run_ledger+sync_attune_inventory (原四散 setter 收口)→ enc 单键 ledger 下传;编排器三裸引用成员退役为 _ledger 原语调用 - 调试/单场缺省新账=零扰动面(vision_counter 缺省空=不计,原 enc 缺键守卫语义保留) P1-2 刷新双账收口: - main 刷新计数副本(run_scope.refresh_used_round)删除;单一事实源=编排器受理计数 - presentation_snapshot 成文面升键 refresh_used(can_refresh 邻位)——HUD 余量/遥测 round_refresh_index 单源读快照;V5 快照一致性断言×3(smoke+ui_click HUD 文本比对) P1-3 GP 影子账收口: - adventure_runner summary 增 gp_final 键(close_wallet 清账前快照) - gp_shadow_ledger.gd 全件退役删除;main 委托缝/会话账本成员删除;终局面板 GP 行 取数=summary 键;ui_probe 影子账断言换代为 summary 键断言 验收: 逐位一致对拍三变体全同;冒烟 1079/0+run_all 26 套+probe click+render golden 0.00%
998 lines
49 KiB
GDScript
998 lines
49 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
|
||
# Date: 2026-09-11(2026-09-17 分支节点图批:行进器升级图版推进——单链 = 出边恰一的退化形态)
|
||
# 关卡节点地图(v1.0 图版落地——节点+连线,连线只朝前进方向,唯一入口,Boss 恒唯一终点):
|
||
# 关卡域生命周期唯一编排者——HP 跨战斗携带、恢复点节点兑付、关卡末收束。
|
||
# 行进纪律(零自动推进):到达即兑付;兑付后确认才出现选路口——战斗胜利与奖励/恢复确认一律经
|
||
# choices_presented 进入选路态,玩家 pick_next_node 显式选下一跳(接口漂移清偿:战斗胜利不再自动推进、
|
||
# continue_after_node 泛化为兑付确认——选路确认归 pick_next_node)。
|
||
# 2026-09-15 格面资源批:可选类道具与库存整体删除——奖励点节点内容随功能道具渠道(P1,渠道未定)悬置为
|
||
# 直通节点(零兑付);战斗各自持有 地图战斗资源 容器(映射随战斗退役,无跨战斗状态可延续)。
|
||
# Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。
|
||
# 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归元进度,切片不落盘)。
|
||
# E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变,
|
||
# 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。
|
||
# 对应 GDD:关卡节点地图 v1.0(图版结构——本文件为 P1 批实现位;命名规范见 CLAUDE.md)
|
||
# GP+商店房实施批(2026-09-21):GP 冒险钱包(战斗完成入账 clamp ≥0 · 冒险收束清零)+ 统一冒险
|
||
# 加算区 bonus{atk,max_hp,armor_class}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 +
|
||
# 加算区,调试浮窗百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认——
|
||
# 复用 continue_after_node 兑付确认纪律,零自动推进)。
|
||
# 冒险结构实现批(2026-09-21)——接口漂移清偿登记(06 清单「切片行进代码与定稿的接口漂移」①②③⑥⑦):
|
||
# ① 种子域归冒险结构:start_level 旧路径(单关测试模式)保留自调 rng.new_adventure;新路径
|
||
# start_level_external(冒险模式)不重播种——主种子注入恰一次归 AdventureRunner.start_adventure。
|
||
# ② 生命账本归冒险结构:hero_hp/gp 改属性落 _ledger 字典——冒险模式经 start_level_external 注入
|
||
# AdventureRunner 共享账本(Dictionary 引用共享),单关模式 = 本实例私有账本;战斗结果上报语义 =
|
||
# runner 经共享账本属性直写(接口②——战后生命终值已在本类 _on_battle_finished 回写账本)。
|
||
# ③ 恢复数学抽成单一路径 rest_heal_amount——冒险模式经 _rest_handler 回调(AdventureRunner.apply_rest,
|
||
# 接口③治疗封顶回报执行体归冒险结构);单关模式默认内置。公式逐位一致,零行为变更。
|
||
# ⑥ 收束先清库后宣布——现行无关卡域库存(格面资源批 2026-09-15 可选类删除后为空集),战斗结束
|
||
# retire_mapping + despawn_all 先于 battle_finished 发出即事实清库,不另设空接口(假契约)。
|
||
# ⑦ 倒下的失败结算归冒险结构:本类只发 level_finished(每战结果上报语义),失败结算由
|
||
# AdventureRunner._on_level_finished 执行;GP 冒险收束清零同步归冒险结构(本类冒险模式不关钱包)。
|
||
class_name LevelRunner
|
||
extends RefCounted
|
||
|
||
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp} · shop={gp}
|
||
signal choices_presented(candidates: Array) # 选路口(兑付确认后——候选节点引用数组,零自动推进)
|
||
signal step_row(row: Dictionary) # 转发当前 战斗结算 结算行
|
||
signal round_started(round_index: int)
|
||
signal board_changed()
|
||
signal level_finished(payload: Dictionary) # {result, hp, node_index, node_id, node_type}
|
||
signal wallet_changed(event: String, gp: int, extra: Dictionary) # GP 钱包事件投影:gain{amount}/buy{product,price}/clear——试玩日志与商店覆盖层刷新源(GP+商店房实施批)
|
||
|
||
var db: ConfigDb
|
||
var rng: RngServiceCore
|
||
var level_row: Dictionary[String, Variant] = {}
|
||
var node_index := -1
|
||
# ---- 账本载体(冒险结构实现批——漂移②清偿)----
|
||
# 冒险模式 = AdventureRunner 共享字典(Dictionary 引用共享——hero_hp/gp 属性读写直接落冒险账本);
|
||
# 单关模式 = 本实例私有账本(start_level 重建,既有单关语义逐位保留)。
|
||
var _ledger: Dictionary = {"hero_hp": 0, "gp": 0}
|
||
var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③)
|
||
var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点)
|
||
# hero_hp / gp 属性化(既有读写点零改动——值类型经属性落 _ledger)
|
||
var hero_hp: int:
|
||
get: return int(_ledger["hero_hp"])
|
||
set(v): _ledger["hero_hp"] = v
|
||
var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要)
|
||
get: return int(_ledger["gp"])
|
||
set(v): _ledger["gp"] = v
|
||
var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
|
||
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
|
||
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口
|
||
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
|
||
var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(迭代 7 T3——TreasureItems;跨战斗延续,
|
||
var _treasure_unlocked := {} # aux 解锁门控集(迭代 7 T5——拍板③甲;空集=零投放)
|
||
var _attunement: Array = [] # 同调装配集(迭代 11 第①批 T5——adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动)
|
||
# 关卡末清空+快照计数入 level_finished 载荷)
|
||
# 两根类型轴(迭代 23 T4——adventure 参数面快照经 set_hero_axes 下传;空串=未注入→锻造配置默认值回退。
|
||
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
|
||
var _hero_attack_type := ""
|
||
var _hero_armor_type := ""
|
||
# ---- 商店共享池模式态(迭代 11 第②批 T3——缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池换代)----
|
||
var _shop_pool_mode := false # pool 模式旗(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传)
|
||
var _pool_deck: Array = [] # 共享池令牌库共享引用(AdventureRunner._curio_deck——库存=剩余令牌数单点)
|
||
var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_curio_pick 同口写加算区+账本+摘牌)
|
||
var _pool_ticket := Callable() # 门票展示判定 handler(T5——AdventureRunner._feat_ticket_consider;节点与商店共享计数)
|
||
var _pool_cap_check := Callable() # cap 检查回调(迭代 20 T2——AdventureRunner._curio_cap_ok;案②提前退役)
|
||
var _shop_shelf: Array = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡)
|
||
var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零)
|
||
var _shop_paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零)
|
||
|
||
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
|
||
var hero_hp_pct := 0
|
||
# ---- 关卡相位轴(2026-10-02 修复批——布尔旗收拢:_running/_awaiting_choice 两旗 → 显式 Phase enum;
|
||
# 对齐 行动经济 Phase 先例(action_economy.gd:内部 enum + 守卫换算,外部读面契约零变化)——
|
||
# level_result:String 与 awaiting_choice() 被 AdventureRunner/呈现层消费的语义逐字不变)----
|
||
enum Phase { IDLE, RUNNING, AWAITING_CHOICE, ENDED }
|
||
var _phase: int = Phase.IDLE
|
||
var level_result := "" # "" / "victory" / "defeat"(外部契约面——语义零变化)
|
||
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
|
||
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
|
||
var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false)
|
||
|
||
|
||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||
db = db_ref
|
||
rng = rng_ref
|
||
|
||
|
||
# 进行中谓词(原 _running 语义——RUNNING 或 AWAITING_CHOICE 两相位合计;内部守卫专用)
|
||
func _is_running() -> bool:
|
||
return _phase == Phase.RUNNING or _phase == Phase.AWAITING_CHOICE
|
||
|
||
|
||
# ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 encounter 传入战斗)----
|
||
|
||
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
|
||
hero_atk_pct = atk_pct
|
||
hero_hp_pct = hp_pct
|
||
|
||
|
||
# 叠加次序(元进度实现批钉死):表值 → 永久强化 flat(base_override)→ 加算区 flat(bonus)先加
|
||
# → 调试浮窗百分比后乘(调试数值工具恒乘最后——代拍板 6,与 GP 批既有次序兼容)
|
||
func effective_atk() -> int:
|
||
var base: int = int(db.hero.atk) + int(base_override.get("atk", 0)) + int(run_ledger.bonus.get("atk", 0))
|
||
return maxi(1, int(round(float(base) * (100.0 + float(hero_atk_pct)) / 100.0)))
|
||
|
||
|
||
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(hero_hp_pct)) / 100.0)))
|
||
|
||
|
||
# 护甲等级生效值(入战 AC 三层可加——迭代 23 T4:甲型基础 AC〔forge.tres 选中甲路线 base_ac〕+
|
||
# 甲路线等级加值〔base_override.armor_class——hero_override 锻造分量〕+ 加算区 armor_class 轴;
|
||
# 无调试浮窗百分比通道,纯 flat)
|
||
func effective_ac() -> int:
|
||
return maxi(0, _armor_base_ac() + int(base_override.get("armor_class", 0)) + int(run_ledger.bonus.get("armor_class", 0)))
|
||
|
||
|
||
# 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线=轻甲 10,
|
||
# 与 hero.tres 两轴删除前 armor_class=10 行为连续)
|
||
func _armor_base_ac() -> int:
|
||
var at: String = _hero_armor_type if _hero_armor_type != "" else db.forge_default_armor_type()
|
||
var row: Dictionary = db.forge_armor_route_by_type(at)
|
||
if row.is_empty():
|
||
return int(db.hero.armor_class) # 表缺失防御兜底(现役基准)
|
||
return int(row.get("base_ac", 0))
|
||
|
||
|
||
# 行动点加成生效值(迭代 10 奖励重构批——统一冒险加算区第四轴 ap;经战斗开始入参 hero_ap_bonus
|
||
# 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕)
|
||
func effective_ap_bonus() -> int:
|
||
return maxi(0, int(run_ledger.bonus.get("ap", 0)))
|
||
|
||
|
||
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
|
||
func restore_hero_base() -> void:
|
||
hero_atk_pct = 0
|
||
hero_hp_pct = 0
|
||
|
||
|
||
# ---- 关卡生命周期 ----
|
||
|
||
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}, template_pick: int = -1) -> bool:
|
||
# 单关测试模式(旧路径——既有语义一字不改):私有账本、内置恢复数学、自调 new_adventure(漂移①
|
||
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
|
||
# base_override_in = 永久强化 flat 注入尾参(换算率标定批 2026-09-23——缺省 {} 全零白板,
|
||
# 与既有调用方逐位一致零行为变更;契约同 AdventureRunner.start_adventure params.hero_override:
|
||
# 三键 {atk, max_hp, armor_class}、缺键/-1 = 0、负值 clamp 0。游戏直入口仍恒白板——
|
||
# 注入面只服务数值验证工具,非玩家可达路径)
|
||
_external = false
|
||
_rest_handler = Callable(self, "rest_heal_amount")
|
||
_ledger = {"hero_hp": 0, "gp": 0}
|
||
var row := _find_level_row(level_index)
|
||
if row.is_empty():
|
||
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
|
||
return false
|
||
node_index = -1
|
||
level_result = ""
|
||
_phase = Phase.IDLE
|
||
_candidates = []
|
||
visited = []
|
||
_graph_mode = false
|
||
gp = 0 # 冒险钱包清零开局(GP+商店房实施批——局内货币生命周期起点)
|
||
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
|
||
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
|
||
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
|
||
for k in base_override.keys():
|
||
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
|
||
for node in nodes():
|
||
if node.has("next"):
|
||
_graph_mode = true
|
||
break
|
||
hero_hp = effective_max_hp()
|
||
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
|
||
level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7)
|
||
_phase = Phase.RUNNING
|
||
var entry := _entry_node()
|
||
if entry.is_empty():
|
||
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
|
||
_phase = Phase.ENDED
|
||
return false
|
||
return _enter_node(entry)
|
||
|
||
|
||
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
|
||
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
|
||
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
|
||
# base_override = 永久强化 flat 共享引用(元进度实现批——缺省 {} 全零白板,与 B1 空参数路径逐位一致)。
|
||
# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)),
|
||
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
|
||
# 停点前移批 2026-09-23:defer_entry 尾参(缺省 false = 既有调用方零变更)——true 时不自动
|
||
# _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在
|
||
# level_started 之后调 present_entry_choice() 呈现、玩家 pick_next_node 显式进首战。拆两步的
|
||
# 信号顺序契约:呈现层须先经 level_started 重取 runner 实例,choices_presented 才不会被
|
||
# _on_choices_presented 的 runner 空防御静默丢弃。入口呈现零 RNG 消耗、pick 走同一个
|
||
# _enter_node(entry)——与旧自动进入逐位等价(测量面零漂移红线)。
|
||
func start_level_external(level_index: int, ledger: Dictionary,
|
||
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
|
||
defer_entry: bool = false, template_pick: int = -1, resume_state: Dictionary = {}) -> bool:
|
||
var row := _find_level_row(level_index)
|
||
if row.is_empty():
|
||
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
|
||
return false
|
||
_external = true
|
||
_ledger = ledger
|
||
base_override = base_override_ref
|
||
_shop_purchased = purchased_ref
|
||
_rest_handler = rest_handler
|
||
level_row.assign(_resolve_template_row(row, template_pick))
|
||
node_index = -1
|
||
level_result = ""
|
||
_phase = Phase.IDLE
|
||
_candidates = []
|
||
visited = []
|
||
_graph_mode = false
|
||
for node in nodes():
|
||
if node.has("next"):
|
||
_graph_mode = true
|
||
break
|
||
_phase = Phase.RUNNING
|
||
# 断点续玩路径(迭代 8 卡①——defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
|
||
# 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。
|
||
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
|
||
if not resume_state.is_empty():
|
||
return _restore_to_stop(int(resume_state.get("node_id", -1)),
|
||
resume_state.get("visited_ids", []) as Array)
|
||
var entry := _entry_node()
|
||
if entry.is_empty():
|
||
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
|
||
_phase = Phase.ENDED
|
||
return false
|
||
if defer_entry:
|
||
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node)
|
||
_candidates = [entry]
|
||
return true
|
||
return _enter_node(entry)
|
||
|
||
|
||
# 入口停点呈现(停点前移批 2026-09-23——defer_entry=true 的第二步):发出入口候选供呈现层弹
|
||
# 选路层;非停点态(缺省自动进入路径)拒绝。零 RNG 消耗——呈现与行进取号解耦。
|
||
func present_entry_choice() -> bool:
|
||
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index != -1:
|
||
push_error("[关卡节点地图] present_entry_choice 拒绝——非入口停点态(defer_entry 未开或已 pick)")
|
||
return false
|
||
choices_presented.emit(_candidates)
|
||
return true
|
||
|
||
|
||
# ---- 断点续玩(迭代 8 卡①——停点粒度重建)----
|
||
|
||
# 停点重建(start_level_external resume_state 路径的落点):node_id = 停点所在节点编号(-1 = 入口
|
||
# 停点),visited_ids = 已到节点编号序(呈现层图条数据源重建)。纯状态重建零 RNG 消耗;
|
||
# 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。
|
||
func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
|
||
if node_id < 0:
|
||
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,单候选 = 入口节点)
|
||
var entry := _entry_node()
|
||
if entry.is_empty():
|
||
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
|
||
_phase = Phase.ENDED
|
||
return false
|
||
_phase = Phase.AWAITING_CHOICE
|
||
_candidates = [entry]
|
||
return true
|
||
var stop: Dictionary = {}
|
||
for node in nodes():
|
||
if _node_id(node) == node_id:
|
||
stop = node
|
||
break
|
||
if stop.is_empty():
|
||
push_error("[关卡节点地图] 断点恢复失败——停点节点编号 %d 不在本关图" % node_id)
|
||
_phase = Phase.ENDED
|
||
return false
|
||
node_index = nodes().find(stop)
|
||
visited = []
|
||
for vid in visited_ids:
|
||
for node in nodes():
|
||
if _node_id(node) == int(vid):
|
||
visited.append(node)
|
||
break
|
||
_phase = Phase.AWAITING_CHOICE
|
||
_candidates = _out_nodes(stop)
|
||
if _candidates.is_empty():
|
||
push_error("[关卡节点地图] 断点恢复失败——停点节点 %d 无出边(非合法停点)" % node_id)
|
||
_phase = Phase.ENDED
|
||
return false
|
||
return true
|
||
|
||
|
||
# 停点呈现(断点续玩路径——present_entry_choice 的中途节点版):信号顺序契约同入口
|
||
# (呈现层须先经 level_started 重取 runner 实例)。零 RNG 消耗。
|
||
func present_resumed_choice() -> bool:
|
||
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index < 0:
|
||
push_error("[关卡节点地图] present_resumed_choice 拒绝——非中途停点态")
|
||
return false
|
||
choices_presented.emit(_candidates)
|
||
return true
|
||
|
||
|
||
# 断点道具库存回填(迭代 8 卡①——奖励节点已选道具随停点快照回局;幂等,先清后入受理序重排)
|
||
func restore_treasure_items(items: Array) -> void:
|
||
if treasure_inventory == null:
|
||
treasure_inventory = TreasureItems.new(db)
|
||
treasure_inventory.reset()
|
||
treasure_inventory.restore_inventory(items)
|
||
|
||
|
||
func _find_level_row(level_index: int) -> Dictionary:
|
||
return db.level_row(level_index) # 表访问收口(2026-10-02——行集直扫归 LevelTable/ConfigDB 访问器)
|
||
|
||
|
||
# ---- 模板池解析(关卡节点图生成 v0.4——M-3 迭代 5 T5)----
|
||
# row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费);
|
||
# -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。
|
||
# 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数;
|
||
# active_template_index 供断点快照备案(迭代 8 卡①——已抽模板续玩零重抽零 RNG 消耗)。
|
||
var active_template_name := ""
|
||
var active_template_index := -1 # -1 = 非池形态(旧单图)/未解析
|
||
|
||
func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary:
|
||
var templates: Array = row.get("templates", [])
|
||
active_template_name = ""
|
||
active_template_index = -1
|
||
if templates.is_empty():
|
||
return row
|
||
var idx := 0
|
||
if template_pick >= 0:
|
||
idx = clampi(template_pick, 0, templates.size() - 1)
|
||
else:
|
||
var stream: RandomNumberGenerator = rng.open_stream("node_map")
|
||
idx = stream.randi_range(0, templates.size() - 1)
|
||
var tpl: Dictionary = templates[idx]
|
||
active_template_name = String(tpl.get("display_name", ""))
|
||
active_template_index = idx
|
||
var out := row.duplicate(true)
|
||
out["nodes"] = tpl.get("nodes", [])
|
||
return out
|
||
|
||
|
||
func nodes() -> Array:
|
||
return level_row.get("nodes", [])
|
||
|
||
|
||
func current_node() -> Dictionary:
|
||
if node_index < 0 or node_index >= nodes().size():
|
||
return {}
|
||
return nodes()[node_index]
|
||
|
||
|
||
# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)----
|
||
# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。
|
||
# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。
|
||
|
||
func _node_id(node: Dictionary) -> int:
|
||
if _graph_mode:
|
||
return int(node.get("node_depth", -1))
|
||
return nodes().find(node)
|
||
|
||
|
||
# 入口节点 = 无入边唯一节点(图合法性校验保证恰一)
|
||
func _entry_node() -> Dictionary:
|
||
var inbound := {}
|
||
for node in nodes():
|
||
for tid in _out_ids(node):
|
||
inbound[tid] = true
|
||
for node in nodes():
|
||
if not inbound.has(_node_id(node)):
|
||
return node
|
||
return {}
|
||
|
||
|
||
# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态)
|
||
func _out_ids(node: Dictionary) -> Array:
|
||
if node.has("next"):
|
||
return node.get("next", [])
|
||
var nodes: Array = nodes()
|
||
var i := nodes.find(node)
|
||
if i >= 0 and i < nodes.size() - 1:
|
||
return [i + 1]
|
||
return []
|
||
|
||
|
||
# 出边候选(节点引用数组——选路口/呈现层共用读面)
|
||
func _out_nodes(node: Dictionary) -> Array[Dictionary]:
|
||
var ids := {}
|
||
for n in nodes():
|
||
ids[_node_id(n)] = n
|
||
var out: Array[Dictionary] = []
|
||
for tid in _out_ids(node):
|
||
if ids.has(tid):
|
||
out.append(ids[tid])
|
||
return out
|
||
|
||
|
||
# 当前选路候选(选路态读面——呈现层渲染下一跳;非选路态 = 空)
|
||
func next_candidates() -> Array:
|
||
return _candidates.duplicate()
|
||
|
||
|
||
# 当前选路候选编号表(pick_next_node 的合法入参域——编号空间解析单一出口)
|
||
func next_candidate_ids() -> Array:
|
||
var out: Array[int] = []
|
||
for c in _candidates:
|
||
out.append(_node_id(c))
|
||
return out
|
||
|
||
|
||
# 当前节点编号(呈现层/日志读面——编号空间解析单一出口)
|
||
func current_node_id() -> int:
|
||
return _node_id(current_node())
|
||
|
||
|
||
# 任意节点编号 / 出边编号表(呈现层整图渲染读面——与行进同源,禁呈现层复算编号空间)
|
||
func node_id_of(node: Dictionary) -> int:
|
||
return _node_id(node)
|
||
|
||
|
||
func node_out_ids(node: Dictionary) -> Array:
|
||
return _out_ids(node)
|
||
|
||
|
||
# 已到节点编号序列(只读投影——编号空间解析单一出口)
|
||
func visited_ids() -> Array:
|
||
var out: Array[int] = []
|
||
for v in visited:
|
||
out.append(_node_id(v))
|
||
return out
|
||
|
||
|
||
func awaiting_choice() -> bool:
|
||
return _phase == Phase.AWAITING_CHOICE
|
||
|
||
|
||
func _enter_node(node: Dictionary) -> bool:
|
||
if not _is_running():
|
||
return false
|
||
node_index = nodes().find(node)
|
||
visited.append(node)
|
||
match String(node.get("type", "")):
|
||
"battle", "boss":
|
||
return _start_battle(node)
|
||
"reward":
|
||
# 奖励点内容待拍板(格面资源批 2026-09-15:可选类道具删除,功能道具渠道 P1 未定——
|
||
# 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进)
|
||
node_entered.emit(node, {})
|
||
return true
|
||
"rest":
|
||
var healed := _apply_rest()
|
||
node_entered.emit(node, {"healed": healed, "hp": hero_hp, "max_hp": effective_max_hp()})
|
||
return true
|
||
"shop":
|
||
# 商店房兑付(GP+商店房实施批 2026-09-21):到达即开店(覆盖层归呈现层),购买出账/
|
||
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
|
||
if _shop_pool_mode:
|
||
_shop_open_pool() # 迭代 11 第②批 T3——进店抽 3(恰一 shop_draw scope;买后全重抽同签)
|
||
node_entered.emit(node, {"gp": gp, "shelf": true})
|
||
return true
|
||
node_entered.emit(node, {"gp": gp}) # legacy 固定三件(off 对照承载——零 RNG 零池消费)
|
||
return true
|
||
push_error("[关卡节点地图] 未知节点类型: %s" % str(node))
|
||
return false
|
||
|
||
|
||
func _start_battle(node: Dictionary) -> bool:
|
||
_settlement = BattleOrchestrator.new(db, rng)
|
||
if treasure_inventory == null:
|
||
treasure_inventory = TreasureItems.new(db) # 惰性建——关卡域单实例(跨战斗延续)
|
||
_settlement.treasure_inventory = treasure_inventory # 迭代 7 T3——库存注入(授予路由+use_treasure 编排消费面)
|
||
if _settlement != null:
|
||
_settlement.set_treasure_unlocked(_treasure_unlocked) # 迭代 7 T5——解锁门控下传(装板摇号前)
|
||
# core↔core 接线只许方法 Callable(ADR-0001 静态规则 S6——lambda 捕获 self 会延长
|
||
# RefCounted 生命周期乃至成环:settlement 的信号持捕获 runner 的 lambda,runner 又持 settlement)
|
||
_settlement.step_row.connect(_forward_step_row)
|
||
_settlement.round_started.connect(_forward_round_started)
|
||
_settlement.board_changed.connect(_forward_board_changed)
|
||
_settlement.battle_finished.connect(_on_battle_finished)
|
||
# node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的
|
||
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染(2026-09-11 playtest 缺陷①)
|
||
node_entered.emit(node, {})
|
||
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
|
||
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
|
||
"hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(),
|
||
"attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 迭代 23 T4——两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退)
|
||
"attunement": _attunement,
|
||
"ledger": run_ledger, # 迭代 27 T3——共享四账单类下传(原四散 dict 引用键收口;裁点②甲)
|
||
"feats": _feats,
|
||
"tree_rules": _tree_rules,
|
||
"battle_index": _battle_index(),
|
||
"voucher_rap": _voucher_rap,
|
||
"voucher_time_sand": _voucher_time_sand,
|
||
"voucher_insurance": _voucher_insurance,
|
||
"node_type": String(node.get("type", ""))}):
|
||
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
|
||
return false
|
||
_voucher_rap = 0 # 迭代 20 T4——节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清)
|
||
_voucher_time_sand = 0
|
||
_voucher_insurance = 0
|
||
return true
|
||
|
||
|
||
func _forward_step_row(row: Dictionary) -> void:
|
||
step_row.emit(row)
|
||
|
||
|
||
# battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕;
|
||
# 按已到节点 type=battle 计数,与图形态 node_depth/单链数组下标两编号空间解耦〔混编碰撞免疫〕;
|
||
# 开局姿态组「每关首轮窗」条件面——迭代 21 T3)
|
||
func _battle_index() -> int:
|
||
var n := 0
|
||
for v in visited:
|
||
if String((v as Dictionary).get("type", "")) == "battle":
|
||
n += 1
|
||
return n
|
||
|
||
|
||
func _forward_round_started(ri: int) -> void:
|
||
round_started.emit(ri)
|
||
|
||
|
||
func _forward_board_changed() -> void:
|
||
board_changed.emit()
|
||
|
||
|
||
func _on_battle_finished(payload: Dictionary) -> void:
|
||
var node := current_node()
|
||
var result: String = String(payload.get("result", ""))
|
||
if result == "victory":
|
||
hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含)
|
||
_gain_wallet(int(payload.get("gp_dropped", 0))) # 战斗完成入账(clamp ≥0——破财可把 escrow 打负,钱包入账时钳制)
|
||
if String(node.get("type", "")) == "boss" or result == "defeat":
|
||
# Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(漂移⑦清偿——每战结果上报冒险结构,
|
||
# 失败结算归 AdventureRunner;本类只发 level_finished)
|
||
level_result = "victory" if result == "victory" else "defeat"
|
||
var hp_left := hero_hp if result == "victory" else 0
|
||
if not _external:
|
||
close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle
|
||
_phase = Phase.ENDED
|
||
_settlement = null
|
||
var treasure_cleared := 0
|
||
if treasure_inventory != null:
|
||
treasure_cleared = treasure_inventory.inventory_count() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数)
|
||
treasure_inventory.reset() # 迭代 7 T3——关卡末库存清空(无论输赢,幂等)
|
||
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
|
||
"node_id": _node_id(node), "node_type": String(node.get("type", "")),
|
||
"aux_cleared": treasure_cleared}) # additive 扩签(不参与派生——node_map 扩签先例)
|
||
return
|
||
# 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口
|
||
_settlement = null
|
||
_present_choices()
|
||
|
||
|
||
# 恢复数学单一路径(漂移③清偿;2026-10-02 修复批——同式双落点收口:算术宿主 = 本静态方法,
|
||
# AdventureRunner.apply_rest 同式委托,字面量双写退役)。floor(上限 × 比例 ÷ 100) 后封顶缺口;
|
||
# 冒险结构 GDD 公式逐位一致
|
||
static func rest_heal(pct: int, hp: int, max_hp: int) -> int:
|
||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|
||
|
||
|
||
func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int:
|
||
return rest_heal(pct, hp, max_hp) # 公有成文出口保持(start_level 内置恢复执行体经 Callable 引用)
|
||
|
||
|
||
func _apply_rest() -> int:
|
||
var pct: int = int(level_row.get("rest_heal_pct", 0))
|
||
# 接口③治疗封顶回报:单关模式 = 内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest
|
||
#(执行归冒险结构——回报比例、由其按封顶公式回写共享账本后再由本类叠加)
|
||
var actual: int = int(_rest_handler.call(pct, hero_hp, effective_max_hp()))
|
||
hero_hp += actual
|
||
return actual
|
||
|
||
|
||
# ---- 选路(零自动推进的停点语义:兑付确认 → 候选呈现 → 显式 pick)----
|
||
|
||
# 兑付确认后呈现选路口(战斗胜利 / 奖励·恢复确认统一入口——单候选同样确认,Boss 外无免确认通道)
|
||
func _present_choices() -> bool:
|
||
var cands := _out_nodes(current_node())
|
||
if cands.is_empty():
|
||
push_error("[关卡节点地图] 当前节点无出边且非 Boss(图合法性校验应已拦截)")
|
||
return false
|
||
_phase = Phase.AWAITING_CHOICE
|
||
_candidates = cands
|
||
choices_presented.emit(cands)
|
||
return true
|
||
|
||
|
||
# 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生)
|
||
func pick_next_node(node_id: int) -> bool:
|
||
if _phase != Phase.AWAITING_CHOICE:
|
||
return false
|
||
for cand in _candidates:
|
||
if _node_id(cand) == node_id:
|
||
_phase = Phase.RUNNING
|
||
_candidates = []
|
||
return _enter_node(cand)
|
||
return false
|
||
|
||
|
||
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)----
|
||
|
||
func current_battle():
|
||
return _settlement
|
||
|
||
# aux 解锁门控集注入(迭代 7 T5——AdventureRunner 自 params 传递)
|
||
func set_treasure_unlocked(ids: Dictionary) -> void:
|
||
_treasure_unlocked = ids
|
||
if _settlement != null:
|
||
_settlement.set_treasure_unlocked(ids)
|
||
|
||
|
||
# 同调装配集注入(迭代 11 第①批 T5——冒险开始参数面;AdventureRunner 唯一调用方)
|
||
func set_attunement(ids: Array) -> void:
|
||
_attunement = ids.duplicate()
|
||
|
||
|
||
# 特性持有集注入(迭代 12 T1——AdventureRunner._start_current_level 唯一调用方;begin_battle
|
||
# enc.feats 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效
|
||
# (attune_charges 共享账本同款);空集=零扰动)
|
||
var _feats: Array = []
|
||
|
||
|
||
func set_feats(ids: Array) -> void:
|
||
_feats = ids
|
||
|
||
|
||
# 特性持有集读面(迭代 12 呈现补齐——HUD C 区特性段数据源)
|
||
func feats_held() -> Array:
|
||
return _feats.duplicate()
|
||
|
||
|
||
# 树 rule 键集注入(迭代 12 T10——begin_battle enc.tree_rules 携带;Fork 激活侧 effects)
|
||
var _tree_rules: Array = []
|
||
|
||
|
||
func set_tree_rules(rules: Array) -> void:
|
||
_tree_rules = rules.duplicate()
|
||
|
||
|
||
# 两根类型轴注入(迭代 23 T4——adventure 参数面快照;空串=未注入→锻造配置默认值回退。
|
||
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
|
||
func set_hero_axes(attack_type: String, armor_type: String) -> void:
|
||
_hero_attack_type = attack_type
|
||
_hero_armor_type = armor_type
|
||
|
||
|
||
# 共享四本账注入(迭代 27 T3——P1-4 收口:RunLedger 单类下传,替代原 bonus/attune_charges/
|
||
# vision_counter/adventure_counters 四散引用;战斗侧只走账类原语,战末 export/开局 seed
|
||
# 时点语义不变〔裁点②甲〕)
|
||
var run_ledger: RunLedger = RunLedger.new()
|
||
|
||
|
||
func set_run_ledger(l: RunLedger) -> void:
|
||
run_ledger = l
|
||
|
||
|
||
# 凭证件三账注入(迭代 20 T4——储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性,
|
||
# battle begin 消费后归零;缺省 0=零扰动)
|
||
var _voucher_rap := 0
|
||
var _voucher_time_sand := 0
|
||
var _voucher_insurance := 0
|
||
|
||
|
||
func set_voucher_grants(rap: int, time_sand: int, insurance: int) -> void:
|
||
_voucher_rap = maxi(0, rap)
|
||
_voucher_time_sand = maxi(0, time_sand)
|
||
_voucher_insurance = maxi(0, insurance)
|
||
|
||
|
||
# 同调主动件充能账骑库存面注入(迭代 11 第①批 T6 原语义 + 迭代 27 T3 换代——账本住
|
||
# RunLedger;能力件骑库存面注入保留:差额注入防续玩双份——resume 路径 restore_inventory
|
||
# 已含断点内 attune 记录。原 set_attune_charges/set_vision_counter/set_adventure_counters
|
||
# 三散 setter 随单类下传承接)
|
||
func sync_attune_inventory() -> void:
|
||
var charges: Dictionary = run_ledger.attune_charges
|
||
if treasure_inventory == null:
|
||
treasure_inventory = TreasureItems.new(db)
|
||
for iid in charges:
|
||
var existing := 0
|
||
for r in treasure_inventory.snapshot_inventory():
|
||
if String(r.item_id) == String(iid) and String(r.source_class) == "attunement" and not bool(r.used):
|
||
existing += 1
|
||
for i in range(maxi(0, int(charges[iid]) - existing)):
|
||
treasure_inventory.add_item(String(iid), "attunement", Vector2i(-1, -1), 0)
|
||
|
||
# 三选一 GP 袋件入账(迭代 10——钱包收口扩「奖励入账」入口;复用战斗完成同一入账原语,
|
||
# wallet_changed 载荷带 source 供呈现归因)
|
||
func grant_curio_gp(amount: int) -> void:
|
||
gp = maxi(0, gp + amount)
|
||
wallet_changed.emit("gain", gp, {"amount": amount, "source": "curio_pick"})
|
||
|
||
|
||
# ---- 玩家输入转发(呈现层只与 runner 对话)----
|
||
|
||
# 奖励/恢复/商店房兑付确认(呈现层在覆盖层上点击"继续/离开"后调用;自动模拟在 node_entered 回调内立即调用)
|
||
# 泛化语义(接口漂移清偿):确认 ≠ 推进——确认后进选路口,下一跳归 pick_next_node
|
||
func continue_after_node() -> bool:
|
||
var t := String(current_node().get("type", ""))
|
||
if t != "reward" and t != "rest" and t != "shop":
|
||
return false
|
||
if t == "shop":
|
||
_shop_shelf = [] # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态——迭代 11 第②批 T2)
|
||
_shop_visit_buys = 0
|
||
_shop_paid_refreshes = 0 # MC-1
|
||
return _present_choices()
|
||
|
||
|
||
# ---- GP 冒险钱包(GP+商店房实施批 2026-09-21:入账/出账/收束清零三收口)----
|
||
|
||
# 战斗完成入账(gp_dropped 载荷——clamp ≥0;破财可把战斗域 escrow 打负,钱包不透支)
|
||
func _gain_wallet(amount: int) -> void:
|
||
gp = maxi(0, gp + amount)
|
||
wallet_changed.emit("gain", gp, {"amount": amount})
|
||
|
||
|
||
# 冒险收束清零(通关/倒下/放弃——局内货币生命周期终点,不入存档不入摘要;public——冒险模式由
|
||
# AdventureRunner._settle 在冒险收束时调用,单关模式 = 关卡收束即冒险收束)
|
||
func close_wallet() -> void:
|
||
gp = 0
|
||
wallet_changed.emit("clear", 0, {})
|
||
|
||
|
||
# ---- 商店房兑付(GP+商店房实施批:呈现层只读投影 + 购买出账)----
|
||
|
||
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
|
||
func shop_products() -> Array:
|
||
var out: Array[Dictionary] = []
|
||
for prow in db.shop_products():
|
||
out.append({
|
||
"product_id": String(prow.get("product_id", "")),
|
||
"display_name": String(prow.get("display_name", "")),
|
||
"attr": String(prow.get("attr", "")),
|
||
"flat_value": int(prow.get("flat_value", 0)),
|
||
"price": current_price(prow),
|
||
"purchased": purchased_tier(String(prow.get("product_id", ""))),
|
||
})
|
||
return out
|
||
|
||
|
||
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
|
||
# (散折价随批:economy_spend 激活时商品标价 ×80%——见 _spend_discounted)
|
||
func current_price(prow: Dictionary) -> int:
|
||
return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(int(level_row.level_index)) \
|
||
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", ""))))
|
||
|
||
|
||
# 散折价单点(迭代 21 T3——财富之道·散 economy_spend 激活时商品购买价 ×80% provisional,
|
||
# floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price
|
||
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折〔卡片§11②裁决〕;
|
||
# 折率归 M4 数值批)
|
||
func _spend_discounted(price: int) -> int:
|
||
if _tree_rules.has("economy_spend"):
|
||
return maxi(1, int(floor(float(price) * 0.8)))
|
||
return price
|
||
|
||
|
||
func purchased_tier(product_id: String) -> int:
|
||
return int(_shop_purchased.get(product_id, 0))
|
||
|
||
|
||
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
|
||
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
|
||
func buy_product(product_id: String) -> bool:
|
||
var prow: Dictionary = db.shop_product_row(product_id)
|
||
if prow.is_empty():
|
||
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
|
||
return false
|
||
var attr := String(prow.get("attr", ""))
|
||
if not run_ledger.bonus.has(attr):
|
||
push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr])
|
||
return false
|
||
var price := current_price(prow)
|
||
if gp < price:
|
||
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [product_id, price, gp])
|
||
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝;2026-10-02 补静默留痕)
|
||
gp -= price
|
||
var flat := int(prow.get("flat_value", 0))
|
||
run_ledger.bonus[attr] = int(run_ledger.bonus[attr]) + flat
|
||
if attr == "max_hp":
|
||
hero_hp = mini(hero_hp + flat, effective_max_hp()) # 购血:上限与当前生命同步 +Δ(封顶新上限)
|
||
_shop_purchased[product_id] = purchased_tier(product_id) + 1
|
||
wallet_changed.emit("buy", gp, {"product": product_id, "price": price})
|
||
return true
|
||
|
||
|
||
func player_request_refresh() -> bool:
|
||
if _settlement == null:
|
||
return false
|
||
return _settlement.player_request_refresh()
|
||
|
||
|
||
# ---- 商店共享池模式(迭代 11 第②批 T3——P1 共享池/P4 ceil;legacy 固定三件路径上方原样保留为 off 对照承载)----
|
||
|
||
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
|
||
# 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。
|
||
# 迭代 20 T2——cap 检查回调(案②提前退役:cap 满 kind 剔出货架候选视图)。
|
||
func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable(), cap_check: Callable = Callable()) -> void:
|
||
_pool_deck = deck_ref
|
||
_pool_consume = consume_handler
|
||
_pool_ticket = ticket_consider # T5——门票展示判定(AdventureRunner._feat_ticket_consider;同流取数复用 shop_draw scope)
|
||
_pool_cap_check = cap_check
|
||
_shop_pool_mode = true
|
||
|
||
|
||
# 进店开店(_enter_node shop 分支 pool 模式):进店购买次计数清零 + 抽 3 落架(恰一 shop_draw scope)
|
||
func _shop_open_pool() -> void:
|
||
_shop_visit_buys = 0
|
||
_shop_paid_refreshes = 0 # MC-1——进店付费刷新计数清零
|
||
_shop_draw_shelf()
|
||
|
||
|
||
# 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余
|
||
# 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率
|
||
# 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡,
|
||
# 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架
|
||
# 合法态(池空硬闸——买光即枯竭既定后果)。
|
||
func _shop_draw_shelf() -> void:
|
||
_shop_shelf = []
|
||
var sellable: Array = []
|
||
for row in db.shop_pool_rows():
|
||
var shelf_kind := String(row.get("kind_id", ""))
|
||
if _pool_cap_check.is_valid() and not bool(_pool_cap_check.call(shelf_kind, "")):
|
||
continue # 迭代 20 T2 案②——cap 满提前退役(货架候选视图剔除;牌库本体不动)
|
||
for t in _pool_deck:
|
||
var tok: Dictionary = t as Dictionary
|
||
if String(tok.get("kind", "")) == shelf_kind:
|
||
sellable.append({"kind": shelf_kind, "tier": String(tok.get("tier", ""))})
|
||
if sellable.is_empty():
|
||
return
|
||
var stream: RandomNumberGenerator = rng.open_stream("shop_draw")
|
||
for i in sellable.size():
|
||
var j := stream.randi_range(i, sellable.size() - 1)
|
||
var tmp = sellable[i]
|
||
sellable[i] = sellable[j]
|
||
sellable[j] = tmp
|
||
var seen: Array = []
|
||
for tok in sellable:
|
||
var k := String((tok as Dictionary).get("kind", ""))
|
||
if seen.has(k):
|
||
continue
|
||
seen.append(k)
|
||
_shop_shelf.append(tok)
|
||
if _shop_shelf.size() >= 3:
|
||
break
|
||
# T5 门票注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。
|
||
# 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移)
|
||
if _pool_ticket.is_valid() and not _shop_shelf.is_empty() and bool(_pool_ticket.call(stream)):
|
||
_shop_shelf[_shop_shelf.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"} # 迭代 20 T2——档名四级换代(旧 rare 语义=优秀)
|
||
# 商店保底退役(迭代 15 卡② T4——渠道轴定位裁决:商店渠道只出属性值提升+资源,专长在非 Boss
|
||
# 渠道只以门票形态出现(奇物.md v2.7/A4 断言)。迭代 12 T6「货架零特性挤卡换吞噬流件」简化口径
|
||
# 与渠道白名单直接冲突(devour=规则改写型=专长系),随渠道分离退役;专长商店入口=T5 门票。
|
||
|
||
|
||
# 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0)
|
||
func pool_stock(kind: String) -> int:
|
||
var n := 0
|
||
for t in _pool_deck:
|
||
if String((t as Dictionary).get("kind", "")) == kind:
|
||
n += 1
|
||
return n
|
||
|
||
|
||
# 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向;
|
||
# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外)
|
||
# 基价 provisional 15 固定锚(台账披露待追认——shop.tres 词表扩列归数值批)。
|
||
func pool_price(kind: String) -> int:
|
||
var row: Dictionary = db.shop_pool_row(kind)
|
||
if row.is_empty():
|
||
if kind == "relic":
|
||
return _spend_discounted(int(ceil(15.0 * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) * pow(db.shop_price_a(), float(_shop_visit_buys)))))
|
||
return 999999
|
||
var base := float(maxi(1, int(row.get("base_price", 1))))
|
||
var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) \
|
||
* pow(db.shop_price_a(), float(_shop_visit_buys))
|
||
return _spend_discounted(int(ceil(price_f)))
|
||
|
||
|
||
# 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。
|
||
# 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式维持批②裁定 tier-blind)。
|
||
func shop_shelf() -> Array:
|
||
var out: Array[Dictionary] = []
|
||
for tok in _shop_shelf:
|
||
var kind := String((tok as Dictionary).get("kind", ""))
|
||
var tier := String((tok as Dictionary).get("tier", ""))
|
||
var row: Dictionary = db.shop_pool_row(kind)
|
||
var display := String(row.get("display_name", kind))
|
||
var value := int(db.adventure.curio_value(kind, tier))
|
||
if row.is_empty() and kind == "relic":
|
||
var rrow: Dictionary = db.feat_row(String(tok.get("feat_id", "")))
|
||
display = String(rrow.get("display_name", "特性"))
|
||
value = 0
|
||
out.append({
|
||
"kind_id": kind,
|
||
"tier": tier,
|
||
"feat_id": String(tok.get("feat_id", "")),
|
||
"display_name": display,
|
||
"value": value,
|
||
"price": pool_price(kind),
|
||
"stock_left": pool_stock(kind),
|
||
})
|
||
return out
|
||
|
||
|
||
# 商店独立刷新(MC-1——P-C2 裁 2026-10-04 推翻 v1.5「无独立刷新」):免费次数(天赋增益位,基础
|
||
# knob=0——Q2 裁「基础免费 0 次」)用尽后 GP 付费刷新;刷新=货架整架重抽(shop_draw 恰一新 scope,
|
||
# 与进店/买后重抽同签——重抽同源共享池);池空/非池模式=不可刷新合法态。
|
||
func shop_refresh_free_left() -> int:
|
||
# 迭代 22 秘宝批 T3——刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用,
|
||
# 与基础 knob 同生命周期;未装配=基值逐位同旧)
|
||
return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
|
||
|
||
|
||
# 刷新执照装配合计(迭代 22 秘宝批 T3——装配集 kind 分发扫描;零装配=0)
|
||
func _attune_shop_refresh_bonus() -> int:
|
||
var total := 0
|
||
for iid_v in _attunement:
|
||
var arow: Dictionary = db.treasure_item_row(String(iid_v))
|
||
if (arow.get("effect_kinds", []) as Array).has("shop_refresh_bonus"):
|
||
total += int(arow.get("magnitude", 0))
|
||
return total
|
||
|
||
|
||
func shop_refresh_cost() -> int:
|
||
return int(ceil(float(db.adventure.shop_refresh_cost_base)
|
||
* pow(db.adventure.shop_refresh_cost_curve, float(_shop_paid_refreshes))))
|
||
|
||
|
||
func shop_refresh() -> bool:
|
||
if not _shop_pool_mode or _shop_shelf.is_empty():
|
||
return false
|
||
if shop_refresh_free_left() <= 0:
|
||
var cost := shop_refresh_cost()
|
||
if gp < cost:
|
||
return false
|
||
gp -= cost
|
||
_shop_paid_refreshes += 1
|
||
_shop_draw_shelf()
|
||
return true
|
||
|
||
|
||
# GP 冒险钱包单口支出(2026-10-06——三选一 GP 付费刷新与商店刷新共用同一钱包;余额不足 false 零副作用)
|
||
func spend_gp(amount: int) -> bool:
|
||
if amount <= 0 or gp < amount:
|
||
return false
|
||
gp -= amount
|
||
return true
|
||
|
||
|
||
# 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架
|
||
# 令牌本体(kind+tier——_apply_curio_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买
|
||
# 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作)
|
||
func buy_pool_item(kind: String) -> bool:
|
||
var shelf_tok := {}
|
||
for tok in _shop_shelf:
|
||
if String((tok as Dictionary).get("kind", "")) == kind:
|
||
shelf_tok = tok
|
||
break
|
||
if not _shop_pool_mode or shelf_tok.is_empty():
|
||
push_error("[关卡节点地图] 商店购买拒绝——非池模式或件不在架: %s" % kind)
|
||
return false
|
||
var price := pool_price(kind)
|
||
if gp < price:
|
||
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [kind, price, gp])
|
||
return false
|
||
var tier := String(shelf_tok.get("tier", ""))
|
||
if _pool_consume.is_null() or not bool(_pool_consume.call(kind, tier)):
|
||
push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s|%s" % [kind, tier])
|
||
return false
|
||
gp -= price
|
||
_shop_visit_buys += 1
|
||
wallet_changed.emit("buy", gp, {"product": kind, "price": price})
|
||
_shop_draw_shelf() # 买 1 → 三件全重抽(恰一 shop_draw scope——同进店取号签)
|
||
return true
|
||
|
||
|
||
# 池模式谓词(呈现层组合根分发用——货架面 vs legacy 商品面)
|
||
func is_shop_pool() -> bool:
|
||
return _shop_pool_mode
|
||
|
||
|
||
# 断点快照读面(P2 甲货架+账本入断点——AdventureRunner._snapshot_breakpoint 消费;离开店即空架)
|
||
func shop_bp_shelf() -> Array:
|
||
return _shop_shelf.duplicate()
|
||
|
||
|
||
func shop_bp_visit_buys() -> int:
|
||
return _shop_visit_buys
|