feat: 三选一刷新对齐商店(2026-10-06 用户令)——免费双源 X/Y 用尽后 GP 付费档(同 shop_refresh_* 旋钮/同冒险钱包 spend_gp 单口/同 reward_pick 池重抽),付费刷计数每停点清零;CurioPickOverlay.set_refresh_display 与商店同构(钮常驻选择态/两源皆穷禁用/角标免费余量/价签行 GP 价);通关停点刷新重发带 refreshed 标直落选择面不再重开胜利总结;店内门票专长三选一维持无刷新;单测 curio_refresh_paid_test+覆盖层刷新面断言;落文=秘宝.md 双源节 GP 付费档+冒险结构 v0.16+06 商店刷新条目换代(机制已落码态)

This commit is contained in:
DKChan committed 2026-10-06 07:03:57 +08:00
1 parent ced5fb75c3
commit 12807298eb
11 files changed
+185 -30

No files matched your search

+33 -7
View File
@@ -75,6 +75,7 @@ var _treasure_unlocked := {} # aux 解锁门控集(迭代 7
var _attunement: Array = [] # 同调装配集(迭代 11 第①批 T5——params.attunement 接收面,start_level 外下传 LevelRunner)
var _attune_refresh_x := 0 # 刷新件余量(迭代 11 第①批 T5b P1 甲——每碰三选一重置=X 表值;优先消耗、不结转)
var _attune_refresh_y := 0 # Y 冒险周期池(开局参数面注入,用完即无——营地升级行表值)
var _curio_paid_refreshes := 0 # 本次停点已付费刷新次数(2026-10-06 用户令对齐商店 MC-1——GP 刷新价曲线输入,随停点创建清零=与商店进店清零同构)
var attune_charges := {} # 同调主动件冒险充能账本(迭代 11 第①批 T6——跨关卡共享 dict;settlement use_treasure 扣减直改同引用;断点随 attunement_charges 键入档)
var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省)
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
@@ -569,6 +570,7 @@ func node_curio_candidates() -> Dictionary:
func present_node_curio(candidates: Array, treasure_detail: Array = []) -> void:
_reset_refresh_x() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转)
_curio_paid_refreshes = 0 # 付费刷计数随停点清零(对齐商店进店清零——2026-10-06)
_node_curio_pending = {"candidates": candidates.duplicate(true), "aux_detail": treasure_detail.duplicate(true)}
@@ -667,30 +669,53 @@ func node_curio_pending_state() -> Dictionary:
}
# 三选一刷新(组合根唯一调用口;无停点/无余量 false 零副作用)。通关停点重抽后重发
# 三选一刷新(免费位 X/Y——组合根唯一调用口;无停点/无余量 false 零副作用)。通关停点重抽后重发
# curio_choice_presented(呈现层重建卡片+刷新余量);节点停点由组合根读 node_curio_pending_state 重 present。
func refresh_curio() -> bool:
if _attune_refresh_x <= 0 and _attune_refresh_y <= 0:
return _refresh_curio_common(false)
# 三选一 GP 付费刷新(2026-10-06 用户令对齐商店 MC-1——免费位 X/Y 用尽后转付费,同旋钮同曲线同钱包;
# 组合根先经 curio_stop_pending+钱包支出再调,本口只做计数与重抽)。池规则全保留——不放回/达上限剔牌/掷定前置直显。
func refresh_curio_paid() -> bool:
return _refresh_curio_common(true)
# 本次 GP 付费刷新价(与商店货架刷新同源:ceil(基价 × 曲线^本次停点已付费刷)——读同两个调参旋钮,单点调参)
func curio_refresh_gp_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(_curio_paid_refreshes))))
# 三选一停点在途谓词(付费路径扣费前置校验——无停点禁扣钱,防空转烧钱包)
func curio_stop_pending() -> bool:
return not _curio_pending.is_empty() or not _node_curio_pending.is_empty()
func _refresh_curio_common(paid: bool) -> bool:
if not curio_stop_pending():
return false
if not _curio_pending.is_empty():
if paid:
_curio_paid_refreshes += 1
else:
if _attune_refresh_x <= 0 and _attune_refresh_y <= 0:
return false
if _attune_refresh_x > 0:
_attune_refresh_x -= 1
else:
_attune_refresh_y -= 1
if not _curio_pending.is_empty():
var cands: Array = _draw_curio_candidates("level_clear")
_curio_pending["candidates"] = cands.duplicate(true)
_curio_pending["aux_detail"] = _last_draw_treasure.duplicate()
var payload: Dictionary = (_curio_pending.get("payload", {}) as Dictionary).duplicate(true)
payload["candidates"] = cands
payload["aux_detail"] = _last_draw_treasure.duplicate()
payload["refreshed"] = true # 重发载荷带标——呈现层直落选择面,不再重开胜利总结面(2026-10-06)
curio_choice_presented.emit(payload) # 同信号重 present(零自动推进契约不破——停点仍在途)
_snapshot_breakpoint()
return true
if not _node_curio_pending.is_empty():
if _attune_refresh_x > 0:
_attune_refresh_x -= 1
else:
_attune_refresh_y -= 1
var cands2: Array = _draw_curio_candidates("reward_node")
_node_curio_pending["candidates"] = cands2.duplicate(true)
_node_curio_pending["aux_detail"] = _last_draw_treasure.duplicate()
@@ -954,6 +979,7 @@ func _on_level_finished(payload: Dictionary) -> void:
# 置位先于 emit(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位)
_pending_next_level = true
_reset_refresh_x() # 迭代 11 第①批 T5b——碰三选一重置 X 余量(不结转)
_curio_paid_refreshes = 0 # 付费刷计数随停点清零(对齐商店进店清零——2026-10-06)
var pick_payload: Dictionary = {
"level_index": level_index,
"candidates": _draw_curio_candidates("level_clear"), # T4 渠道白名单——关底 Boss 击败=通关渠道只出专长
+8
View File
@@ -865,6 +865,14 @@ func shop_refresh() -> bool:
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 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作)
+29 -8
View File
@@ -112,8 +112,9 @@ func on_presented(payload: Dictionary) -> void:
toast.show_toast("上关剩余 %d 件秘宝已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 迭代 7 T4 清空反馈
_pending_curio = payload.duplicate(true)
_curio_context = "level_clear"
if bool(payload.get("replace_pending", false)):
if bool(payload.get("replace_pending", false)) or bool(payload.get("refreshed", false)):
# 续玩:替换子交互在途中断——重建三选一面后直落替换子态(候选不重抽)
# 刷新重发(2026-10-06):refreshed 标直落选择面——不再重开胜利总结面
show_pick()
return
end_overlay.open_victory(Narrator.battle_end_text("victory", false), "继续",
@@ -142,7 +143,7 @@ func present_node_curio() -> void:
_open_modal.call(curio_overlay)
curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(node_cands, node_draw.get("aux_detail", []) as Array))
adventure.present_node_curio(node_cands, node_draw.get("aux_detail", []) as Array)
curio_overlay.set_refresh_info(adventure.attune_refresh_left()) # 迭代 11 第①批 T5b——刷新余量投喂
_feed_refresh_display(adventure) # 迭代 11 第①批 T5b + 2026-10-06 对齐商店——刷新面投喂单点
# 三选一面开出(战斗总结结算确认 / 续玩停点恢复;替换在途由 treasure_replace_presented 续发直落子态)
@@ -151,10 +152,19 @@ func show_pick() -> void:
_open_modal.call(curio_overlay)
curio_overlay.open_pick(Narrator.curio_pick_header(payload), _curio_card_texts(payload.get("candidates", []) as Array, payload.get("aux_detail", []) as Array))
var adventure = _adventure_get.call()
curio_overlay.set_refresh_info(adventure.attune_refresh_left() if adventure != null else 0) # 迭代 11 第①批 T5b——刷新余量投喂
if adventure != null:
_feed_refresh_display(adventure) # 迭代 11 第①批 T5b + 2026-10-06 对齐商店——刷新面投喂单点
_refresh_presentations.call()
# 刷新呈现投喂单点(免费余量 X/Y + GP 付费价 + 冒险钱包可付——读数与商店 set_refresh_display 同构)
func _feed_refresh_display(adventure) -> void:
var runner = _runner_get.call()
var gp := int(runner.gp) if runner != null else 0
var cost: int = adventure.curio_refresh_gp_cost()
curio_overlay.set_refresh_display(adventure.attune_refresh_left(), cost, gp >= cost)
func _curio_card_texts(candidates: Array, treasure_detail: Array = []) -> Array:
# TP0 令牌池——候选 = {"kind","tier"} 令牌字典;卡片 id = "kind|tier|feat_id" 复合键(同 kind
# 大小两档/同档多 relic 可同屏,kind 单键不构成唯一身份),确认回程拆键(on_pick_confirmed)
@@ -204,18 +214,29 @@ func _treasure_card_line(iid: String) -> String:
return "%s · %s" % [String(row.get("display_name", iid)), " ".join(parts)]
# 三选一刷新(迭代 11 第①批 T5b——P1 甲 X/Y 双源;通关停点经重发 curio_choice_presented 自然重建,
# 节点停点在此读在途态重 present——零自动推进契约不破,停点仍在途)
# 三选一刷新(迭代 11 第①批 T5b X/Y 双源 + 2026-10-06 用户令对齐商店 MC-1——免费位用尽转 GP 付费档:
# 同 shop_refresh_cost_base/curve 旋钮、同冒险钱包 runner.gp;通关停点经重发 curio_choice_presented 直落
# 选择面,节点停点在此读在途态重 present——零自动推进契约不破,停点仍在途)
func on_refresh() -> void:
var adventure = _adventure_get.call()
if adventure == null:
if adventure == null or not adventure.curio_stop_pending():
return
if not adventure.refresh_curio():
var runner = _runner_get.call()
var refreshed: bool = adventure.refresh_curio()
if not refreshed:
# 免费位(X/Y)已穷——GP 付费档(先扣钱包后重抽,余额不足拒绝零副作用)
var cost: int = adventure.curio_refresh_gp_cost()
if runner == null or not runner.spend_gp(cost):
toast.show_toast("金币不足——刷新失败", "warn")
return
refreshed = adventure.refresh_curio_paid()
_refresh_presentations.call() # 钱包变化上屏(对齐商店购买后刷新面)
if not refreshed:
return
if _curio_context == "reward_node":
var st: Dictionary = adventure.node_curio_pending_state()
curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st.get("candidates", []) as Array, st.get("aux_detail", []) as Array))
curio_overlay.set_refresh_info(adventure.attune_refresh_left())
_feed_refresh_display(adventure)
func on_pick_confirmed(item_id: String) -> void:
+21 -8
View File
@@ -20,7 +20,8 @@ var _continue_btn: Button # 继续钮(兑付反馈态唯一
var _refresh_btn: Button # 刷新钮(迭代 11 第①批 T5b——余量>0 才显示;M7 升顶条图标钮)
var _refresh_badge: Label # M7 余量角标(图标钮右上——demo ⑥ 同构)
var _refresh_l: Label # 刷新余量读数(X 优先 + Y 冒险池)
var _refresh_allowance := 0 # 组合根投喂余量(本视图零核心引用)
var _refresh_allowance := 0 # 组合根投喂免费余量(X/Y——本视图零核心引用)
var _refresh_gp_cost := 0 # 组合根投喂 GP 刷新价(免费位用尽后的付费档读数)
var _selected := "" # 点选暂存(确认前可改选)
var _header := "" # 停点头部文案(选择态保持显示)
var _card_meta: Array = [] # open_pick 缓存 [{id, text}]——高亮还原读面
@@ -105,18 +106,22 @@ func open_pick(header: String, cards: Array) -> void:
_confirm_btn.visible = false
_replace_box.visible = false
_continue_btn.visible = false
_refresh_btn.visible = false # 刷新余量随 set_refresh_info 投喂后现形(open 先归零防残留)
_refresh_btn.visible = false # 刷新面随 set_refresh_display 投喂后现形(open 先归零防残留;店内门票面不投喂=无刷新)
_refresh_l.text = ""
visible = true
# 三选一刷新余量投喂(组合根读 adventure.attune_refresh_left()——0 = 刷新面隐藏)
func set_refresh_info(allowance: int) -> void:
_refresh_allowance = maxi(0, int(allowance))
_refresh_btn.visible = _refresh_allowance > 0
# 三选一刷新呈现投喂(2026-10-06 用户令对齐商店 MC-1 同构——free_left=X/Y 免费位余量〔基础 0〕,
# 免费位用尽转 GP 付费档:gp_cost=本次 GP 刷新价、can_afford=冒险钱包可付;钮常驻选择态,两源皆穷=禁用不隐藏)
func set_refresh_display(free_left: int, gp_cost: int, can_afford: bool) -> void:
_refresh_allowance = maxi(0, int(free_left))
_refresh_gp_cost = maxi(0, int(gp_cost))
_refresh_btn.visible = _refresh_allowance > 0 or _refresh_gp_cost > 0
_refresh_badge.text = str(_refresh_allowance)
_refresh_l.text = "免费 · 不消耗 AP" if _refresh_allowance > 0 else ""
_refresh_l.visible = _refresh_allowance > 0
_refresh_l.text = "免费 · 不消耗 AP" if _refresh_allowance > 0 else ("刷新 %d 金币" % _refresh_gp_cost if _refresh_gp_cost > 0 else "")
_refresh_l.visible = _refresh_l.text != ""
_refresh_btn.tooltip_text = "刷新候选——换一批战利品\n免费 %d 次(刷新件 X 优先 + 冒险池 Y)\nGP 刷新 %d 金币\n池源=共享池,与商店货架同源" % [_refresh_allowance, _refresh_gp_cost]
_refresh_btn.disabled = _refresh_allowance <= 0 and not can_afford
func _emit_refresh_requested() -> void:
@@ -152,6 +157,11 @@ func press_continue() -> void:
_continue_btn.pressed.emit()
# 探针/测试复用:以真实按钮路径触发刷新意图(信号外发——受理归组合根)
func press_refresh() -> void:
_refresh_btn.pressed.emit()
func selected_id() -> String:
return _selected
@@ -163,6 +173,7 @@ func show_result(text: String) -> void:
_confirm_btn.visible = false
_replace_box.visible = false
_continue_btn.visible = true
_refresh_btn.visible = false # 刷新=选择态专属功能(结果态停点已兑付——残钮零意图可点)
visible = true
@@ -184,6 +195,7 @@ func open_replace(items: Array, incoming_name: String, holding: int, slots: int)
_confirm_btn.visible = false
_replace_box.visible = true
_continue_btn.visible = false
_refresh_btn.visible = false # 刷新=选择态专属功能(替换子态不换候选)
visible = true
@@ -193,6 +205,7 @@ func close_pick() -> void:
_replace_box.visible = false
_continue_btn.visible = false
_confirm_btn.visible = false
_refresh_btn.visible = false
visible = false
+1
View File
@@ -34,6 +34,7 @@ func _suites() -> Array:
{"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0},
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 迭代 7 T7——秘宝结构冻结断言面
{"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 迭代 8 卡①/T9——断点往返/续玩确定性/幂等/退出清断点
{"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 2026-10-06——三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口)
{"path": "res://tests/unit/presentation/pacing_queue_test.gd", "expect": 0}, # 迭代 8 卡⑦ AC⑤/T9——排空零分叉/零丢行/延迟双态
{"path": "res://tests/unit/presentation/reward_overlay_test.gd", "expect": 0}, # 迭代 7 hotfix——奖励节点 UI 直验(盲区补漏)
{"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0},
@@ -0,0 +1,59 @@
extends SceneTree
# 三选一刷新免费双源 + GP 付费档单测(2026-10-06 用户令——三选一刷新对齐商店 MC-1:
# 免费位 X/Y 用尽后转 GP 付费,同旋钮同曲线同钱包)。
# 守面:付费价曲线(ceil(商店基价×曲线^本次停点已付费刷))/免费优先序 X→Y/付费刷新零触碰免费位/
# 停点在途谓词(无停点禁扣钱)/停点重建清零付费计数(对齐商店进店清零)/钱包单口支出 spend_gp。
# 运行:godot --headless --path game --script res://tests/unit/core/curio_refresh_paid_test.gd(expect 0)
const Kit := preload("res://tests/unit/test_kit.gd")
func _init() -> void:
var db = load("res://core/config_db.gd").new()
if not db.load_tables("res://data/tables", false):
print("[curio_refresh_paid_test] FAIL — 数值表加载失败")
quit(1)
return
var base: int = int(db.adventure.shop_refresh_cost_base)
var curve: float = float(db.adventure.shop_refresh_cost_curve)
Kit.check_quiet(base > 0 and curve > 1.0, "商店刷新旋钮在档(base=%d curve=%.2f——三选一付费档同源读取)" % [base, curve])
# ---- ① 停点在途谓词:无停点时免费/付费刷新皆拒绝(付费路径扣费前置校验防空转烧钱包)----
var adr = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr.start_adventure(1234, {"hero_override": {}, "treasure_unlocked": {}}), "冒险启动(种子 1234——刷新重抽需真实令牌池)")
Kit.check_quiet(adr.curio_stop_pending() == false, "无停点=在途谓词 false")
Kit.check_quiet(adr.refresh_curio() == false and adr.refresh_curio_paid() == false, "无停点:免费/付费刷新皆拒绝零副作用")
# ---- ② 免费位优先序 X→Y(白盒造通关停点——smoke_all ⑫ 同构)----
adr._curio_pending = {"candidates": _cands(), "payload": {}}
Kit.check_quiet(adr.curio_stop_pending() == true, "通关停点在途=谓词 true")
adr._attune_refresh_x = 1
adr._attune_refresh_y = 2
Kit.check_quiet(adr.refresh_curio() and adr._attune_refresh_x == 0 and adr._attune_refresh_y == 2, "X 优先消耗(x1y2→x0y2)")
Kit.check_quiet(adr.refresh_curio() and adr._attune_refresh_y == 1, "X 穷转 Y 池(y2→y1)")
# ---- ③ GP 付费档:计数递增爬价 + 零触碰免费位 ----
Kit.check_quiet(adr.curio_refresh_gp_cost() == base, "首刷报价=基价(免费位尚余 y1,报价独立于免费位)")
Kit.check_quiet(adr.refresh_curio_paid() and adr._attune_refresh_y == 1, "付费刷新不触碰免费位(y1 保持)")
Kit.check_quiet(adr.curio_refresh_gp_cost() == int(ceil(float(base) * curve)), "二刷价爬升=ceil(基价×曲线)(已付费刷=1)")
Kit.check_quiet(adr.refresh_curio_paid() and adr.curio_refresh_gp_cost() == int(ceil(float(base) * curve * curve)), "三刷价再爬升=ceil(基价×曲线²)(已付费刷=2)")
# ---- ④ 停点重建清零付费计数(对齐商店进店清零——不结转)----
adr.present_node_curio(_cands())
Kit.check_quiet(adr._curio_paid_refreshes == 0 and adr.curio_refresh_gp_cost() == base, "节点停点重建=付费计数清零回基价")
adr.refresh_curio_paid()
Kit.check_quiet(adr._curio_paid_refreshes == 1, "付费刷新计数在途递增(=1)")
adr.present_node_curio(_cands())
Kit.check_quiet(adr.curio_refresh_gp_cost() == base, "再碰三选一付费计数仍清零(同 X 余量不结转口径)")
# ---- ⑤ 钱包单口支出 spend_gp(LevelRunner——三选一付费档与商店刷新共用同一冒险钱包)----
var lr = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
lr.gp = 30
Kit.check_quiet(lr.spend_gp(0) == false and int(lr.gp) == 30, "零额支出拒绝零副作用")
Kit.check_quiet(lr.spend_gp(31) == false and int(lr.gp) == 30, "余额不足拒绝零副作用")
Kit.check_quiet(lr.spend_gp(20) == true and int(lr.gp) == 10, "足额支出扣账(30→10)")
quit(Kit.finish("curio_refresh_paid"))
func _cands() -> Array:
return [{"kind": "gp", "tier": "common"}, {"kind": "attack", "tier": "common"}, {"kind": "max_hp", "tier": "common"}]
@@ -0,0 +1 @@
uid://5ehkr1644tk8
@@ -38,6 +38,29 @@ func _run() -> void:
if not _req("选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns,
ov.visible and btns == 3 and not ov._confirm_btn.visible and not ov._continue_btn.visible):
return
# ①b 刷新面投喂(2026-10-06 对齐商店 MC-1——投喂前隐藏/免费位点亮/GP 付费档禁用态/真实钮路径外发)
if not _req("投喂前刷新钮应隐藏(未投喂上下文如店内门票面无刷新)",
not ov._refresh_btn.visible):
return
var refreshed := []
ov.refresh_requested.connect(func(): refreshed.append(true))
ov.set_refresh_display(0, 20, false)
if not _req("GP 付费档:余量 0 价 20 余额不足=钮现形但禁用、角标 0、价签行可见",
ov._refresh_btn.visible and ov._refresh_btn.disabled and ov._refresh_badge.text == "0"
and ov._refresh_l.visible and ov._refresh_l.text == "刷新 20 金币"):
return
ov.set_refresh_display(2, 20, false)
if not _req("免费位余量 2:钮点亮、角标 2、免费行——真实按钮路径外发 refresh_requested",
ov._refresh_btn.visible and not ov._refresh_btn.disabled and ov._refresh_badge.text == "2"
and ov._refresh_l.text == "免费 · 不消耗 AP"):
return
ov.set_refresh_display(0, 20, true)
if not _req("付费档余额可付:免费位 0 但钮点亮(对齐商店——GP 档可刷)",
ov._refresh_btn.visible and not ov._refresh_btn.disabled):
return
ov.press_refresh()
if not _req("press_refresh 未外发 refresh_requested", refreshed.size() == 1):
return
# ② 点选→高亮→确认外发(真实按钮路径);确认前改选以最后点选为准
ov.press_card(0)
ov.press_card(1)
@@ -63,9 +86,11 @@ func _run() -> void:
if not _req("丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced),
replaced_first and replaced.size() == 1 and int(replaced[0]) == 0):
return
# ④ 结果态:继续钮点亮、唯一出口
# ④ 结果态:继续钮点亮、唯一出口(刷新钮随选择态收——停点已兑付残钮零意图可点)
ov.set_refresh_display(2, 20, false)
ov.show_result("已选择:攻击力提升 · 攻击 +1")
if not _req("结果态应点亮继续且收替换面板", ov._continue_btn.visible and not ov._replace_box.visible):
if not _req("结果态应点亮继续且收替换面板与刷新钮",
ov._continue_btn.visible and not ov._replace_box.visible and not ov._refresh_btn.visible):
return
ov.press_continue()
if not _req("继续未外发 continue_pressed", continued.size() == 1):