From 4f62a489b68cec3065426efca16d55ad07547dfe Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 1 Oct 2026 06:12:25 +0800 Subject: [PATCH] =?UTF-8?q?iter10=20=E8=BF=BD=E5=8A=A0=E6=89=B9:=20?= =?UTF-8?q?=E5=A5=96=E5=8A=B1=E8=8A=82=E7=82=B9=E4=B8=89=E9=80=89=E4=B8=80?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=B1=A0=EF=BC=88=E7=94=A8=E6=88=B7=E6=8B=8D?= =?UTF-8?q?=E6=9D=BF=EF=BC=89=E2=80=94=E2=80=94AdventureRunner=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9API+AuxItems=20=E5=8F=A5=E6=9F=84=E9=9D=A2+main=20?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E9=9D=A2=E5=88=86=E6=B4=BE+node=5Foverlay=20?= =?UTF-8?q?=E9=81=93=E5=85=B7=E9=9D=A2=E9=80=80=E5=BD=B9+autoplay=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E9=A9=B1=E5=8A=A8+reward=5Foverlay=5Ftest=20?= =?UTF-8?q?=E9=9A=8F=E9=9D=A2=E8=BF=81=E7=A7=BB+a=E8=A3=81=E5=A4=87?= =?UTF-8?q?=E9=80=89(L3=E5=A4=8D=E6=9D=803)+g=E8=A3=81semver(v2=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=96=AD=E8=A8=80+=E5=AD=98=E6=A1=A3=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96v1.3)+f=E8=A3=81=E8=B0=83=E7=A0=94=E5=BE=85?= =?UTF-8?q?=E5=8A=9E(06=E6=96=B0=E6=9D=A1)+=E6=96=87=E6=A1=A3=E4=BA=94?= =?UTF-8?q?=E4=BB=B6+=E4=BB=BB=E5=8A=A1=E4=B9=A6=EF=BC=88=E5=86=92?= =?UTF-8?q?=E7=83=9F628/0+run=5Fall=2015=E5=A5=97+=E4=BA=94=E6=8E=A2?= =?UTF-8?q?=E9=92=88+=E4=B8=89=E9=97=B8=E9=9B=B6=E6=96=B0=E5=A2=9E+?= =?UTF-8?q?=E7=86=B5=E5=A2=9EPASS=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../m3-iter10-addendum-task-2026-10-01.md | 34 ++++++ design/gdd/06-待办清单.md | 8 ++ design/gdd/systems/冒险循环/关卡节点地图.md | 4 +- design/gdd/systems/冒险循环/冒险结构.md | 2 +- design/gdd/systems/排雷循环/魔法道具.md | 1 + design/gdd/systems/表现与支撑/存档序列化.md | 1 + design/gdd/ux/node-reward.md | 26 ++--- .../qa/m3-iter10-exit-checklist-2026-10-01.md | 20 +++- design/registry/entities.yaml | 2 +- game/core/adventure_runner.gd | 109 ++++++++++++++++++ game/core/aux_items.gd | 18 +++ game/data/tables/meta.tres | 9 +- game/presentation/battle_narrator.gd | 5 + game/presentation/main.gd | 68 ++++------- game/presentation/views/bonus_pick_overlay.gd | 4 +- game/presentation/views/node_overlay.gd | 42 ------- game/smoke/level_autoplay.gd | 35 ++++-- game/smoke/smoke_all.gd | 67 +++++++++-- .../unit/presentation/reward_overlay_test.gd | 76 ++++++++---- 19 files changed, 377 insertions(+), 154 deletions(-) create mode 100644 .zcode/plans/m3-iter10-addendum-task-2026-10-01.md diff --git a/.zcode/plans/m3-iter10-addendum-task-2026-10-01.md b/.zcode/plans/m3-iter10-addendum-task-2026-10-01.md new file mode 100644 index 00000000..de9f7ecf --- /dev/null +++ b/.zcode/plans/m3-iter10-addendum-task-2026-10-01.md @@ -0,0 +1,34 @@ +# M-3 迭代 10 追加批任务书:奖励节点共享池 + 七项代拍板裁决(2026-10-01) + +> 触发 = 用户 2026-10-01 对迭代 10 代拍板 7 项的逐条裁决 + 新增范围指令(「奖励节点其实也是一个三选一的奖励获取,和通关关卡得到的这个三选一奖励是相同的,他们共享同一个池子;把需要补充的文档,还有迭代的任务书和代码实现都补充上」)。本批在迭代 10 worktree(m3-iter10-20261001)内执行,不合入 master、渲染层验证冻结口径不变。 + +## 一、裁决记录(2026-10-01 用户逐条) + +| # | 代拍板 | 裁决 | +|---|---|---| +| a | Boss 钥石子选项 | **用备选**:复杀按关 2/2/3 递进(首杀 3 三关统一;L3 3/3 系 first≥repeat 合法端点)——原代拍板「复杀统一 2」推翻,meta.tres/registry 随改 | +| b | 三选一件值 provisional | 认同(1/5/1/GP20/AP1/道具 1 件,归调参批重投) | +| c | AP 件兑现语义 | 认同(每场战斗首轮基线 +1) | +| d | 钥石增量行呈现位 | 认同(战斗总结结算框;三选一面零钥石) | +| e | 同调位过渡形态 | **待白话说明后追认**(本批先按现行形态保留;追加批已把奖励节点渠道升级为替换子交互两路——原「满位不授予」收窄面随共享池消除) | +| f | MC 三选一策略 | 暂定维持 + **登记调研待办**(06 新条目):随机选取奖励是否会对角色能力有较大影响——首件 vs 按权重随机双窗对照 | +| g | save 版本兼容 | 对齐 semantic version 向后兼容口径:旧档可读可玩、断点可续、缺新键合法空态;v2→v3 兼容断言随批(存档序列化.md v1.3 成文) | + +## 二、新增范围:奖励节点三选一共享池 + +**设计**:奖励节点到达即开与通关三选一相同的选择面——提升池六种同权重、同全冒险次数上限(两渠道合计判上限)、同 reward_pick 封闭流;道具件满位触发替换子交互(与通关面同构);「池空直通」合法态退役(GP/道具件无上限,牌库恒非空)。 + +**落地面**: +- AdventureRunner:node_bonus_candidates / present_node_bonus / apply_node_bonus / resolve_bonus_replace(分发节点句柄与通关序号两轨)+ _node_bonus_pending(断点不载——续玩重建重抽,计数器保证同候选) +- AuxItems:holding_records / discard_item(节点替换的库存句柄面) +- main.gd:奖励节点分支改走 BonusPickOverlay 共用面(_bonus_context 分派 continue);node_overlay 道具三选一面退役(保留恢复点用途);_reward_node_choices/_on_reward_item_chosen 退役 +- autoplay:节点三选一确定性驱动(首件/放弃新件)+ resolved 上下文分派 +- narrator:node_bonus_header +- 冒烟:⑫ v2 兼容三断言 + ⑬ 节点共享池三断言 + ② 节点计数/上限集扩;_adv_drive_to_shop 节点驱动 +- reward_overlay_test 随面迁移改写(守 BonusPickOverlay:两段式/确认外发/替换两路/结果态) + +**文档**:关卡节点地图.md(节点奖励节改写+行表)、冒险结构.md(提升池冒险级单例句)、node-reward.md v2.0(重写)、魔法道具.md v0.7(渠道改写注记)、存档序列化.md v1.3(semver 口径)、06(调研待办新条)、registry(meta_table a 裁备选值) + +## 三、验证义务 + +冒烟全量 + run_all 15 套 + 五探针 + doc 三闸零新增 + 熵增闸 + MC 重锚三跑(追加批改经济面——读数报告更新 v2 段)。 diff --git a/design/gdd/06-待办清单.md b/design/gdd/06-待办清单.md index 2de13c2c..3551eb86 100644 --- a/design/gdd/06-待办清单.md +++ b/design/gdd/06-待办清单.md @@ -266,6 +266,14 @@ **状态**:已登记(2026-10-01,迭代 10 批随批) +#### 奖励随机选取对能力面影响调研(迭代 10 追加批 f 裁决登记,2026-10-01) + +**现状**:迭代 10 追加批用户裁决——MC 自动模拟三选一策略(候选取首件/替换放弃新件)暂定维持;若后续发现随机选取对角色能力有较大影响再调整策略。**调研事项 = 随机选取奖励是否会对角色能力有较大的影响**:autoplay「首件策略」vs「按权重随机策略」的通关率双窗对照(同种子配对读数,方法仿换算率 OFAT 配对先例;随机策略需新增取数签防污染 reward_pick 复放面——设计随调研批)。 + +**归属**:曲线调参批(现行窗)+ 分析侧 + +**状态**:已登记(2026-10-01,迭代 10 追加批随批) + #### 关卡内结构随机生成(哈迪斯 / 哥布林弹珠式布局变化) **现状**:用户 2026-09-20 方向性提及布局随机变化(哈迪斯 / 哥布林弹珠式)。**2026-09-27 拍板立项**,设计文档 [关卡节点图生成](systems/冒险循环/关卡节点图生成.md) v0.3:图模板池逐局抽签+三级台阶+测量对账;五拍板点全部裁决落文——①首关参与抽签仅首次冒险固定脚本④池每关 5 张⑤逐关抽确认②统一用一个流(rng 架构批先行,ADR-0007)③机制先行骨架随后。实施排队架构批裁决后。 diff --git a/design/gdd/systems/冒险循环/关卡节点地图.md b/design/gdd/systems/冒险循环/关卡节点地图.md index e1151fc0..ebd8bb60 100644 --- a/design/gdd/systems/冒险循环/关卡节点地图.md +++ b/design/gdd/systems/冒险循环/关卡节点地图.md @@ -98,7 +98,7 @@ Boss 兑付(胜利或英雄死亡)触发关卡收束点,此后不再有选 |---|---| | 战斗 / Boss | 组装定位信息(关卡序号 + 节点编号),移交[战斗结算](../战斗循环/战斗结算.md)开战;战斗结果(胜负与战后生命)经战斗结果上报回流冒险结构(契约语义归[冒险结构](冒险结构.md),本文不复述) | | 恢复 | 向冒险结构发治疗封顶回报(载荷 = 该关恢复比例,读本关配置行);按比例回血并封顶的执行归冒险结构 | -| 奖励 | 触发节点奖励兑付:获得一件功能道具并入持有域(见下) | +| 奖励 | 触发节点奖励兑付:提升三选一(与通关三选一共享同池——六种加权抽 3 点选一件,同次数上限;见下节) | | 商店 | 打开商店覆盖层(商品陈列与定价读商店表);购买即时生效、写统一冒险加算区(与关卡大奖励同一本账——账本语义归[冒险结构](冒险结构.md),本文不复述);离开覆盖层即兑付确认。商店房不携带战斗行:零敌人、零棋盘、零战斗初始化 | **遭遇构成的编排面**——本文对"放什么敌人"的全部职责只有三条: @@ -109,7 +109,7 @@ Boss 兑付(胜利或英雄死亡)触发关卡收束点,此后不再有选 第二关的"新内容 = 既有池新技能"([技能池提案](../../../research/proposal-skill-pool.md)已定)投放时,改的是战斗侧行里引用的技能池内容——节点图结构原样不动,本文无需为投放留任何新结构位。 -**节点奖励 = 魔法道具投放渠道(现行=切片期过渡形态)**:现行兑付(2026-09-29 迭代 7 拍板②丙)= 奖励节点到达即开**三选一构筑点**(呈现 spec:[node-reward.md](../../ux/node-reward.md))——从可用道具池取一件加入关卡域库存,选择前不给出口;池空直通**零兑付是合法空态**,不是缺陷。道具内容、可用池口径与使用窗口语义归[魔法道具](../排雷循环/魔法道具.md)。**形态沿革(2026-09-30 用户裁决甲)**:本渠道系切片期过渡形态,终局=魔法道具局外同调件(底稿 proposal-magic-item-attunement-2026-09-22.md),随同调实施批退役改写;旧「功能道具的正式渠道」拍板(2026-09-17)已被现行三选一口径取代。 +**节点奖励 = 提升三选一(迭代 10 追加批——用户 2026-10-01 拍板:与通关三选一共享同池)**:奖励节点到达即开三选一(呈现 = 提升三选一覆盖层共用面,spec:[node-reward.md](../../ux/node-reward.md) v2.0)——从提升池六种(攻击/生命上限/护甲/GP 袋/魔法道具/行动点件)按同一加权洗牌抽 3(同权重、同全冒险次数上限、同 reward_pick 封闭流),玩家点选一件兑付;道具件满位触发替换子交互(与通关三选一同构)。件获取次数计数与牌库剔除态为冒险级单例——两渠道合计判上限。原「魔法道具投放渠道三选一」(迭代 7 拍板②丙过渡形态)随批退役;终局=魔法道具局外同调件的改写义务仍归同调实施批(proposal-magic-item-attunement-2026-09-22.md §9)。「池空直通」合法态随共享池退役——池含无上限 GP/道具件,牌库恒非空。 ### 关卡收束点:先清库、后宣布 diff --git a/design/gdd/systems/冒险循环/冒险结构.md b/design/gdd/systems/冒险循环/冒险结构.md index da4e3c6f..16fad961 100644 --- a/design/gdd/systems/冒险循环/冒险结构.md +++ b/design/gdd/systems/冒险循环/冒险结构.md @@ -176,7 +176,7 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 | 魔法道具件(随机一件入库存) | 道具授予既有路由入库存 | 受同调位约束(见下) | 1 | 无次数上限(受同调位) | | 行动点件(AP +1/战) | 加算区行动点轴累加 | 每场战斗开始首轮行动点基线 +1 | 1 | 2 次(全冒险最紧) | -**抽取机制 = 加权洗牌不放回**(禁等概率真随机):weight-key 洗牌(每件 key = U(0,1)^(1/权重) 取前 3,Efraimidis-Spirakis),单次三选一零重复、逐件按权重出概率;消费 reward_pick 封闭随机流(主种子派生、[数据配置](../表现与支撑/数据配置.md)口径),同种子同操作序列同三选项可复放。达任何次数上限的件**先剔出牌库再洗牌**(AP 用尽 2 次、属性件达 3 次后永不再现)——防「三选项全不可选」死角(GP/道具件无次数上限,牌库恒非空)。 +**抽取机制 = 加权洗牌不放回**(禁等概率真随机):weight-key 洗牌(每件 key = U(0,1)^(1/权重) 取前 3,Efraimidis-Spirakis),单次三选一零重复、逐件按权重出概率;消费 reward_pick 封闭随机流(主种子派生、[数据配置](../表现与支撑/数据配置.md)口径),同种子同操作序列同三选项可复放。达任何次数上限的件**先剔出牌库再洗牌**(AP 用尽 2 次、属性件达 3 次后永不再现)——防「三选项全不可选」死角(GP/道具件无次数上限,牌库恒非空)。**提升池为冒险级单例**(迭代 10 追加批):关卡内奖励节点的三选一与通关三选一共享同一池、同一权重与同一次数上限(两渠道合计判上限),兑付语义归[关卡节点地图](关卡节点地图.md)。 **魔法道具件受同调位约束**:道具持有上限 = 同调位数。本批挂过渡读数源 transitional_sync_slots(provisional 初始 1——首次冒险即可持 1 件道具,L1 通关大奖励即可体验道具件);正主 = 同调位里程碑谓词表([proposal-magic-item-attunement-2026-09-22](../../../research/proposal-magic-item-attunement-2026-09-22.md):0 起步、硬上限 3、增长 = 通关次数 + Boss 击杀数走 attunement_growth.tres),同调实施批落地后切换读数源为谓词表实时求值。获取渠道约束形态(迭代 10 过渡承载):三选一道具件超位触发**替换交互**(现有件与新件 N+1 选 N——丢任一原有件腾位收新,或放弃新件不获取;终态恒 ≤ 同调位);奖励节点与战斗内渠道超位**不授予**(候选拒绝 + 掉落沉没,逐行归因留痕)——停点渠道全覆盖的替换交互随同调实施批统一收口(06 在册)。 diff --git a/design/gdd/systems/排雷循环/魔法道具.md b/design/gdd/systems/排雷循环/魔法道具.md index 9ecc10b2..4314ec37 100644 --- a/design/gdd/systems/排雷循环/魔法道具.md +++ b/design/gdd/systems/排雷循环/魔法道具.md @@ -201,3 +201,4 @@ playtest 预警(预注册,归难度曲线与Boss): | v0.4 | 2026-09-17 | 数值纪律收口批:正文与调参表 knob 现行值改指路存根(事实源 data/tables/*.tres 与 registry),1 处失同步修正(AP 8→6 漏改:爆破工兵包定位读数段的「每轮行动点默认 8」改指路 action_economy.tres)——道具加量、供给读数、调参表六行 w/t/mag、道具依赖预警阈值 50% 均改指路 registry `aux_items`;行名与表结构列保留 | | v0.5 | 2026-09-29 | **内容批落地修订(迭代 7——用户八拍板)**:①道具定义表六行入表(AuxItemTable——三有效实装:工兵令 ap_grant/探巢摆钟真视/爆破工兵包组合;**探宝罗盘退役留痕**(拍板①乙——未开格类型微光常显覆盖预读价值,retired 标记词表兼容);显影尘/地穴全图卷 deferred「能力封存」(拍板④甲,Alpha 重定);②F2 维度覆盖改**投放维度覆盖**口径+豁免面 {plan, info, counter} 显式登记(罗盘退役+显影件悬置所致,豁免随 Alpha 重定/天赋批消减);③解锁语义定稿=工坊解锁→进掉落投放池(拍板③甲,未解锁权重 0);④奖励节点=三选一构筑点(拍板②丙,node-reward.md v1.0);⑤掉落投放三件总权重 10/池 88(拍板⑥甲,provisional) | | v0.6 | 2026-09-30 | **拍板点 1 收口批(2026-09-30 用户裁决甲:同调=终局方向)**:①新增「定位与形态沿革」节——现行正文定性=切片期过渡形态、终局=局外同调件(设计权威底稿 proposal-magic-item-attunement-2026-09-22.md 八拍板),两态关系=形态替换、衔接纪律与事故留痕入节;②正文口径回写四处:五维度表信息/规划/反制三行改现行态(罗盘退役/显影件封存/豁免面入正文)、覆盖检查改投放维度覆盖口径、掉落池获取节补解锁门控(2026-09-29 拍板③甲)、板级叠加层预读集改「机制在案无投放来源」;③验收两条(护栏/覆盖检查)与调参表投放态注记、地形变异依赖行同步;④实施批登记见 06「魔法道具同调实施批」条。审计冲突 C19/C29 随批收口(全录见 design/research/gdd-concept-coverage-audit-2026-09-30.md) | +| v0.7 | 2026-10-01 | **奖励节点渠道改写注记(迭代 10 追加批——用户拍板「奖励节点与通关三选一共享同池」)**:①「奖励节点三选一构筑点」形态(v0.5 ④)退役——奖励节点改提升三选一共享池(魔法道具件为池中 1/6 权重选项,同调位满位替换交互随 BonusPickOverlay 共用面生效,spec:node-reward.md v2.0);②道具件经节点渠道入本关库存(来源归因 reward_node,与掉落/三选一渠道同受 transitional_sync_slots 过渡上限约束);③战斗掉落渠道零改动;④本批过渡约束形态与切换义务注记见 registry aux_items 条同日批注;同调实施批的全渠道统一收口义务不变 | diff --git a/design/gdd/systems/表现与支撑/存档序列化.md b/design/gdd/systems/表现与支撑/存档序列化.md index d1c00024..45941a36 100644 --- a/design/gdd/systems/表现与支撑/存档序列化.md +++ b/design/gdd/systems/表现与支撑/存档序列化.md @@ -169,3 +169,4 @@ MVP 不要求本系统的闭环([不做清单](../../05-不做清单.md)把单 | v1.0 | 2026-09-21 | **存档序列化批(SaveManager 落地)**:格式裁决 JSON 单档 `user://save.json`;数据模型逐域成文(元进度四件 + 最近冒险摘要 cap 10);读写时机(启动读盘/冒险收束写盘/营地即时写/测试入口不写)+ tmp+rename 原子写;版本策略(高版本拒读禁写/低版本链式迁移/缺域补齐);损坏兜底(.corrupt.bak 备份 + 默认新档);待设计问题清单六条全部裁决成文(1/2/3/5/6 入核心规则,4 = 单档 MVP 多档留 P2)。自范围草案升定稿 | | v1.1 | 2026-09-23 | 单关测试直入口(L2/L3)退役(用户拍板——唯一试玩入口 = 完整冒险):不写档条款改口径为「调试两层与 headless 单关测试路径(LevelRunner.start_level,仅 QA 工具)」;写盘面零改动(正式冒险收束唯一写档点不变) | | v1.2 | 2026-09-29 | **断点域出生(迭代 8 卡①,schema v1→v2)**:`breakpoint` 域登记(停点粒度续玩快照,内容 owner=冒险结构);反面清单收窄到 meta 域+断点域豁免条款;写盘时机+停点快照/三结局清除;生产迁移链 v1→v2 补空;P1 快照预挂号兑现(rng `restore_adventure` 回填面);域界验收口径随改 | +| v1.3 | 2026-10-01 | **版本兼容口径批(迭代 10 追加批 g 裁——用户拍板:版本兼容对齐 semantic version 标准)**:①版本策略成文口径——本域版本号语义 = **向后兼容的次序版本**:升版只允许「旧档在新代码可读可玩可续玩」(缺新键=合法空态、内容零丢失、断点可续),禁「新档在旧代码可读」承诺(向前兼容不做);②v2→v3 迁移体=空迁移(断点附随件扩列〔bonus_taken/bonus_pending/bonus_result/bonus_aux_carry/keystone_drops + bonus.ap 轴〕与摘要 additive 键 keystone_drops 均由读取方 .get 兜底——兼容性由冒烟 v1→v3 与 v2→v3 断点续玩断言承载,2026-10-01 增);③中断节点兼容义务:每升版必须在冒烟断言「旧版断点→新版续玩受理」(现行三档 v1/v2/v3 齐) | diff --git a/design/gdd/ux/node-reward.md b/design/gdd/ux/node-reward.md index e02b80ce..5e1351d8 100644 --- a/design/gdd/ux/node-reward.md +++ b/design/gdd/ux/node-reward.md @@ -1,30 +1,30 @@ # 节点奖励屏 UX spec(node-reward) -> 状态:v1.0(2026-09-29 迭代 7 T5 随批出生——06「节点奖励屏 UX spec」条目兑现;上游拍板=奖励节点三选一〔用户 2026-09-29 拍板②丙〕) -> 形态沿革(2026-09-30 用户裁决甲):本屏承载的三选一投放系**切片期过渡形态**——终局=魔法道具局外同调件(设计权威底稿 design/research/proposal-magic-item-attunement-2026-09-22.md,八拍板 2026-09-22 在案),同调实施批立项后本屏随投放渠道退役/改写;过渡期各项现行拍板继续有效(口径正主与沿革详见[魔法道具](../systems/排雷循环/魔法道具.md)「定位与形态沿革」节) -> 事实源:`game/presentation/views/node_overlay.gd`(show_reward_items/close_reward)+ `main.gd`(_reward_node_choices/_on_reward_item_chosen) -> 相邻:[level-clear](level-clear.md)(关卡结算屏)/ [level-map](level-map.md)(关卡画面)/ [魔法道具](../systems/排雷循环/魔法道具.md)(内容权威) +> 状态:v2.0(2026-10-01 迭代 10 追加批重写——用户拍板:奖励节点三选一与通关三选一**共享同池**;原「魔法道具投放渠道」过渡形态退役) +> 事实源:`game/presentation/views/bonus_pick_overlay.gd`(三选一覆盖层共用面)+ `main.gd`(奖励节点分支——`adventure.node_bonus_candidates/apply_node_bonus`)+ `battle_narrator.gd`(node_bonus_header) +> 相邻:[level-clear](level-clear.md)(通关三选一面——同一覆盖层的另一上下文)/ [level-map](level-map.md)(关卡画面)/ [魔法道具](../systems/排雷循环/魔法道具.md)(道具件内容权威)/ [冒险结构](../systems/冒险循环/冒险结构.md)(提升池单例与次数上限) ## 定位 -奖励节点(reward node)到达即开的**模态构筑点**:从可用道具池取一件加入关卡背包。「节点奖励 = 功能道具的正式渠道」(关卡节点地图 v1.0 ⑤ 定稿)的呈现承载。 +奖励节点(reward node)到达即开的**提升三选一选择面**:与通关三选一共享同一提升池(六种:攻击/生命上限/护甲/GP 袋/魔法道具/行动点件)——同权重、同全冒险次数上限、同 reward_pick 封闭流。与通关面的差异仅在上下文:无部分回血、无钥石增量行,兑付后「继续」回图选路(不推进关卡)。 ## 布局与交互 -- 全屏变暗层 + 居中标题「关卡奖励点 / 取一件魔法道具(加入关卡背包——关卡结束清空)」。 -- 道具按钮横排三枚(150×72:字形大字=道具名首字 + 全名两行);**选择前不给出口**(继续按钮隐藏——强制三选一的构筑点语义)。 -- 池 ≤3 全列(现行有效道具三件:工兵令/探巢摆钟/爆破工兵包——选一即全列);池扩充后按注册序取前 3(呈现侧零 RNG 消费纪律)。 -- 池空(全未解锁/表空合法态)= 直通节点确认「此行无道具可取——继续前行」(切片期空态语义保留)。 -- 选择 → 道具入关卡域库存(来源归因 `reward_node`)→ toast「获得 X(入关卡背包)」→ C 区槽位刷新 → 兑付确认回选路(零自动推进同款)。 +- 载体 = 提升三选一覆盖层(BonusPickOverlay,与 [level-clear](level-clear.md) 共用组件——零新增交互原语)。 +- 头部行:「关卡奖励点 / 从提升池抽三选一(与通关三选一同池同权重同上限):选择一件带走:」(Narrator.node_bonus_header)。 +- 三卡点选(✓ 高亮,确认前可改选)→「确认选择」→ 兑付反馈(选中件效果 + 丢弃提示)→「继续」→ 回图选路(零自动推进同款)。 +- 魔法道具件满位:替换子面板(现有库存件 + 新件 N+1 选 N,丢旧收新 / 放弃新件两路——与通关面同构;丢弃目标 = 关卡域库存件句柄)。 ## 口径 -- 可用池 = aux 活行 ∩ 工坊解锁集(拍板③甲「解锁→进投放池」同源门控——未解锁不入奖励池与掉落池)。 -- 退役件(探宝罗盘)/失义件(显影尘/地穴全图卷)不入池;营地呈现「已退役 / 已解锁·能力封存中」(呈现 spec:[camp.md](camp.md) v1.0——2026-09-30 拍板点 4A 落位,原悬空指认解除)。 -- 库存语义=关卡域(跨战斗延续、关卡末清空+快照反馈);使用=挖掘期两步交互(C 区槽选中→棋盘目标格)。 +- **同池同上限**:件获取次数计数为冒险级单例——奖励节点与通关三选一两渠道合计判上限(达上限件在两渠道同样剔牌库);权重与牌库口径读冒险表(registry adventure_table 为准)。 +- 道具件入**本关库存**(即时可用,来源归因 `reward_node`)——区别于通关三选一的跨关携带(清库后授予、下一关生效)。 +- 「池空直通」合法态随共享池退役:池含无次数上限的 GP/道具件,牌库恒非空。 +- 退役件(探宝罗盘)/失义件不入道具件随机池(同调位约束口径不变,归[魔法道具](../systems/排雷循环/魔法道具.md))。 ## 版本历史 | 版本 | 日期 | 改动 | |---|---|---| | v1.0 | 2026-09-29 | 迭代 7 T5 随批出生(三选一构筑点形态——用户拍板②丙) | +| v2.0 | 2026-10-01 | **共享池改写(迭代 10 追加批——用户拍板「奖励节点与通关三选一相同、共享同一个池子」)**:①定位改提升三选一选择面(六种共享池、同权重同次数上限、同 reward_pick 流);②载体改 BonusPickOverlay 共用面(原 node_overlay 道具三选一面退役);③道具件满位升级为替换子交互两路(原过渡形态=满位不授予);④「池空直通」合法态退役(牌库恒非空);⑤道具件入本关库存(即时可用),区别于通关渠道的跨关携带 | diff --git a/design/qa/m3-iter10-exit-checklist-2026-10-01.md b/design/qa/m3-iter10-exit-checklist-2026-10-01.md index a7e1efeb..205fb051 100644 --- a/design/qa/m3-iter10-exit-checklist-2026-10-01.md +++ b/design/qa/m3-iter10-exit-checklist-2026-10-01.md @@ -17,8 +17,9 @@ | 9 | 三选一面关游戏 → 主菜单「继续上局」 | 回到三选一面,候选与中断前逐位一致(不重抽);兑付后中断则回兑付反馈态 | | 10 | L3(末关)Boss 击杀 | 不出三选一面,直通冒险完成结算;钥石明细列逐杀三笔(首杀 3×3) | | 11 | 营地回城 | 钥石余额 = 本局逐杀掉落合计;营地呈现存量(与进账时刻不同义) | -| 12 | 同调位替换子面板 | **过渡键 =1 时实机不可达**(carry 每关清空)——视觉验证随同调实施批 slots≥2 场景;本批以展示树探针/单测承载逻辑正确性 | +| 12 | 同调位替换子面板 | **过渡键 =1 时通关渠道实机不可达**(carry 每关清空);**奖励节点渠道可达**(本关库存可满位——先取道具件再走奖励节点选道具件)→ 替换子面板 N+1 选 N、丢旧收新/放弃新件两路 | | 13 | 减动效开 | 三选一面静态化(无动画依赖),点选确认流程不变 | +| 14 | 奖励节点三选一(追加批) | 奖励节点开与通关同款选择面(头部「关卡奖励点」、无回血/钥石行);点选兑付后「继续」回图选路;属性件计数与通关渠道合计判上限(取满 3 次后该件不再出现) | ## 二、代拍板追认清单(计划书 §10 白名单内——合并前逐条追认) @@ -43,9 +44,20 @@ | 熵增闸 | PASS 四查全过 | PASS | | MC | 改后锚 400 局(种 78000 both min)→ 通关率双窗读数报告(见 readings 报告) | 改前锚 400 局(种 77000) | -## 四、合并前待办(用户点名后执行) +## 四、代拍板裁决记录(2026-10-01 用户逐条——首批 7 项 + 追加范围) + +| # | 裁决 | +|---|---| +| a | **用备选**:复杀按关 2/2/3 递进(首杀 3 三关统一)——meta.tres/registry 已随改 | +| b/c/d | 认同(件值 provisional / AP 每战首轮 +1 / 钥石行在战斗总结结算框) | +| e | 说明已呈(同调位过渡形态白话)——**追加批已把奖励节点渠道升级为替换子交互两路**,原「满位不授予」收窄面随共享池消除;追认口径以 node-reward.md v2.0 / 冒险结构.md 同调位段现行文为准 | +| f | 暂定维持 + 调研待办已登记 06(随机选取奖励对角色能力影响——首件 vs 权重随机双窗对照) | +| g | semver 向后兼容口径成文(存档序列化.md v1.3)+ v2→v3 兼容断言随批(冒烟 ⑫) | +| 追加 | **奖励节点三选一共享池**(用户指令)——设计/任务书/代码/文档/测试全落,见 `.zcode/plans/m3-iter10-addendum-task-2026-10-01.md` | + +## 五、合并前待办(用户点名后执行) 1. 目检清单第一节实机执行(或先合并后目检,由用户裁)。 -2. 代拍板第二节逐条追认(或全按推荐走到底一句代总)。 +2. e 项白话说明后追认。 3. 台账(active.md)/roadmap 迭代 10 行改写——按约定**推迟到合并批准时一并落籍**(分支上未动两共享文件)。 -4. MC 双窗读数报告判读(通关率下压方向是否符合预期带;AP 轴强度读数)→ 归曲线调参批联动。 +4. MC 三锚读数报告判读(含追加批共享池经济面变化)→ 归曲线调参批联动。 diff --git a/design/registry/entities.yaml b/design/registry/entities.yaml index 7de7bf68..c7620903 100644 --- a/design/registry/entities.yaml +++ b/design/registry/entities.yaml @@ -620,7 +620,7 @@ entities: table: MetaTable(数据配置 承载——元进度实现批 2026-09-21 建表落籍;schema data/schemas/meta_table.gd + data/tables/meta.tres) kind: 元进度/永久强化数值行单点(定价 / Boss 两档 / 工坊阈值 / 换算率四行族——消费面一律走 ConfigDB.meta_* 访问器,禁直读行集) row_families: forge_products {attr ∈ {max_hp, atk, armor_class} 恰三, display_name, flat_value, base_price, price_ratio_pct ∈ [100,400]} · boss_keystone {level_index 连续从 1, first_kill ≥ repeat_kill} · workshop_items {item_id ∈ registry aux_items 六件词表恰全, display_name, clear_threshold ∈ [1,99]} · conversion {attr 与 forge 词表一致恰三, per_level_shift_pct ∈ [0,100]} - provisional: 数值全 provisional 归元进度设计批配读数(骨架先行先例 = GP+商店房批)——现行落表值:flat 血+3/攻+1/甲+1、base 10 ratio 150 三行同;Boss 两档 **首杀 3 / 复杀 2 三关统一**(**迭代 10 奖励重构批重锚**——原 L1 5/1 · L2 8/2 · L3 12/3 退役;P-B 乙拍板贴近「Boss 掉 2–3 个」示例量级,子选项「复杀统一 2」归代拍板白名单待追认;精英掉 1 预留行随怪物批实装落);工坊阈值 1/1/2/2/3/3(按总通关次数);换算率全 0 占位 + provisional: 数值全 provisional 归元进度设计批配读数(骨架先行先例 = GP+商店房批)——现行落表值:flat 血+3/攻+1/甲+1、base 10 ratio 150 三行同;Boss 两档 **首杀 3 三关统一 / 复杀按关 2/2/3 递进**(**迭代 10 奖励重构批重锚 + 用户 2026-10-01 裁「a 用备选」**——原 L1 5/1 · L2 8/2 · L3 12/3 与代拍板「复杀统一 2」均退役;P-B 乙拍板贴近「Boss 掉 2–3 个」示例量级,L3 3/3 系 first≥repeat 结构约束的合法端点;精英掉 1 预留行随怪物批实装落);工坊阈值 1/1/2/2/3/3(按总通关次数);换算率全 0 占位 impl: core/meta_progression.gd(MetaProgression 账本服务——余额/几何定价〔tier 级价格 = 迭代 floor(p × ratio/100)〕/购买/工坊解锁〔总通关次数渠道〕/settle_adventure Boss 两档入账/hero_override 注入计算;Core 层零 UI 零信号) workshop_unlock_rule: 首版只挂总通关次数渠道(Σ level_clear_counts 达阈值——2026-09-21 实现批代拍板 2 并经用户确认,GDD 未细分);**channel 可替换轴**(同日用户拍板:解锁条件须抽象、可替换、可扩展——渠道由 workshop_items.channel 字段声明〔缺省 total_clears,词表 MetaTable.WORKSHOP_CHANNELS 封闭〕,判定量换算单点 = MetaProgression._workshop_progress,换/加渠道 = 词表加值 + 一 match 分支,阈值比较与解锁写档零改动;未知渠道恒 0 = 永不解锁 fail-safe;候选 adventure_depth 最高通关深度 / keystone 直购双渠道已预留语义未实现);钥石直购价随定价批后补 referenced_by: [元进度, 存档序列化, 数据配置] diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 073a76a7..4f01e819 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -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,同种子同三候选) diff --git a/game/core/aux_items.gd b/game/core/aux_items.gd index d32c57be..dc204754 100644 --- a/game/core/aux_items.gd +++ b/game/core/aux_items.gd @@ -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——任一效果有信息即可用); # 纯预读行 = 板上还有未开格才可用。 diff --git a/game/data/tables/meta.tres b/game/data/tables/meta.tres index 870d3fd7..69f56a3a 100644 --- a/game/data/tables/meta.tres +++ b/game/data/tables/meta.tres @@ -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 归曲线调参批。 diff --git a/game/presentation/battle_narrator.gd b/game/presentation/battle_narrator.gd index f9a43bd6..96e94f90 100644 --- a/game/presentation/battle_narrator.gd +++ b/game/presentation/battle_narrator.gd @@ -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 := "冒险失败 · 未打穿" diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 3c008921..fe77f727 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -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: diff --git a/game/presentation/views/bonus_pick_overlay.gd b/game/presentation/views/bonus_pick_overlay.gd index 3d964fc6..4c4b47e3 100644 --- a/game/presentation/views/bonus_pick_overlay.gd +++ b/game/presentation/views/bonus_pick_overlay.gd @@ -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)) diff --git a/game/presentation/views/node_overlay.gd b/game/presentation/views/node_overlay.gd index 9db5468f..e80f9a8e 100644 --- a/game/presentation/views/node_overlay.gd +++ b/game/presentation/views/node_overlay.gd @@ -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() diff --git a/game/smoke/level_autoplay.gd b/game/smoke/level_autoplay.gd index 1d96b83d..a0654b19 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/smoke/level_autoplay.gd @@ -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" diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 27e748c0..17d0ef00 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -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): diff --git a/game/tests/unit/presentation/reward_overlay_test.gd b/game/tests/unit/presentation/reward_overlay_test.gd index 7453f1c4..e4fc85f7 100644 --- a/game/tests/unit/presentation/reward_overlay_test.gd +++ b/game/tests/unit/presentation/reward_overlay_test.gd @@ -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)