iter10 追加批: 奖励节点三选一共享池(用户拍板)——AdventureRunner 节点API+AuxItems 句柄面+main 共用面分派+node_overlay 道具面退役+autoplay 节点驱动+reward_overlay_test 随面迁移+a裁备选(L3复杀3)+g裁semver(v2兼容断言+存档序列化v1.3)+f裁调研待办(06新条)+文档五件+任务书(冒烟628/0+run_all 15套+五探针+三闸零新增+熵增PASS)
This commit is contained in:
1 parent
93e4764ca0
commit
4f62a489b6
19 files changed
+377
-154
No files matched your search
@@ -80,6 +80,7 @@ var _bonus_pending := {} # 停点态:{"candidates": Array, "p
|
||||
var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付)
|
||||
var _bonus_aux_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗)
|
||||
var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源)
|
||||
var _node_bonus_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽)
|
||||
|
||||
|
||||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||||
@@ -197,6 +198,7 @@ func _reset_bonus_state() -> void:
|
||||
_bonus_result = {}
|
||||
_bonus_aux_carry = []
|
||||
_keystone_drops = []
|
||||
_node_bonus_pending = {}
|
||||
|
||||
|
||||
# TunePanel 百分比通道(测试面板覆盖——每关新 runner 都要应用;正式侧归元进度)
|
||||
@@ -331,6 +333,113 @@ func _finish_bonus_pick(picked: String, value: int, hp_before: int) -> void:
|
||||
_snapshot_breakpoint()
|
||||
|
||||
|
||||
# ---- 奖励节点三选一(迭代 10 追加批——用户拍板:与通关三选一共享同池同权重同次数上限)----
|
||||
|
||||
# 候选呈现(节点到达时调用——恰一 reward_pick scope 取数;本停点断点不载,
|
||||
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)
|
||||
func node_bonus_candidates() -> Array:
|
||||
return _draw_bonus_candidates()
|
||||
|
||||
|
||||
func present_node_bonus(candidates: Array) -> void:
|
||||
_node_bonus_pending = {"candidates": candidates.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))
|
||||
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)
|
||||
"aux_item":
|
||||
return _apply_node_aux(value)
|
||||
_:
|
||||
push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s" % id)
|
||||
return false
|
||||
_finish_node_bonus(id, value)
|
||||
return true
|
||||
|
||||
|
||||
# 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用);
|
||||
# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨)
|
||||
func _apply_node_aux(count: int) -> bool:
|
||||
var pool: Array = _bonus_aux_pool()
|
||||
if pool.is_empty():
|
||||
push_error("[冒险结构] apply_node_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
|
||||
return false
|
||||
var picked: Array[String] = []
|
||||
for i in maxi(1, count):
|
||||
picked.append(_roll_bonus_aux_item(pool))
|
||||
var slots := maxi(0, int(db.adventure.transitional_sync_slots))
|
||||
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 holding + picked.size() <= slots:
|
||||
if _runner != null:
|
||||
for iid in picked:
|
||||
_runner.grant_aux_item(iid, "reward_node")
|
||||
_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 _node_bonus_pending.is_empty() or String(_node_bonus_pending.get("chosen", "")) != "aux_item":
|
||||
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
|
||||
|
||||
|
||||
# 替换受理分发(单入口——节点停点在途优先按库存句柄,否则按通关停点的携带序号)
|
||||
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,同种子同三候选)
|
||||
|
||||
@@ -73,6 +73,24 @@ func holding_count() -> int:
|
||||
return n
|
||||
|
||||
|
||||
# 持有件记录(迭代 10 追加批——奖励节点替换子交互数据源:只列未消耗件,handle 定位丢弃目标)
|
||||
func holding_records() -> Array:
|
||||
var out: Array = []
|
||||
for rec in _items:
|
||||
if not bool(rec.get("used", false)):
|
||||
out.append({"handle": int(rec.get("handle", 0)), "item_id": String(rec.get("item_id", ""))})
|
||||
return out
|
||||
|
||||
|
||||
# 丢弃(同调位替换交互节点渠道——腾位收新;与 Used 消费分轨的一次性移除,快照不再含)
|
||||
func discard_item(handle: int) -> bool:
|
||||
for i in _items.size():
|
||||
if int(_items[i].get("handle", -1)) == handle and not bool(_items[i].get("used", false)):
|
||||
_items.remove_at(i)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# 板级零信息可用性预判(图标置灰/入口禁用判据——GDD「整张图都没东西可看」级):
|
||||
# 真视恒可用(零雷窗口=信息非空);AP 类恒可用(组合判据取 OR——任一效果有信息即可用);
|
||||
# 纯预读行 = 板上还有未开格才可用。
|
||||
|
||||
@@ -34,7 +34,7 @@ boss_keystone = [{
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"first_kill": 3,
|
||||
"repeat_kill": 2
|
||||
"repeat_kill": 3
|
||||
}]
|
||||
workshop_items = [{
|
||||
"item_id": "probe_dust",
|
||||
@@ -101,8 +101,9 @@ conversion = [{
|
||||
; 零消费——_defender_ac 主角分支本次接线,缺参/-1 = 表值兜底);修复后甲轴读数 +1.2±0.4 为
|
||||
; 解锁后参数实测,非基线外推。战役轨迹三花费序(生命/攻击/护甲优先)随批采集验证。
|
||||
;
|
||||
; 迭代 10 奖励重构批(2026-10-01——boss_keystone 重锚,P-B 乙拍板):三关统一 首杀 3 / 复杀 2
|
||||
; (原 5/1 · 8/2 · 12/3 退役)——贴近「Boss 掉 2–3 个钥石」用户示例量级、保持 first_kill ≥ repeat_kill
|
||||
; 结构;子选项「复杀统一 2」归代拍板白名单(另一子选项 = 按关 2/2/3 递进)。钥石渠道自本批起 =
|
||||
; 迭代 10 奖励重构批(2026-10-01——boss_keystone 重锚,P-B 乙拍板):首杀 3 三关统一;
|
||||
; 复杀按关 2/2/3 递进(**用户 2026-10-01 裁「a 用备选」**——原代拍板「复杀统一 2」被推翻;
|
||||
; L3 首杀=复杀=3 系 first_kill ≥ repeat_kill 结构约束下的合法端点)。
|
||||
; 钥石渠道自本批起 =
|
||||
; Boss/精英击杀逐杀掉落(大奖励 keystone_per_level 渠道退役;精英掉 1 预留行随怪物批实装落)。
|
||||
; 产出结构变化 → 元进度标定义务重开(06-待办清单 2026-10-01 登记条)。provisional 归曲线调参批。
|
||||
@@ -121,6 +121,11 @@ static func bonus_keystone_line(payload: Dictionary) -> String:
|
||||
return "钥石 +%d · 累计 %d" % [int(payload.get("keystone_gained", 0)), int(payload.get("keystone_total", 0))]
|
||||
|
||||
|
||||
# 奖励节点三选一头部行(迭代 10 追加批——与通关三选一共享同池;node-reward.md v2)
|
||||
static func node_bonus_header() -> String:
|
||||
return "关卡奖励点\n从提升池抽三选一(与通关三选一同池同权重同上限):\n选择一件带走:"
|
||||
|
||||
|
||||
# 冒险结算屏文案(摘要五件事:打穿没有 / 通关几关 / 止步第几关 / 钥石合计 / 主种子)
|
||||
static func adventure_summary_text(summary: Dictionary) -> String:
|
||||
var verdict := "冒险失败 · 未打穿"
|
||||
|
||||
+21
-47
@@ -45,7 +45,8 @@ var _settlement_stop_pending := false # 卡⑥ 战斗胜利总结结算框挂起
|
||||
var _pending_candidates: Array = [] # 结算框挂起期的选路候选暂存(choices_presented 载荷)
|
||||
var _last_gain_amount := 0 # 最近一笔钱包入账(卡⑥ GP 增量取数面——wallet_changed gain 载荷)
|
||||
var _pending_bonus := {} # 三选一停点载荷暂存(迭代 10——战斗总结结算确认后开三选一面;续玩停点复用)
|
||||
var bonus_overlay: BonusPickOverlay # 提升三选一覆盖层(迭代 10——level-clear.md v2 选择面承载)
|
||||
var bonus_overlay: BonusPickOverlay # 提升三选一覆盖层(迭代 10——level-clear.md v2 选择面承载;追加批起奖励节点共用)
|
||||
var _bonus_context := "level_clear" # 三选一面上下文(level_clear=通关停点→推进下一关 / reward_node=奖励节点→回图选路)
|
||||
var _start_overlay: Control
|
||||
var _end_overlay: BattleEndOverlay
|
||||
var _inter_overlay: Control
|
||||
@@ -174,7 +175,6 @@ func _build_ui() -> void:
|
||||
legend_overlay.closed.connect(_on_modal_closed.bind(legend_overlay))
|
||||
review_overlay.closed.connect(_on_modal_closed.bind(review_overlay))
|
||||
node_overlay.continue_pressed.connect(_on_node_continue)
|
||||
# 迭代 7 T5——item_chosen 连接在奖励分支首唤时防重接线(上方)
|
||||
level_screen.route_chosen.connect(_on_route_chosen)
|
||||
level_screen.abandon_requested.connect(_on_abandon_requested) # 放弃入口(2026-09-21 用户拍板——台账 119)
|
||||
shop_overlay.buy_requested.connect(_on_shop_buy)
|
||||
@@ -394,10 +394,13 @@ func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
|
||||
_show_board_fx_for_node(t)
|
||||
log_view.append_bb(Narrator.node_banner(runner, node, t))
|
||||
elif t == "reward":
|
||||
_open_modal(node_overlay)
|
||||
node_overlay.item_chosen.connect(_on_reward_item_chosen) if not node_overlay.item_chosen.is_connected(_on_reward_item_chosen) else null
|
||||
node_overlay.show_reward_items(_reward_node_choices())
|
||||
log_view.append_bb(Narrator.reward_line())
|
||||
# 奖励节点三选一(迭代 10 追加批——与通关三选一共享同池同权重同上限;BonusPickOverlay 共用面,
|
||||
# 道具件满位替换子交互同构)。原「魔法道具投放渠道」三选一(迭代 7 过渡形态)随批退役。
|
||||
_bonus_context = "reward_node"
|
||||
var node_cands: Array = adventure.node_bonus_candidates()
|
||||
_open_modal(bonus_overlay)
|
||||
bonus_overlay.open_pick(Narrator.node_bonus_header(), _bonus_card_texts(node_cands))
|
||||
adventure.present_node_bonus(node_cands)
|
||||
log_view.append_bb(Narrator.reward_line())
|
||||
elif t == "rest":
|
||||
var healed: int = int(info.get("healed", 0))
|
||||
@@ -422,6 +425,7 @@ func _on_bonus_presented(payload: Dictionary) -> void:
|
||||
if int(payload.get("aux_cleared", 0)) > 0:
|
||||
board_zone.toast.show_toast("上关剩余 %d 件魔法道具已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 迭代 7 T4 清空反馈
|
||||
_pending_bonus = payload.duplicate(true)
|
||||
_bonus_context = "level_clear"
|
||||
if bool(payload.get("replace_pending", false)):
|
||||
# 续玩:替换子交互在途中断——重建三选一面后直落替换子态(候选不重抽)
|
||||
_show_bonus_pick()
|
||||
@@ -452,7 +456,10 @@ func _bonus_card_texts(candidates: Array) -> Array:
|
||||
|
||||
|
||||
func _on_bonus_pick_confirmed(item_id: String) -> void:
|
||||
if adventure == null or not adventure.pick_bonus(item_id):
|
||||
if adventure == null:
|
||||
return
|
||||
var ok: bool = adventure.apply_node_bonus(item_id) if _bonus_context == "reward_node" else adventure.pick_bonus(item_id)
|
||||
if not ok:
|
||||
push_error("[组合根] 三选一兑付被拒——见输出日志")
|
||||
|
||||
|
||||
@@ -474,8 +481,8 @@ func _open_bonus_replace(items: Array, incoming: Dictionary) -> void:
|
||||
named.size(), int(db.adventure.transitional_sync_slots))
|
||||
|
||||
|
||||
func _on_bonus_replace_chosen(discard_index: int) -> void:
|
||||
if adventure == null or not adventure.resolve_aux_replace(discard_index):
|
||||
func _on_bonus_replace_chosen(discard: int) -> void:
|
||||
if adventure == null or not adventure.resolve_bonus_replace(discard):
|
||||
push_error("[组合根] 同调位替换受理被拒——见输出日志")
|
||||
|
||||
|
||||
@@ -486,10 +493,14 @@ func _on_bonus_resolved(result: Dictionary) -> void:
|
||||
_refresh_presentations()
|
||||
|
||||
|
||||
# 继续钮:收三选一面 → 推进下一关(零自动推进——兑付后受理)
|
||||
# 继续钮:收三选一面 → 上下文分派(通关停点=推进下一关 / 奖励节点=兑付确认回图选路——零自动推进)
|
||||
func _on_bonus_continue() -> void:
|
||||
_close_modal(bonus_overlay)
|
||||
bonus_overlay.close_pick()
|
||||
if _bonus_context == "reward_node":
|
||||
if runner != null:
|
||||
runner.continue_after_node()
|
||||
return
|
||||
if _adventure_mode and adventure != null:
|
||||
if not adventure.continue_to_next_level():
|
||||
_fatal_screen("关卡推进失败——见输出日志")
|
||||
@@ -1039,43 +1050,6 @@ func _aux_unlocked_set() -> Dictionary:
|
||||
return out
|
||||
|
||||
|
||||
|
||||
# 摇取面:活行 ∩ 工坊解锁集(拍板③甲——解锁进投放池同源口径);池 ≤3 全列(当前三件=选一即全列),
|
||||
# 池扩充后加权摇三(确定性摇取:读 seed 流外——呈现侧零 RNG 消费纪律 → 全列前 3 按注册序)。
|
||||
func _reward_node_choices() -> Array:
|
||||
if runner == null or runner.aux_inventory == null:
|
||||
return []
|
||||
var out: Array = []
|
||||
for row in db.aux_active_rows():
|
||||
var iid := String(row.get("item_id", ""))
|
||||
if not _aux_unlocked.has(iid):
|
||||
continue # 未解锁不入奖励池(与掉落投放同门控)
|
||||
out.append({
|
||||
"item_id": iid,
|
||||
"display_name": String(row.get("display_name", "")),
|
||||
"glyph": String(row.get("display_name", "?")).left(1),
|
||||
})
|
||||
if out.size() >= 3:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# 选择兑付:入关卡域库存(来源归因 reward_node)→ 收屏 → 兑付确认回选路(零自动推进同款)
|
||||
func _on_reward_item_chosen(item_id: String) -> void:
|
||||
if runner == null or runner.aux_inventory == null:
|
||||
return
|
||||
var rec: Dictionary = runner.grant_aux_item(item_id, "reward_node")
|
||||
if rec.is_empty():
|
||||
# 同调位满位拒绝(迭代 10 过渡承载——奖励节点渠道满位不授予;替换交互归三选一渠道,
|
||||
# 全渠道统一收口随同调实施批)。覆盖层保持开、零入账零关闭(玩家可改选继续/离开)
|
||||
board_zone.toast.show_toast("同调位已满——「%s」未获取" % String(db.aux_item_row(item_id).get("display_name", item_id)), "warn")
|
||||
return
|
||||
node_overlay.close_reward()
|
||||
_refresh_aux_slots()
|
||||
board_zone.toast.show_toast("获得 %s(入关卡背包)" % String(db.aux_item_row(item_id).get("display_name", item_id)), "info")
|
||||
_on_node_continue()
|
||||
|
||||
|
||||
# 槽点击(第一步):AP 类(target=none)点击即用(无目标);空间类进/出选中态(toggle 取消)。
|
||||
func _on_aux_slot_clicked(slot_handle: int) -> void:
|
||||
if settlement == null or runner == null or runner.aux_inventory == null:
|
||||
|
||||
@@ -127,9 +127,9 @@ func open_replace(items: Array, incoming_name: String, holding: int, slots: int)
|
||||
for c in _replace_box.get_children():
|
||||
c.queue_free()
|
||||
for it in items:
|
||||
var idx := int(it.get("index", -1))
|
||||
var tag: int = int(it.get("index", it.get("handle", -1))) # 通关渠道=携带序号 / 节点渠道=库存句柄(核心分发面裁决)
|
||||
var btn := UiKit.button("丢弃「%s」换新" % String(it.get("display_name", it.get("item_id", ""))))
|
||||
btn.pressed.connect(func(): replace_chosen.emit(idx))
|
||||
btn.pressed.connect(func(): replace_chosen.emit(tag))
|
||||
_replace_box.add_child(btn)
|
||||
var decline := UiKit.button("放弃新件(不获取)")
|
||||
decline.pressed.connect(func(): replace_chosen.emit(-1))
|
||||
|
||||
@@ -6,11 +6,9 @@ class_name NodeOverlay
|
||||
extends Control
|
||||
|
||||
signal continue_pressed
|
||||
signal item_chosen(item_id: String) # 迭代 7 T5——奖励节点三选一兑付(拍板②丙:奖励屏=小构筑点)
|
||||
|
||||
var _label: Label
|
||||
var _btn: Button
|
||||
var _choice_box: HBoxContainer # 道具三选一按钮区(show_reward_items 时呈现)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -30,56 +28,16 @@ func _init() -> void:
|
||||
box.add_child(_label)
|
||||
_btn = UiKit.button("继续")
|
||||
_btn.pressed.connect(_emit_continue_pressed)
|
||||
_choice_box = HBoxContainer.new()
|
||||
_choice_box.add_theme_constant_override("separation", 12)
|
||||
_choice_box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
box.add_child(_choice_box)
|
||||
box.add_child(_btn)
|
||||
visible = false
|
||||
|
||||
|
||||
# 奖励节点兑付(迭代 7 T5——「节点奖励=功能道具正式渠道」关卡节点地图.md:110 定稿兑现;
|
||||
# 拍板②丙=三选一呈现:池 ≤3 全列(当前有效道具三件——选一即全列),池扩充后加权摇三)。
|
||||
# choices = [{item_id, display_name, glyph}](组合根组装——视图纯呈现,选择经信号外发)。
|
||||
# 选择前「继续」隐藏(强制选择);选择即入库存+屏收。
|
||||
func show_reward_items(choices: Array) -> void:
|
||||
for c in _choice_box.get_children():
|
||||
c.queue_free()
|
||||
if choices.is_empty():
|
||||
# 池空(全未解锁/表空合法态)——直通节点确认(切片期合法空态语义保留)
|
||||
_label.text = "关卡奖励点
|
||||
(此行无道具可取——继续前行)"
|
||||
_btn.text = "继续"
|
||||
_btn.visible = true
|
||||
_choice_box.visible = false
|
||||
visible = true
|
||||
return
|
||||
_label.text = "关卡奖励点
|
||||
取一件魔法道具(加入关卡背包——关卡结束清空)"
|
||||
for ch in choices:
|
||||
var cid := String(ch.get("item_id", ""))
|
||||
var btn := UiKit.button("%s %s" % [String(ch.get("glyph", "?")), String(ch.get("display_name", ""))])
|
||||
btn.custom_minimum_size = Vector2(150, 72)
|
||||
btn.pressed.connect(func(): item_chosen.emit(cid))
|
||||
_choice_box.add_child(btn)
|
||||
_btn.visible = false # 选择前不给出口(强制三选一——构筑点语义)
|
||||
_choice_box.visible = true
|
||||
visible = true
|
||||
|
||||
|
||||
func show_rest(healed: int, hp_after: int, max_hp: int) -> void:
|
||||
_label.text = "血量恢复点\n休息回复 %d HP(%d → %d / %d)" % [
|
||||
healed, hp_after - healed, hp_after, max_hp]
|
||||
_btn.text = "休息完毕,继续"
|
||||
visible = true
|
||||
|
||||
|
||||
# 选择完成收屏(组合根在 item_chosen 处理毕调——入库存+确认后回选路由同拍)
|
||||
func close_reward() -> void:
|
||||
_choice_box.visible = false
|
||||
_btn.visible = true
|
||||
visible = false
|
||||
|
||||
# 探针复用:以真实按钮路径触发继续
|
||||
func press_continue() -> void:
|
||||
_btn.pressed.emit()
|
||||
|
||||
@@ -172,7 +172,19 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
|
||||
st.boss_depth = int(node.get("node_depth", -1))
|
||||
st.hp_at_boss = int(adr.ledger["hero_hp"])
|
||||
elif t == "reward":
|
||||
r.continue_after_node() # 奖励点 = 内容待拍板直通节点(格面资源批 2026-09-15)
|
||||
# 奖励节点三选一(迭代 10 追加批——共享池确定性策略:候选取首件、替换放弃新件;
|
||||
# 兑付后 continue_after_node 回图选路)
|
||||
var ncands: Array = adr.node_bonus_candidates()
|
||||
st.reward_events.append({"node_bonus_pick": ncands.duplicate(true)})
|
||||
if ncands.is_empty():
|
||||
r.continue_after_node() # 牌库空合法态直通(结构上不可达——gp/道具件无上限,防御保留)
|
||||
else:
|
||||
adr.present_node_bonus(ncands)
|
||||
if adr.apply_node_bonus(String(ncands[0])):
|
||||
if not adr._node_bonus_pending.is_empty() and adr._node_bonus_pending.has("incoming"):
|
||||
adr.resolve_bonus_replace(-1) # 满位替换:放弃新件
|
||||
else:
|
||||
st.result = "advance_failed"
|
||||
elif t == "rest":
|
||||
st.rest_healed = int(info.get("healed", 0))
|
||||
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
|
||||
@@ -201,17 +213,22 @@ static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> voi
|
||||
st.result = "advance_failed"
|
||||
|
||||
|
||||
# 三选一兑付完成 → 推进下一关(自动路径无停点等待——零自动推进契约由显式确认满足)
|
||||
static func _on_adventure_bonus_resolved(result: Dictionary, st: Dictionary, adr) -> void:
|
||||
st.reward_events.append({"bonus_resolved": result.duplicate(true)})
|
||||
if not adr.continue_to_next_level():
|
||||
# 同调位满位替换子交互(确定性策略:放弃新件不获取——分发面按停点上下文裁决节点/通关渠道)
|
||||
static func _on_adventure_aux_replace(items: Array, incoming: Dictionary, st: Dictionary, adr) -> void:
|
||||
st.reward_events.append({"aux_replace": incoming.duplicate(true)})
|
||||
if not adr.resolve_bonus_replace(-1):
|
||||
st.result = "advance_failed"
|
||||
|
||||
|
||||
# 同调位满位替换子交互(确定性策略:放弃新件不获取——-1)
|
||||
static func _on_adventure_aux_replace(items: Array, incoming: Dictionary, st: Dictionary, adr) -> void:
|
||||
st.reward_events.append({"aux_replace": incoming.duplicate(true)})
|
||||
if not adr.resolve_aux_replace(-1):
|
||||
# 三选一兑付完成 → 上下文分派(通关停点=推进下一关 / 奖励节点=回图选路)
|
||||
static func _on_adventure_bonus_resolved(result: Dictionary, st: Dictionary, adr) -> void:
|
||||
st.reward_events.append({"bonus_resolved": result.duplicate(true)})
|
||||
if String(result.get("context", "")) == "reward_node":
|
||||
var r = adr.runner()
|
||||
if r == null or not r.continue_after_node():
|
||||
st.result = "advance_failed"
|
||||
return
|
||||
if not adr.continue_to_next_level():
|
||||
st.result = "advance_failed"
|
||||
|
||||
|
||||
|
||||
+59
-8
@@ -305,17 +305,30 @@ func _smoke_adventure() -> void:
|
||||
check(int(sum.get("master_seed", -1)) == 42, "摘要携带主种子(42)")
|
||||
var picks: Array = []
|
||||
var resolves: Array = []
|
||||
var node_picks := 0
|
||||
for ev in (st["reward_events"] as Array):
|
||||
if (ev as Dictionary).has("bonus_pick"):
|
||||
picks.append((ev as Dictionary)["bonus_pick"])
|
||||
elif (ev as Dictionary).has("node_bonus_pick"):
|
||||
node_picks += 1 # 奖励节点三选一(追加批——每关奖励节点各 1 次)
|
||||
elif (ev as Dictionary).has("bonus_resolved"):
|
||||
resolves.append((ev as Dictionary)["bonus_resolved"])
|
||||
check(picks.size() == 2 and resolves.size() == 2,
|
||||
"三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发,奖励只朝下一战生效)")
|
||||
check(picks.size() == 2 and resolves.size() == 2 + node_picks,
|
||||
"通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)")
|
||||
check(node_picks >= 1, "奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks)
|
||||
var node_resolves := 0
|
||||
for r0 in resolves:
|
||||
if String((r0 as Dictionary).get("context", "")) == "reward_node":
|
||||
node_resolves += 1
|
||||
check(node_resolves == node_picks, "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [node_resolves, node_picks])
|
||||
var level_resolves: Array = []
|
||||
for r0 in resolves:
|
||||
if String((r0 as Dictionary).get("context", "")) != "reward_node":
|
||||
level_resolves.append(r0)
|
||||
var heal_ok := picks.size() == 2
|
||||
var cand_ok := picks.size() == 2
|
||||
var ks_gain_ok := picks.size() == 2
|
||||
var resolve_ok := resolves.size() == 2
|
||||
var resolve_ok := level_resolves.size() == 2
|
||||
var max_flat := int(_db.adventure.bonus_max_hp_flat)
|
||||
var heal_pct := int(_db.adventure.clear_heal_pct)
|
||||
var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导)
|
||||
@@ -342,7 +355,7 @@ func _smoke_adventure() -> void:
|
||||
or int(p.get("keystone_total", -1)) != ks_prefix:
|
||||
ks_gain_ok = false
|
||||
if resolve_ok:
|
||||
var r: Dictionary = resolves[i]
|
||||
var r: Dictionary = level_resolves[i]
|
||||
if not (p.get("candidates", []) as Array).has(String(r.get("picked", ""))) \
|
||||
or int(r.get("value", -1)) != int(_db.adventure.bonus_value(String(r.get("picked", "")))):
|
||||
resolve_ok = false
|
||||
@@ -356,14 +369,14 @@ func _smoke_adventure() -> void:
|
||||
var k := 5.0 # hp+400 ⇒ TunePanel 百分比后乘 ×5(加算先加、百分比后乘——生效次序口径)
|
||||
var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k)))
|
||||
var valid_hp := {}
|
||||
for acc in 3:
|
||||
for acc in 4:
|
||||
valid_hp[maxi(1, int(round(float(int(_db.hero.max_hp) + acc * max_flat) * k)))] = true
|
||||
var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base
|
||||
for v in (seq as Array):
|
||||
if not valid_hp.has(int(v)):
|
||||
seq_ok = false
|
||||
check(seq_ok,
|
||||
"各战上限入参 ∈ {基础, 基础+件值, 基础+2×件值}(覆盖值通道传播——首战=基础表值 %d;AP 件不加上限)" % exp_base)
|
||||
"各战上限入参 ∈ {基础..基础+3×件值}(覆盖值通道——通关+奖励节点双渠道共享上限 3;首战=基础 %d)" % exp_base)
|
||||
# ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致)
|
||||
var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
|
||||
check(st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq
|
||||
@@ -533,6 +546,19 @@ func _smoke_adventure() -> void:
|
||||
a12._on_level_finished({"result": "victory", "hp": max_full, "node_index": 1, "node_id": 1, "node_type": "shop"})
|
||||
check(int(cap["payload"].get("heal_gained", -1)) == 0,
|
||||
"满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)")
|
||||
# ⑬ 奖励节点共享池(迭代 10 追加批——同 _draw 同上限同计数;GP 件走同一入账收口)
|
||||
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
|
||||
check(a13.start_adventure(13, {"first_script": true, "aux_unlocked": {"blast_kit": true}}),
|
||||
"节点共享池场景:冒险启动(seed 13)")
|
||||
a13._bonus_taken["attack"] = int(_db.adventure.bonus_cap_attack)
|
||||
var ndraw: Array = a13.node_bonus_candidates()
|
||||
check(not (ndraw as Array).has("attack"),
|
||||
"奖励节点共享池:达上限件同样剔牌库(同 _draw 同上限——%s)" % str(ndraw))
|
||||
a13.present_node_bonus(["gp"])
|
||||
var gp13 := int(a13.ledger.get("gp", 0))
|
||||
check(a13.apply_node_bonus("gp") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.bonus_gp_amount),
|
||||
"奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.bonus_gp_amount))
|
||||
check(a13._node_bonus_pending.is_empty(), "节点停点态兑付后清零(恰一次语义同构)")
|
||||
var s10: Dictionary = a10.summary
|
||||
check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)),
|
||||
"放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)")
|
||||
@@ -562,7 +588,14 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
|
||||
r.pick_next_node(int(ids[0]))
|
||||
continue
|
||||
var t := String(r.current_node().get("type", ""))
|
||||
if t == "reward" or t == "rest":
|
||||
if t == "reward":
|
||||
# 奖励节点三选一(迭代 10 追加批——候选取首件、替换放弃新件、兑付确认回图)
|
||||
var ncands: Array = a.node_bonus_candidates()
|
||||
if not ncands.is_empty() and a.apply_node_bonus(String(ncands[0])):
|
||||
if not a._node_bonus_pending.is_empty() and a._node_bonus_pending.has("incoming"):
|
||||
a.resolve_bonus_replace(-1)
|
||||
r.continue_after_node()
|
||||
elif t == "rest":
|
||||
r.continue_after_node()
|
||||
return false
|
||||
|
||||
@@ -3632,7 +3665,8 @@ func _smoke_save() -> void:
|
||||
"user://smoke_save_spend.json", "user://smoke_save_highver.json",
|
||||
"user://smoke_save_corrupt.json", "user://smoke_save_migrate.json",
|
||||
"user://smoke_save_domain.json", "user://smoke_save_cap.json",
|
||||
"user://smoke_save_integration.json", "user://smoke_save_breakpoint.json"]
|
||||
"user://smoke_save_integration.json", "user://smoke_save_breakpoint.json",
|
||||
"user://smoke_save_v2compat.json"]
|
||||
var stale: Array = []
|
||||
for p in paths:
|
||||
stale.append(p)
|
||||
@@ -3816,6 +3850,23 @@ func _smoke_save() -> void:
|
||||
check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 3,
|
||||
"v1 旧档迁移后写盘 version == 3")
|
||||
|
||||
# ⑫ v2 旧档→v3 兼容(迭代 10 g 裁——semver 向后兼容:旧断点零丢失、新代码可续玩)
|
||||
var fv2 = sm_script.new(paths[10])
|
||||
var fv2f := FileAccess.open(paths[10], FileAccess.WRITE)
|
||||
fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7},
|
||||
"breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
|
||||
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
|
||||
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
|
||||
"visited_ids": []}}))
|
||||
fv2f.close()
|
||||
check(fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7,
|
||||
"v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)")
|
||||
check(int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777,
|
||||
"v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)")
|
||||
var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777))
|
||||
check(adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}),
|
||||
"v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)")
|
||||
|
||||
# 段尾清理(测试文件绝不残留)
|
||||
for p in stale:
|
||||
if FileAccess.file_exists(p):
|
||||
|
||||
@@ -1,41 +1,75 @@
|
||||
extends SceneTree
|
||||
# 奖励节点三选一 UI 直验套件(迭代 7 T5-hotfix 转正——奖励节点 UI 无探针路径触达的盲区补漏:
|
||||
# show_reward_items 函数体曾在合并中丢失而全闸绿灯实机炸——「功能存在 ≠ 功能可达」的测试面教训)。
|
||||
# 三选一覆盖层 UI 直验套件(迭代 7 T5-hotfix 转正;迭代 10 追加批随面迁移改写)——
|
||||
# 原守 node_overlay.show_reward_items;奖励节点三选一与通关三选一合并共用 BonusPickOverlay 后,
|
||||
# 本套件改守共用面的「功能存在 ≠ 功能可达」盲区:选择外发/强制确认两段式/替换子面板两路/结果态。
|
||||
# 运行:godot --headless --path game --script res://tests/unit/presentation/reward_overlay_test.gd(expect 0)
|
||||
# 覆盖:池空直通态 / 全列呈现按钮数 / 强制选择(继续隐藏)/ item_chosen 信号外发。
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
var ov = load("res://presentation/views/node_overlay.gd").new()
|
||||
var ov = load("res://presentation/views/bonus_pick_overlay.gd").new()
|
||||
root.add_child(ov)
|
||||
var called := []
|
||||
ov.item_chosen.connect(func(id: String): called.append(id))
|
||||
ov.show_reward_items([])
|
||||
if ov.visible != true or ov._btn.visible != true:
|
||||
print("[reward_verify] FAIL — 池空态应为直通确认(visible/继续可见)")
|
||||
quit(1)
|
||||
return
|
||||
ov.close_reward()
|
||||
ov.show_reward_items([
|
||||
{"item_id": "sapper_order", "display_name": "工兵令", "glyph": "工"},
|
||||
{"item_id": "nest_pendulum", "display_name": "探巢摆钟", "glyph": "摆"},
|
||||
var picked := []
|
||||
var replaced := []
|
||||
var continued := []
|
||||
ov.pick_confirmed.connect(func(id: String): picked.append(id))
|
||||
ov.replace_chosen.connect(func(tag: int): replaced.append(tag))
|
||||
ov.continue_pressed.connect(func(): continued.append(true))
|
||||
# ① 选择态:三卡呈现、确认钮隐藏(两段式——点选前不给确认)
|
||||
ov.open_pick("第 1 层通过!", [
|
||||
{"id": "attack", "text": "攻击力提升\n攻击 +1"},
|
||||
{"id": "gp", "text": "金币袋\n金币 +20"},
|
||||
{"id": "ap", "text": "行动点提升\n每战首轮 AP +1"},
|
||||
])
|
||||
var btns := 0
|
||||
for c in ov._choice_box.get_children():
|
||||
if c is Button:
|
||||
btns += 1
|
||||
if btns != 2 or ov._btn.visible:
|
||||
print("[reward_verify] FAIL — 两件应呈现两按钮且继续隐藏(btns=%d 继续可见=%s)" % [btns, str(ov._btn.visible)])
|
||||
if ov.visible != true or btns != 3 or ov._confirm_btn.visible or ov._continue_btn.visible:
|
||||
print("[reward_verify] FAIL — 选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns)
|
||||
quit(1)
|
||||
return
|
||||
for c in ov._choice_box.get_children():
|
||||
# ② 点选→高亮→确认外发(真实按钮路径);确认前改选以最后点选为准
|
||||
ov.press_card(0)
|
||||
ov.press_card(1)
|
||||
ov.press_confirm()
|
||||
if ov._confirm_btn.visible != true or picked.size() != 1 or String(picked[0]) != "gp":
|
||||
print("[reward_verify] FAIL — 点选确认未外发 pick_confirmed(gp)(%s)" % str(picked))
|
||||
quit(1)
|
||||
return
|
||||
# ③ 替换子面板:丢旧收新与放弃新件两路外发(通关渠道=携带序号 / 节点渠道=库存句柄——tag 直通)
|
||||
ov.open_replace([{"index": 0, "item_id": "probe_dust", "display_name": "显影尘"}], "爆破工兵包", 1, 1)
|
||||
var replace_btns := 0
|
||||
for c in ov._replace_box.get_children():
|
||||
if c is Button:
|
||||
replace_btns += 1
|
||||
if replace_btns != 2 or ov._choice_box.visible:
|
||||
print("[reward_verify] FAIL — 替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns)
|
||||
quit(1)
|
||||
return
|
||||
for c in ov._replace_box.get_children():
|
||||
if c is Button:
|
||||
c.pressed.emit()
|
||||
break
|
||||
if called.size() != 1 or String(called[0]) != "sapper_order":
|
||||
print("[reward_verify] FAIL — 选择未外发 item_chosen(%s)" % str(called))
|
||||
if replaced.size() != 1 or int(replaced[0]) != 0:
|
||||
print("[reward_verify] FAIL — 丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced))
|
||||
quit(1)
|
||||
return
|
||||
print("[reward_verify] PASS — 池空直通/全列呈现/强制选择/选择外发 全过")
|
||||
# ④ 结果态:继续钮点亮、唯一出口
|
||||
ov.show_result("已选择:攻击力提升 · 攻击 +1")
|
||||
if ov._continue_btn.visible != true or ov._replace_box.visible:
|
||||
print("[reward_verify] FAIL — 结果态应点亮继续且收替换面板")
|
||||
quit(1)
|
||||
return
|
||||
ov.press_continue()
|
||||
if continued.size() != 1:
|
||||
print("[reward_verify] FAIL — 继续未外发 continue_pressed")
|
||||
quit(1)
|
||||
return
|
||||
ov.close_pick()
|
||||
if ov.visible:
|
||||
print("[reward_verify] FAIL — close_pick 应收屏")
|
||||
quit(1)
|
||||
return
|
||||
print("[reward_verify] PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过")
|
||||
quit(0)
|
||||
Reference in new issue
Block a user