From 12807298eb385f792cff1389de3d02e10bac7549 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Tue, 6 Oct 2026 07:03:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=89=E9=80=89=E4=B8=80=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E9=BD=90=E5=95=86=E5=BA=97=EF=BC=882026-10-0?= =?UTF-8?q?6=20=E7=94=A8=E6=88=B7=E4=BB=A4=EF=BC=89=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=E5=8F=8C=E6=BA=90=20X/Y=20=E7=94=A8=E5=B0=BD?= =?UTF-8?q?=E5=90=8E=20GP=20=E4=BB=98=E8=B4=B9=E6=A1=A3=EF=BC=88=E5=90=8C?= =?UTF-8?q?=20shop=5Frefresh=5F*=20=E6=97=8B=E9=92=AE/=E5=90=8C=E5=86=92?= =?UTF-8?q?=E9=99=A9=E9=92=B1=E5=8C=85=20spend=5Fgp=20=E5=8D=95=E5=8F=A3/?= =?UTF-8?q?=E5=90=8C=20reward=5Fpick=20=E6=B1=A0=E9=87=8D=E6=8A=BD?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E4=BB=98=E8=B4=B9=E5=88=B7=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E6=AF=8F=E5=81=9C=E7=82=B9=E6=B8=85=E9=9B=B6=EF=BC=9BCurioPick?= =?UTF-8?q?Overlay.set=5Frefresh=5Fdisplay=20=E4=B8=8E=E5=95=86=E5=BA=97?= =?UTF-8?q?=E5=90=8C=E6=9E=84=EF=BC=88=E9=92=AE=E5=B8=B8=E9=A9=BB=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E6=80=81/=E4=B8=A4=E6=BA=90=E7=9A=86=E7=A9=B7?= =?UTF-8?q?=E7=A6=81=E7=94=A8/=E8=A7=92=E6=A0=87=E5=85=8D=E8=B4=B9?= =?UTF-8?q?=E4=BD=99=E9=87=8F/=E4=BB=B7=E7=AD=BE=E8=A1=8C=20GP=20=E4=BB=B7?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E9=80=9A=E5=85=B3=E5=81=9C=E7=82=B9=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=87=8D=E5=8F=91=E5=B8=A6=20refreshed=20=E6=A0=87?= =?UTF-8?q?=E7=9B=B4=E8=90=BD=E9=80=89=E6=8B=A9=E9=9D=A2=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E5=BC=80=E8=83=9C=E5=88=A9=E6=80=BB=E7=BB=93=EF=BC=9B?= =?UTF-8?q?=E5=BA=97=E5=86=85=E9=97=A8=E7=A5=A8=E4=B8=93=E9=95=BF=E4=B8=89?= =?UTF-8?q?=E9=80=89=E4=B8=80=E7=BB=B4=E6=8C=81=E6=97=A0=E5=88=B7=E6=96=B0?= =?UTF-8?q?=EF=BC=9B=E5=8D=95=E6=B5=8B=20curio=5Frefresh=5Fpaid=5Ftest+?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=B1=82=E5=88=B7=E6=96=B0=E9=9D=A2=E6=96=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=9B=E8=90=BD=E6=96=87=3D=E7=A7=98=E5=AE=9D.md=20?= =?UTF-8?q?=E5=8F=8C=E6=BA=90=E8=8A=82=20GP=20=E4=BB=98=E8=B4=B9=E6=A1=A3+?= =?UTF-8?q?=E5=86=92=E9=99=A9=E7=BB=93=E6=9E=84=20v0.16+06=20=E5=95=86?= =?UTF-8?q?=E5=BA=97=E5=88=B7=E6=96=B0=E6=9D=A1=E7=9B=AE=E6=8D=A2=E4=BB=A3?= =?UTF-8?q?=EF=BC=88=E6=9C=BA=E5=88=B6=E5=B7=B2=E8=90=BD=E7=A0=81=E6=80=81?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/gdd/06-待办清单.md | 6 +- design/gdd/systems/冒险循环/冒险结构.md | 3 +- design/gdd/systems/排雷循环/秘宝.md | 2 +- game/core/adventure_runner.gd | 40 ++++++++++--- game/core/level_runner.gd | 8 +++ .../presentation/curio_interact_controller.gd | 37 +++++++++--- game/presentation/views/curio_pick_overlay.gd | 29 ++++++--- game/tests/run_all.gd | 1 + .../unit/core/curio_refresh_paid_test.gd | 59 +++++++++++++++++++ .../unit/core/curio_refresh_paid_test.gd.uid | 1 + .../unit/presentation/reward_overlay_test.gd | 29 ++++++++- 11 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 game/tests/unit/core/curio_refresh_paid_test.gd create mode 100644 game/tests/unit/core/curio_refresh_paid_test.gd.uid diff --git a/design/gdd/06-待办清单.md b/design/gdd/06-待办清单.md index dd4d64ee..68404739 100644 --- a/design/gdd/06-待办清单.md +++ b/design/gdd/06-待办清单.md @@ -381,8 +381,8 @@ #### 商店独立刷新机制——两参数待调参 + 机制文档回写 -**现状**:2026-10-04 用户裁决(P-C2+Q2 二次裁)推翻关卡节点地图 v1.5「无独立刷新、重抽绑购买」口径,定案=**基础免费 0 次 + GP 刷新(ceil(20×1.8^已付费刷) provisional)+ 池源同源(共享池,与三选一同一账本)**;**天赋/营地侧「刷新次数」增益=用户既有意向(口头裁决,曾因未落文丢失——Q2 追认),后续由用户调整添加,实现面预留 free_count=天赋增益位**。未定:天赋增益的具体形态与数值。机制回写面=[冒险结构](systems/冒险循环/冒险结构.md) 共享池账本节 + [关卡节点地图](systems/冒险循环/关卡节点地图.md) 商店节点行 v1.5 换代;实现面=shop_draw/runner(核心小批,非呈现批)。来源=同上 §七 +**现状**:2026-10-04 用户裁决(P-C2+Q2 二次裁)推翻关卡节点地图 v1.5「无独立刷新、重抽绑购买」口径,定案=**基础免费 0 次 + GP 刷新(ceil(20×1.8^已付费刷) provisional)+ 池源同源(共享池,与三选一同一账本)**;**天赋/营地侧「刷新次数」增益=用户既有意向(口头裁决,曾因未落文丢失——Q2 追认),后续由用户调整添加,实现面预留 free_count=天赋增益位**。未定:天赋增益的具体形态与数值。**机制已落码(SR-1 随 UI 落码批收官,2026-10-04/05)**。**三选一同款付费刷新(2026-10-06 用户令——「三选一对齐商店,得有刷新」)已落码**:免费双源(刷新件 X/冒险池 Y)用尽后 GP 付费刷新,同旋钮同曲线同钱包,钮常驻选择态、两源皆穷=禁用;机制登记归[秘宝](systems/排雷循环/秘宝.md)「三选一刷新双源」节。剩余待办=①调参(shop_refresh_free/曲线两参数 provisional);②天赋增益形态与数值;③机制文档回写(冒险结构共享池账本节/关卡节点地图商店行 v1.6 已记,本条收口时核对三处同步)。来源=同上 §七 -**归属**:商店刷新核心小批(UI 落码计划 SR-1(商店刷新批);开工待用户发令) +**归属**:M4 数值批(调参两参数随批);天赋增益随天赋侧内容批 -**状态**:待办(机制已裁、参数与文档回写未落) +**状态**:待办(机制已落码;调参、天赋增益与文档回写未落) diff --git a/design/gdd/systems/冒险循环/冒险结构.md b/design/gdd/systems/冒险循环/冒险结构.md index 42b50a9a..a432a72e 100644 --- a/design/gdd/systems/冒险循环/冒险结构.md +++ b/design/gdd/systems/冒险循环/冒险结构.md @@ -151,7 +151,7 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 掉落掷定消费 gp_drop 封闭随机流(主种子派生、可回放)。**商店货架抽签消费 shop_draw 封闭随机流**(v0.13 扩签——每次抽 3〔进店+每次买后重抽〕恰一 scope,与 reward_pick 分签防取数序互扰)。掉落与价格的现行行值读商店表(registry 为准),数值所有权归[难度曲线与Boss](难度曲线与Boss.md)曲线调参批——本批骨架先行,全部数值 provisional。 -**共享池账本(v0.13 P1 共享池单点;v0.14 令牌库换代)**:商店货架与提升三选一是**同一个池**——货架候选 = 池∖{GP 袋}(用钱买钱恒负交易)= {攻击, 生命上限, 护甲, 行动点} 四种(秘宝件已随同调批出池);**库存 = 剩余令牌数**(TP0——「池消耗即上限」,种类次数上限退役,与奖励兑付摘牌同一令牌库承载,全冒险一次性跨关卡不补给):商店买一件,奖励面就少一枚;「无限购买」收敛为**钱尽或池空为止**(池空=硬闸、A 链爬价=软闸),空货架合法态。货架 = **令牌级抽签**(同 kind 去重每 kind 至多一卡,价格公式 tier-blind 维持批②裁定,购买消费在架令牌本体 kind+tier)。两渠道不得各自私记库存——消费走同一入口写同一令牌库(实现=AdventureRunner._bonus_deck 单点,冒险结构 owner;kind 维度派生计数 _bonus_taken 承载断点附随件与读数)。**货架独立刷新(v0.15 MC-1 2026-10-04——P-C2+Q2 裁)**:进店免费刷新次数(基础 0,天赋/营地侧增益位预留)用尽后 GP 付费刷新(ceil(20×1.8^已付费刷) provisional——调参表旋钮 shop_refresh_*);重抽=同 pool_draw 共享池抽签(shop_draw 恰一新 scope),池空=不可刷新合法态;进店/离开双向清零付费刷计数。 +**共享池账本(v0.13 P1 共享池单点;v0.14 令牌库换代)**:商店货架与提升三选一是**同一个池**——货架候选 = 池∖{GP 袋}(用钱买钱恒负交易)= {攻击, 生命上限, 护甲, 行动点} 四种(秘宝件已随同调批出池);**库存 = 剩余令牌数**(TP0——「池消耗即上限」,种类次数上限退役,与奖励兑付摘牌同一令牌库承载,全冒险一次性跨关卡不补给):商店买一件,奖励面就少一枚;「无限购买」收敛为**钱尽或池空为止**(池空=硬闸、A 链爬价=软闸),空货架合法态。货架 = **令牌级抽签**(同 kind 去重每 kind 至多一卡,价格公式 tier-blind 维持批②裁定,购买消费在架令牌本体 kind+tier)。两渠道不得各自私记库存——消费走同一入口写同一令牌库(实现=AdventureRunner._bonus_deck 单点,冒险结构 owner;kind 维度派生计数 _bonus_taken 承载断点附随件与读数)。**货架独立刷新(v0.15 MC-1 2026-10-04——P-C2+Q2 裁)**:进店免费刷新次数(基础 0,天赋/营地侧增益位预留)用尽后 GP 付费刷新(ceil(20×1.8^已付费刷) provisional——调参表旋钮 shop_refresh_*);重抽=同 pool_draw 共享池抽签(shop_draw 恰一新 scope),池空=不可刷新合法态;进店/离开双向清零付费刷计数。**三选一同款付费档(v0.16 2026-10-06 用户令——刷新对齐商店)**:提升三选一免费双源(刷新件 X/冒险池 Y,机制正主=[秘宝](../排雷循环/秘宝.md)三选一刷新双源节)用尽后 GP 付费刷新——读同 shop_refresh_* 两旋钮(单点调参)、扣同一 GP 冒险账本(LevelRunner.spend_gp 单口)、重抽同 reward_pick 共享池抽签;付费刷计数每停点清零(与进店清零同构);钮常驻选择态、免费与付费两源皆穷=禁用。 ### 关卡大奖励:通关回血 + 提升三选一(钥石退役出大奖励) @@ -332,3 +332,4 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 | v0.12 | 2026-10-01 | **关卡大奖励重构批(迭代 10——用户 2026-09-30 拍板案丙 + P-A~P-E 全裁毕;冒险表扩列 / meta.tres 重锚 / registry / 行动经济 / 元进度 / 01-核心循环 / 03-内容规模 / 难度曲线与Boss / 关卡节点地图 / 06 同批落文)**:①大奖励「固定三件套」退役改两层兑付——非末关通关 = 部分回血(clear_heal_pct 20 provisional)+ 提升三选一(池六种:攻击/生命上限/护甲/GP 袋/秘宝/行动点件;加权洗牌不放回 E-S weight-key 消费 reward_pick 封闭流;属性件上限各 3、AP 件上限 2,达上限先剔牌库);②统一冒险加算区三轴扩四轴 +行动点(AP 件 = 每场战斗开始首轮基线 +1);③钥石退役出大奖励改 Boss/精英击杀逐杀掉落(boss_keystone 重锚首杀 3/复杀 2,末关照常入账,精英掉 1 预留);④秘宝件受同调位约束(过渡键 transitional_sync_slots=1 provisional,正主 = 同调实施批谓词表;超位替换交互 N+1 选 N、战斗内渠道沉没不入);⑤断点附随件扩奖励选择态 + 件获取次数计数;⑥选择停点零自动推进/可中断。渲染层验证冻结(用户 2026-10-01 提前开工拍板——不合入 master、目检待批),目检清单随批交付 | | v0.13 | 2026-10-03 | **GP 商店房重构批(迭代 11 第②批——任务书 v1.3 全裁 P1 共享池/P1-a/P1-b 双甲/P2 甲/P3 甲/P4 ceil/P5 甲;06「GP 商店房重构」条 2026-10-02 用户裁决取代 09-20 无刷新裁决)**:①GP 冒险账本节商店购买行换代——价格公式 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(A/b provisional 1.8/1.1;legacy 旧式随对照模式保留注记);②新增「共享池账本」段——商店货架 = 池∖{GP 袋}∖{秘宝件} 四种,库存=上限即库存全冒险一次性,两渠道同一账本(买一件=奖励面少一件),池空硬闸+空架合法态;③shop_draw 扩签登记;④统一冒险加算区段——pool 模式买上限不回血(池件=奖励件本体)、AP 件累加源并商店;⑤断点附随件扩商店货架态(shop_shelf/shop_visit_buys,P2 甲;确定性重放承载注记);⑥调参表 GP 经济参数行随表换代。实现=AdventureRunner/LevelRunner 池模式(缺省 legacy 对照承载,真实入口投 pool) | | v0.14 | 2026-10-03 | **令牌池落地批(迭代 12 TP0——node-reward v2.2 追认 b 实施批)**:①提升池改**固定配额令牌池**——普通 70/稀有 25/罕见 3=98 张〔罕见档原列「道具件 2」随 aux_item 池种退役〔迭代 11 第①批 T7,落地晚于追认 b〕收窄,池总量 100→98——台账自决披露项〕;②抽取机制换代——Fisher-Yates 不放回抽 3 同 kind 去重每 kind 至多一枚(取代 E-S 加权洗牌),选中踢池未选回池、池耗即枯竭、**种类次数上限退役**;③恢复件新入池(rare 档 +6 当即回血封顶生效上限)+ 数值对(小GP 20/大GP 50 等);④MC 策略 316-f 落地(autoplay 1 级最稀有/2 级最左);⑤共享池账本段换代——库存=剩余令牌数,货架=令牌级抽签(价格公式 tier-blind 维持批②裁定),消费=AdventureRunner._bonus_deck 单点;⑥断点附随件扩令牌池余库(bonus_deck 逐枚还原;旧断点缺键=满库重建防御口径单版本周期);⑦现行基线锚换代=runsum_m4iter12_tp0_n20_20261003(normal 档配对读数同带 0/5/0) | +| v0.16 | 2026-10-06 | **三选一刷新对齐商店批(用户令——「三选一对齐商店,得有刷新」;机制正主=秘宝.md 三选一刷新双源节同批落文)**:免费双源(刷新件 X/冒险池 Y)用尽后 GP 付费刷新——同 shop_refresh_* 两旋钮(curio_refresh_gp_cost 单点调参)、扣同一 GP 冒险账本(LevelRunner.spend_gp 新单口)、重抽同 reward_pick 共享池抽签;付费刷计数每停点清零;呈现=CurioPickOverlay.set_refresh_display 与商店 set_refresh_display 同构(钮常驻选择态、两源皆穷禁用不隐藏、角标=免费余量、价签行示 GP 价);通关停点刷新重发载荷带 refreshed 标直落选择面(不再重开胜利总结面)。店内门票专长三选一暂不挂刷新面(维持现状)。单测=tests/unit/core/curio_refresh_paid_test.gd | diff --git a/design/gdd/systems/排雷循环/秘宝.md b/design/gdd/systems/排雷循环/秘宝.md index b7fee86f..40628304 100644 --- a/design/gdd/systems/排雷循环/秘宝.md +++ b/design/gdd/systems/排雷循环/秘宝.md @@ -18,7 +18,7 @@ - **目录九件**(值全 provisional 归难度曲线与Boss):被动六件=淬毒刃纹(克制面穿刺/魔法直伤 +N)/标中殉爆(每标中一旗全体存活敌吃 N 群伤)/惊巢反制(挖雷惩罚减半+反弹 N)/工兵晨令(前 effective_rounds 轮每轮 +1 AP,P4 甲表值化)/偏运硬币(装板摇号稀有正向 kinds 权重 +N,池结构零触碰)/三选一刷新件(X 次/每碰三选一);主动三件=掘进蓄能(每开够 N 安全格积 1 点发起临时 AP,charges 2/冒险)/探巢摆钟(3×3 真视窄域,charges 2/冒险)/爆破工兵包(3×3 真视 + 4 AP + **中立移除雷格**,active_charged 装配件 charges 2/冒险——迭代 13 实装,见下「中立移除」注)。 > **中立移除(迭代 13 T7/T8 实装口径;裁决事实源=任务书-工程债与测试基建批-2026-10-04.md P1/P2 甲,随批吸收本节为正主)**:使用时对 3×3 区域做**中立移除**——只移除「未揭示、无旗、是雷」的格:不引爆、不揭示、不进连锁;被移除格退色降档呈现(暗淡记忆态同族)、不可挖掘不可插旗(拒绝零消耗);**周边邻接数字=原始雷数冻结不重算(P1 甲)**,移除格自身无数字可保留(未揭示雷格本无数字,P2 甲空集真)。清图判定零涉(分母只数安全格,雷格不入);旗判定基线零回溯(有旗格跳过移除)。获得渠道=营地同调位装配(不入掉落投放池——form 非空行永不入过渡渠道,模拟/自动档零涉,MC 读数零扰动)。 -- **三选一刷新双源**(P1 甲):X=刷新件每碰三选一重给(优先消耗、不结转)+ Y=营地刷新升级行(每冒险开局注入冒险周期池、用完即无;meta.tres attune_refresh_upgrade 行族);重抽消费 reward_pick 既有洗牌流,池规则全保留;autoplay 默认不刷(钓鱼归玩家)。 +- **三选一刷新双源**(P1 甲):X=刷新件每碰三选一重给(优先消耗、不结转)+ Y=营地刷新升级行(每冒险开局注入冒险周期池、用完即无;meta.tres attune_refresh_upgrade 行族);重抽消费 reward_pick 既有洗牌流,池规则全保留;autoplay 默认不刷(钓鱼归玩家)。**GP 付费档(2026-10-06 用户令——三选一刷新对齐商店独立刷新)**:免费双源用尽后可 GP 付费刷新,价=ceil(20×1.8^本次停点已付费刷) provisional(与商店货架刷新读同两个调参旋钮 shop_refresh_*,单点调参);付费刷计数每停点清零(与商店进店清零同构);钱包=冒险 GP 同一账本(LevelRunner.spend_gp 单口);余额不足=钮禁用(不隐藏,对齐商店)。店内门票专长三选一暂不挂刷新面(维持现状,扩面须先裁门票保底计数交互)。 - **🔴 承重契约**(schema 加载期强制):机制轴禁裸 flat 数值(行键白名单封闭);真视窄域 ≤3×3;核心层零 await;零随机;排雷玩法零特判;雷安同池冻结(硬币只动权重投影)。 - **验收分层**:attune=off 续用现行带;attune=on 带平移归难度曲线与Boss 投值(商店 shop=off/on 分层先例同法)。 diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 4f081e65..e312ea57 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -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 击败=通关渠道只出专长 diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index e1c6776e..4ab2b4a1 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -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 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作) diff --git a/game/presentation/curio_interact_controller.gd b/game/presentation/curio_interact_controller.gd index fffbe0db..1657f04c 100644 --- a/game/presentation/curio_interact_controller.gd +++ b/game/presentation/curio_interact_controller.gd @@ -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: diff --git a/game/presentation/views/curio_pick_overlay.gd b/game/presentation/views/curio_pick_overlay.gd index a60a7b19..722cf4e5 100644 --- a/game/presentation/views/curio_pick_overlay.gd +++ b/game/presentation/views/curio_pick_overlay.gd @@ -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 diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 388d5345..d3d9f022 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -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}, diff --git a/game/tests/unit/core/curio_refresh_paid_test.gd b/game/tests/unit/core/curio_refresh_paid_test.gd new file mode 100644 index 00000000..0d6167a5 --- /dev/null +++ b/game/tests/unit/core/curio_refresh_paid_test.gd @@ -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"}] diff --git a/game/tests/unit/core/curio_refresh_paid_test.gd.uid b/game/tests/unit/core/curio_refresh_paid_test.gd.uid new file mode 100644 index 00000000..31b84c71 --- /dev/null +++ b/game/tests/unit/core/curio_refresh_paid_test.gd.uid @@ -0,0 +1 @@ +uid://5ehkr1644tk8 diff --git a/game/tests/unit/presentation/reward_overlay_test.gd b/game/tests/unit/presentation/reward_overlay_test.gd index aa457cfd..2aefa086 100644 --- a/game/tests/unit/presentation/reward_overlay_test.gd +++ b/game/tests/unit/presentation/reward_overlay_test.gd @@ -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):