diff --git a/design/gdd/06-待办清单.md b/design/gdd/06-待办清单.md index 79206aa8..5b28d916 100644 --- a/design/gdd/06-待办清单.md +++ b/design/gdd/06-待办清单.md @@ -238,7 +238,7 @@ **状态**:已登记(2026-10-01,迭代 10 批随批) -#### 奖励池三档位令牌池重设计+MC 策略改判(2026-10-03 用户全裁——b/f 终态,待 M-4 数值批执行) +#### 奖励池三档位令牌池重设计+MC 策略改判(2026-10-03 用户全裁——b/f 终态;**已落地(2026-10-03 迭代 12 TP0)**) **设计(已定稿零歧义,用户逐项确认)**:三选一池改**固定配额令牌池**(用户否决浮动独立概率)——每次洗牌不放回抽 3 张候选、玩家选中的那张**踢出池子**(未选 2 张回池),池耗即枯竭: @@ -246,11 +246,13 @@ |---|---|---| | 普通 | 70 | 小GP 18 / 小增伤 18 / 小护甲 17 / 小生命上限 17 | | 稀有 | 25 | 大GP 5 / 大增伤 5 / 大护甲 5 / 大生命 5 / 恢复 5 | -| 罕见 | 5 | AP 3 / 道具件 2 | +| 罕见 | 3 | AP 3(~~道具件 2~~——**落地收窄**:随 aux_item 池种退役〔迭代 11 第①批 T7,落地晚于追认 b〕不投放,池总量 100→98——TP0 台账自决披露项待追认) | -数值对:小GP 20/大GP 50、小增伤+1/大+2、小护甲+1/大+2、小生命+5/大+10、恢复+6(当即回血)、AP+1、道具 1 件。**种类次数上限退役**(池消耗即上限;道具件同调位上限系另一机制保留)。**MC 策略改判**:1 级优先度=最稀有(档位最高)、2 级=最左(确定性,替代权重随机,候选并列同档取最左);奖励构筑协同深化(哈迪斯/死亡细胞类 build 对模拟的影响)归后续内容批轻登记。 +数值对:小GP 20/大GP 50、小增伤+1/大+2、小护甲+1/大+2、小生命+5/大+10、恢复+6(当即回血)、AP+1(~~道具 1 件~~随池种退役不投放)。**种类次数上限退役**(池消耗即上限;道具件同调位上限系另一机制保留)。**MC 策略改判**:1 级优先度=最稀有(档位最高)、2 级=最左(确定性,替代权重随机,候选并列同档取最左);奖励构筑协同深化(哈迪斯/死亡细胞类 build 对模拟的影响)归后续内容批轻登记。 -**归属**:M-4 数值批(与带 A 收敛同窗——奖励经济扰动通关率读数,批内自取改前/改后锚) +**落地状态(2026-10-03 迭代 12 TP0——交接 prompt §四授权实施)**:池结构/schema validate 硬断言(70/25/3 结构锚)/抽签(Fisher-Yates 同 kind 去重每 kind 至多一枚)/恢复件入池/316-f autoplay 单点/货架令牌级抽签(价格 tier-blind 维持批②裁定)/断点扩键 bonus_deck 全落;normal 档重锚=runsum_m4iter12_tp0_n20_20261003(同 seed0 配对读数同带 0/5/0,无结构位移);文档面=node-reward v2.4+冒险结构 v0.14+registry;旧锚(77000-81000/iter11 off/on)随池换代作废只测不判。 + +**归属**:~~M-4 数值批~~ → 已随迭代 12 TP0 落地(交接 prompt 开工闸查 3 授权路径;终值复核归 M-4 数值批) **状态**:已全裁待执行(设计定稿待实施;现行平铺池+权重随机维持到实施批) diff --git a/design/gdd/systems/冒险循环/冒险结构.md b/design/gdd/systems/冒险循环/冒险结构.md index bb7133f5..27da6831 100644 --- a/design/gdd/systems/冒险循环/冒险结构.md +++ b/design/gdd/systems/冒险循环/冒险结构.md @@ -147,11 +147,11 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 **战斗内缓冲隔离**:战斗进行中从格面兑付拿到的 GP(金币袋正向、破财负向——字典归[地图战斗资源](../战斗循环/地图战斗资源.md))不直接进钱包,而是记入战斗域的中间态缓冲;战斗完成时缓冲随掉落掷定与关级加值一并折进 gp_dropped 统一到账。钱包只在战斗边界变动,战斗域自身零钱包状态——这条隔离让战斗结算保持"折算归我、账本归冒险结构"的既有边界。 -**统一冒险加算区**:商店购买的属性提升与提升三选一的属性件记**同一本账**——加算区四轴 {攻击, 生命上限, 护甲等级, 行动点},入战参数 = 基础表值 + 加算区累加,经战斗开始入参的覆盖值通道生效(与生命账本的上参同一条通道)。购买生命上限时:pool 模式(v0.13——货架件=奖励件本体)只加上限不回当前生命(与三选一件同语义);legacy 模式当前生命与上限同步 +Δ(生命账本的连带写入口)。属性轴累加源 = 商店购买与三选一属性件两类;行动点轴累加源 = 三选一 AP 件与商店 AP 件两类(每场战斗开始的首轮行动点基线 +1,语义细节归[行动经济](../排雷循环/行动经济.md)跨冒险加成轴节;全冒险获取次数上限最紧,provisional 2 次——pool 模式下商店购买与三选一共用该库存,见下)。 +**统一冒险加算区**:商店购买的属性提升与提升三选一的属性件记**同一本账**——加算区四轴 {攻击, 生命上限, 护甲等级, 行动点},入战参数 = 基础表值 + 加算区累加,经战斗开始入参的覆盖值通道生效(与生命账本的上参同一条通道)。购买生命上限时:pool 模式(v0.13——货架件=奖励件本体)只加上限不回当前生命(与三选一件同语义);legacy 模式当前生命与上限同步 +Δ(生命账本的连带写入口)。属性轴累加源 = 商店购买与三选一属性件两类;行动点轴累加源 = 三选一 AP 件与商店 AP 件两类(每场战斗开始的首轮行动点基线 +1,语义细节归[行动经济](../排雷循环/行动经济.md)跨冒险加成轴节;~~全冒险获取次数上限最紧 provisional 2 次~~——v0.14 次数上限退役,池消耗即上限,pool 模式下商店购买与三选一共用令牌库,见下)。 掉落掷定消费 gp_drop 封闭随机流(主种子派生、可回放)。**商店货架抽签消费 shop_draw 封闭随机流**(v0.13 扩签——每次抽 3〔进店+每次买后重抽〕恰一 scope,与 reward_pick 分签防取数序互扰)。掉落与价格的现行行值读商店表(registry 为准),数值所有权归[难度曲线与Boss](难度曲线与Boss.md)曲线调参批——本批骨架先行,全部数值 provisional。 -**共享池账本(v0.13——P1 共享池单点)**:商店货架与提升三选一是**同一个池**——货架候选 = 池∖{GP 袋}(用钱买钱恒负交易)∖{魔法道具件}(三轴 doctrine,机制件永不入店)= {攻击, 生命上限, 护甲, 行动点} 四种;库存 = 上限即库存(属性件各 3、AP 件 2——与「达上限剔牌库」同一账本承载,全冒险一次性跨关卡不补给):商店买一件,奖励面就少一件;「无限购买」收敛为**钱尽或池空为止**(池空=硬闸、A 链爬价=软闸),空货架合法态。两渠道不得各自私记库存——消费走同一入口写同一账本(实现=AdventureRunner._bonus_taken 单点,冒险结构 owner)。 +**共享池账本(v0.13 P1 共享池单点;v0.14 令牌库换代)**:商店货架与提升三选一是**同一个池**——货架候选 = 池∖{GP 袋}(用钱买钱恒负交易)= {攻击, 生命上限, 护甲, 行动点} 四种(魔法道具件已随同调批出池);**库存 = 剩余令牌数**(TP0——「池消耗即上限」,种类次数上限退役,与奖励兑付摘牌同一令牌库承载,全冒险一次性跨关卡不补给):商店买一件,奖励面就少一枚;「无限购买」收敛为**钱尽或池空为止**(池空=硬闸、A 链爬价=软闸),空货架合法态。货架 = **令牌级抽签**(同 kind 去重每 kind 至多一卡,价格公式 tier-blind 维持批②裁定,购买消费在架令牌本体 kind+tier)。两渠道不得各自私记库存——消费走同一入口写同一令牌库(实现=AdventureRunner._bonus_deck 单点,冒险结构 owner;kind 维度派生计数 _bonus_taken 承载断点附随件与读数)。 ### 关卡大奖励:通关回血 + 提升三选一(钥石退役出大奖励) @@ -167,18 +167,20 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 兑付次序:部分回血先行(按当刻账本与上限结算),随后呈现三选一**选择停点**——零自动推进,必须玩家点选才兑付,重复兑付被拒(结算受理只算一次语义同族)。选择停点可中断:断点附随件扩「奖励选择态 + 件获取次数计数」,续玩已选件不静默丢失;**候选逐位一致系停点快照承载的实现现状、非验收义务**(不一致不影响玩法——2026-10-01 用户裁决,见「冒险断点」节)。 -**提升池六种**(件值/权重/次数上限全读冒险表,provisional 归[难度曲线与Boss](难度曲线与Boss.md)曲线调参批): +**提升池六种×档位(v0.14——TP0 令牌池换代;配额/数值对全读冒险表,provisional 归[难度曲线与Boss](难度曲线与Boss.md)曲线调参批)**: -| 件 | 兑付 | 生效方式 | 权重 | 次数上限 | +| 件 | 兑付 | 生效方式 | 档位构成(配额) | 件值(小/大) | |---|---|---|---|---| -| 攻击力提升(flat) | 加算区攻击轴累加 | 覆盖值通道,对后续每战生效 | 3 | 3 次 | -| 生命上限提升(flat) | 加算区生命上限轴累加 | 覆盖值通道(不连带当前生命) | 3 | 3 次 | -| 护甲等级提升(flat) | 加算区护甲轴累加 | 覆盖值通道 | 3 | 3 次 | -| GP 袋(定额入钱包) | GP 冒险账本入账 | 钱包收口扩「奖励入账」入口 | 3 | 无次数上限 | -| 魔法道具件(随机一件入库存) | 道具授予既有路由入库存 | 受同调位约束(见下) | 1 | 无次数上限(受同调位) | -| 行动点件(AP +1/战) | 加算区行动点轴累加 | 每场战斗开始首轮行动点基线 +1 | 1 | 2 次(全冒险最紧) | +| 攻击力提升(flat) | 加算区攻击轴累加 | 覆盖值通道,对后续每战生效 | 普通 18 / 稀有 5 | +1 / +2 | +| 生命上限提升(flat) | 加算区生命上限轴累加 | 覆盖值通道(不连带当前生命) | 普通 17 / 稀有 5 | +5 / +10 | +| 护甲等级提升(flat) | 加算区护甲轴累加 | 覆盖值通道 | 普通 17 / 稀有 5 | +1 / +2 | +| GP 袋(定额入钱包) | GP 冒险账本入账 | 钱包收口扩「奖励入账」入口 | 普通 18 / 稀有 5 | 20 / 50 | +| 恢复件(v2.2 新入池种类) | 当即回血(封顶生效上限) | 生命账本直改 | 稀有 5 | +6 | +| 行动点件(AP +1/战) | 加算区行动点轴累加 | 每场战斗开始首轮行动点基线 +1 | 罕见 3 | +1 | -**抽取机制 = 加权洗牌不放回**(禁等概率真随机):weight-key 洗牌(每件 key = U(0,1)^(1/权重) 取前 3,Efraimidis-Spirakis),单次三选一零重复、逐件按权重出概率;消费 reward_pick 封闭随机流(主种子派生、[数据配置](../表现与支撑/数据配置.md)口径),同种子同操作序列同三选项可复放。达任何次数上限的件**先剔出牌库再洗牌**(AP 用尽 2 次、属性件达 3 次后永不再现)——防「三选项全不可选」死角(GP/道具件无次数上限,牌库恒非空)。**提升池为冒险级单例**(迭代 10 追加批):关卡内奖励节点的三选一与通关三选一共享同一池、同一权重与同一次数上限(两渠道合计判上限),兑付语义归[关卡节点地图](关卡节点地图.md)。 +~~魔法道具件~~(池种退役——迭代 11 第①批 T7 出池;罕见档原列「道具件 2」席位随之收窄,池总量 100→98——TP0 台账自决披露项)。 + +**抽取机制 = 令牌池洗牌不放回(v0.14 追认 b 实施形态,取代 E-S 加权洗牌)**:池 = 固定配额令牌库(普通 70/稀有 25/罕见 3=98 张,kind×tier 多重集);洗牌 = Fisher-Yates 不放回抽 3(**同 kind 去重每 kind 至多一枚**——「三选一=三种不同选项」体验延续;kind 出现率∝剩余令牌数,档位配额即分布、无独立权重 knob);**选中踢池、未选回池**(抽签不摘牌、兑付才摘牌);**池耗即枯竭**(余库不足出齐剩余,空库=零候选合法态直通);**种类次数上限退役**(池消耗即上限)。消费 reward_pick 封闭随机流(主种子派生、[数据配置](../表现与支撑/数据配置.md)口径),同种子同操作序列同三选项可复放。**MC 策略 316-f**:autoplay 1 级优先度=最稀有(rarest>rare>common)、2 级=最左。**提升池为冒险级单例**(迭代 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 在册)。 @@ -201,7 +203,7 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 冒险进行中随时关游戏走人(**中断**),下次启动可从最近停点继续——承载 = 存档 `breakpoint` 域(格式归[存档序列化](../表现与支撑/存档序列化.md),内容口径归本文)。 - **停点粒度**:快照只挂选路停点(入口呈现/战斗胜利后/兑付确认后的选路态)——这是唯一可恢复停点形态。战斗进行中不写快照:中断恢复后**当场战斗从头打**,棋盘中局不入档。 -- **七字段拍板面**:关卡序号 / 节点位 / 生命 / 本关加成区 / GP 钱包 / 商店已购档 / 已抽模板。附随件(续玩正确性必需,随批登记):主种子 + 取数计数器(rng 快照契约回填——续玩从断点号位精确续接原随机时间线,已抽模板零重抽)、钥石累计(Boss 掉落逐杀入账的中途值,终局摘要不失真)、已到节点序(关卡画面图条重建)、魔法道具库存(奖励节点已选件防静默丢失)、魔法道具库存(奖励节点已选件防静默丢失)、**奖励选择态**(三选一停点中断:已选件 / 同调位替换在途——续玩已选不丢失;候选一致性=快照现状非强求 feature,2026-10-01 用户裁决)、**件获取次数计数**(攻击/生命上限/护甲/行动点四件各自已获取次数——次数上限判定不可由加算区累加值反推,牌库剔除态依赖它,迭代 10 扩)、**商店货架态**(当前货架三件 + 本次进店已购次数——P2 甲扩列随迭代 11 第②批;离开店=空架合法态,货架续玩由确定性重放承载:重进店同种子同货架逐位)。 +- **七字段拍板面**:关卡序号 / 节点位 / 生命 / 本关加成区 / GP 钱包 / 商店已购档 / 已抽模板。附随件(续玩正确性必需,随批登记):主种子 + 取数计数器(rng 快照契约回填——续玩从断点号位精确续接原随机时间线,已抽模板零重抽)、钥石累计(Boss 掉落逐杀入账的中途值,终局摘要不失真)、已到节点序(关卡画面图条重建)、魔法道具库存(奖励节点已选件防静默丢失)、魔法道具库存(奖励节点已选件防静默丢失)、**奖励选择态**(三选一停点中断:已选件 / 同调位替换在途——续玩已选不丢失;候选一致性=快照现状非强求 feature,2026-10-01 用户裁决)、**件获取次数计数**(kind 维度派生账本——断点附随件与读数承载)、**令牌池余库**(bonus_deck 逐枚还原——TP0 扩列,池消耗即上限的续玩正确性承载;旧版断点缺键=满库重建防御口径,单版本周期)、**商店货架态**(当前货架三件 + 本次进店已购次数——P2 甲扩列随迭代 11 第②批;离开店=空架合法态,货架续玩由确定性重放承载:重进店同种子同货架逐位)。 - **中断与退出两口径分离**(2026-09-29 用户裁决):中断(关游戏)= 留断点可续;**退出冒险**(放弃流程)= 失败同型结算 + **清除断点**——退出后「继续上局」不再出现。 - **续玩**:自断点重建到最近停点(账面逐字段回填 + 随机流回填),停点处玩家重新选路。三结局(通关/失败/放弃)一律清断点——断点只活在一个进行中的冒险上。 - 与测量面的关系:断点面只在注入存档承载的正式冒险路径生效;模拟/冒烟不注入 = 整面静默,MC 对拍零扰动。 @@ -276,7 +278,7 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 | 上限提升 | int [0,999],冒险表逐件列 | adventure_table bonus_attack_flat / bonus_max_hp_flat / bonus_armor_flat(provisional) | 三选一属性件各自兑付值;值锚与上调约束归难度曲线与Boss | | 三选一件值(其余) | int [0,999],冒险表逐件列 | adventure_table bonus_gp_amount / bonus_ap_flat / bonus_aux_item_count(provisional) | GP 袋定额 / AP 件每战增量 / 道具件授予件数 | | 三选一权重 | int [1,99],冒险表逐件键 | adventure_table bonus_weight_attack / _max_hp / _armor / _gp(各 3)/ bonus_weight_aux / _ap(各 1)(provisional) | 加权洗牌不放回抽样权重(E-S weight-key) | -| 件次数上限 | int [0,99],冒险表逐件键 | adventure_table bonus_cap_attack / _max_hp / _armor(各 3)/ bonus_cap_ap(2)(provisional) | 达上限件先剔出牌库永不再现;GP/道具件无次数上限 | +| 件次数上限 | ~~int [0,99],冒险表逐件键~~(v0.14 退役——池消耗即上限) | adventure_table token_* 配额列族(TP0 令牌池——kind×tier 配额) | 达上限件先剔出牌库永不再现 → 池耗即枯竭;GP/道具件无次数上限 → 同池共耗 | | 通关回血比例 | int [0,100],冒险表单行全局 | adventure_table clear_heal_pct(provisional 20) | 通关部分回血回复生效上限的百分比;封顶上限不回满 | | 同调位过渡键 | int [0,9],冒险表单行全局 | adventure_table transitional_sync_slots(provisional 1) | 道具持有上限过渡读数源;正主 = 同调位里程碑谓词表(同调实施批,切换义务在册) | | Boss 钥石掉落 | int [0,999],meta 表逐关行 | meta.tres boss_keystone 首杀 3 / 复杀 2(provisional) | 逐杀实时入账;精英怪掉 1 预留;消费方归元进度 | @@ -329,3 +331,4 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 | v0.11 | 2026-09-29 | **冒险断点批(迭代 8 卡①③——中断可续玩/退出即结算两口径分离)**:核心规则新增「冒险断点」节(停点粒度/七字段拍板面+附随件/中断退出两口径/续玩语义/测量面静默);依赖表存档序列化行补断点域 owner 登记;验收标准 GP 不留痕条补断点域豁免口径。实现落点 = AdventureRunner 快照/清除/续玩 + SaveManager breakpoint 域 + rng restore_adventure | | 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) | diff --git a/design/gdd/ux/node-reward.md b/design/gdd/ux/node-reward.md index e536a3cb..f4def627 100644 --- a/design/gdd/ux/node-reward.md +++ b/design/gdd/ux/node-reward.md @@ -1,12 +1,12 @@ # 节点奖励屏 UX spec(node-reward) -> 状态:v2.2(2026-10-03 迭代 11 第①批同调实施批——aux_item 池种退役出三选一牌库,池五种;新增三选一刷新件面) +> 状态:v2.4(2026-10-03 TP0 令牌池落地——三选一池改固定配额令牌池 70/25/3=98 张〔罕见档随道具件池种退役收窄〕,kind 六种+档位两维,恢复件新入池,MC 策略 316-f 落地) > 事实源:`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)(提升池单例与次数上限) +> 相邻:[level-clear](level-clear.md)(通关三选一面——同一覆盖层的另一上下文)/ [level-map](level-map.md)(关卡画面)/ [魔法道具](../systems/排雷循环/魔法道具.md)(道具件内容权威——本批起为局外同调件,不再入三选一牌库)/ [冒险结构](../systems/冒险循环/冒险结构.md)(共享池令牌库账本) ## 定位 -奖励节点(reward node)到达即开的**提升三选一选择面**:与通关三选一共享同一提升池(**五种**:攻击/生命上限/护甲/GP 袋/行动点件——v2.2 起魔法道具件随同调批退役出池)——同权重、同全冒险次数上限、同 reward_pick 封闭流。与通关面的差异仅在上下文:无部分回血、无钥石增量行,兑付后「继续」回图选路(不推进关卡)。 +奖励节点(reward node)到达即开的**提升三选一选择面**:与通关三选一共享同一令牌池(**六种×档位**:攻击/生命上限/护甲/GP 袋/行动点件/恢复件 × 普通/稀有/罕见——v2.2 起魔法道具件随同调批退役出池;v2.4 起=固定配额令牌池)——同库、同池耗语义、同 reward_pick 封闭流。与通关面的差异仅在上下文:无部分回血、无钥石增量行,兑付后「继续」回图选路(不推进关卡)。 ## 布局与交互 @@ -18,9 +18,9 @@ ## 口径 -- **同池同上限**:件获取次数计数为冒险级单例——奖励节点与通关三选一两渠道合计判上限(达上限件在两渠道同样剔牌库);权重与牌库口径读冒险表(registry adventure_table 为准)。**v2.3 起第三渠道并账**:商店货架=同池付费渠道(买一件=奖励面少一件),库存=上限即库存,账本语义与切边注记归[冒险结构](../systems/冒险循环/冒险结构.md)共享池账本节。 +- **同库同池耗(v2.4)**:池=固定配额令牌库(普通 70/稀有 25/罕见 3=98 张;罕见档原列「道具件 2」随 aux_item 池种退役〔迭代 11 第①批 T7,落地晚于追认 b〕收窄——台账自决披露项);洗牌不放回抽 3(**同 kind 去重每 kind 至多一枚**——「三种不同选项」体验延续,kind 出现率∝剩余令牌数)、选中踢池未选回池、池耗即枯竭(余库不足出齐剩余,空库=零候选合法态直通);**种类次数上限退役**(池消耗即上限)。数值对:小GP 20/大GP 50、小增伤 +1/大 +2、小护甲 +1/大 +2、小生命 +5/大 +10、恢复 +6 当即回血(封顶生效上限)、AP +1。**MC 策略 316-f**:1 级优先度=最稀有(rarest>rare>common)、2 级=最左(并列同档取最左)。配额结构锚与档位构成读冒险表(registry adventure_table 为准——schema validate 硬断言 70/25/3)。**v2.3 第三渠道并账维持**:商店货架=同库付费渠道(令牌级货架,买一件=奖励面少一件),库存=剩余令牌数,账本语义与切边注记归[冒险结构](../systems/冒险循环/冒险结构.md)共享池账本节。 - ~~道具件入本关库存~~(v2.2 退役——魔法道具局内不掉落机制道具,三轴 doctrine 锁死)。 -- 「池空直通」合法态随共享池退役:池含无次数上限的 GP 件,牌库恒非空(GP 袋对商店面切边、奖励面照常——奖励牌库恒非空结构保证不变)。 +- ~~「池空直通」合法态~~(v2.4 随令牌池换代退役又回归异形:旧口径=GP 无上限保证牌库恒非空;现口径=98 张池真可耗尽,空库=零候选直通合法态——池耗即枯竭既定后果)。 ## 版本历史 @@ -32,3 +32,4 @@ | v2.2 | 2026-10-03 | **池种退役+刷新件面(迭代 11 第①批同调实施批 T7/T5b——P1 甲)**:①aux_item 池种退役出三选一牌库(BONUS_IDS 除名+权重/件数/掷定前置字段随批退役)——魔法道具改走营地同调装配面(三轴 doctrine),v2.1 直显/满位替换两面随渠道退役(机械惰性承载零投放);②三选一刷新件面新增(X/Y 双源:刷新件每碰三选一重给优先消耗不结转+营地刷新升级冒险周期池;重抽消费 reward_pick 既有洗牌流池规则全保留;autoplay 默认不刷) | | v2.2 | 2026-10-03 | **三档位令牌池(用户全裁——待 M-4 数值批实施,现行 v2.1 平铺池维持到实施)**:池改固定配额令牌池(普通 70/稀有 25/罕见 5=100 张;洗牌不放回抽 3、选中踢池未选回池、池耗即枯竭);档位构成=普通〔小GP 18/小增伤 18/小护甲 17/小生命 17〕+稀有〔大GP/大增伤/大护甲/大生命/恢复 各 5〕+罕见〔AP 3/道具 2〕;数值对 20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1、道具 1 件;**种类次数上限退役**(池消耗即上限;同调位上限保留);恢复件=新入池种类;MC 策略改判 1 级最稀有/2 级最左(确定性)——设计全稿与执行口径见 [06 待办「奖励池三档位令牌池重设计」条](../06-待办清单.md);卡片呈现面(本 spec 交互节)零改动 | | v2.3 | 2026-10-03 | **商店并账注记(迭代 11 第②批 GP 商店房重构批——P1 共享池)**:口径节「同池同上限」扩第三渠道=商店货架(pool 模式付费渠道,买一件=奖励面少一件;库存=上限即库存全冒险一次性);本 spec 交互面零改动(商店货架面归 shop.md 与 ShopOverlay,非三选一覆盖层);GP 袋对商店面切边(负交易)、奖励面照常(牌库恒非空结构保证不变) | +| v2.4 | 2026-10-03 | **令牌池落地(迭代 12 TP0——追认 b 实施批)**:三选一池改固定配额令牌池(普通 70/稀有 25/罕见 3=98 张;罕见档原列「道具件 2」随 aux_item 池种退役收窄——台账自决披露项);洗牌=令牌 Fisher-Yates 不放回抽 3 同 kind 去重每 kind 至多一枚、选中踢池未选回池、池耗即枯竭(空库=零候选直通合法态);种类次数上限退役;恢复件=新入池种类(rare 档 +6 当即回血);kind 词表扩 heal 六种;MC 策略 316-f 落地(1 级最稀有/2 级最左);货架=令牌级抽签(价格公式 tier-blind 维持批②裁定);断点扩键 bonus_deck(余库逐枚还原);现行基线锚=runsum_m4iter12_tp0_n20_20261003(normal 档配对读数同带) | diff --git a/design/registry/entities.yaml b/design/registry/entities.yaml index fc14cfa0..30eddaf3 100644 --- a/design/registry/entities.yaml +++ b/design/registry/entities.yaml @@ -584,8 +584,9 @@ entities: clear_heal_pct: int [0,100](knob 默认 20——非末关通关部分回血回复生效上限的百分比,封顶上限不回满、满血回 0 合法;难度上压方向,带重扫义务归 难度曲线与Boss) bonus_attack_flat / bonus_max_hp_flat / bonus_armor_flat: int [0,999](三选一属性件兑付值——加算区攻击/生命上限/护甲轴累加,经覆盖值通道;值锚与上调约束归 难度曲线与Boss) bonus_gp_amount: int [0,999](GP 袋件定额入钱包);bonus_ap_flat: int [0,10](AP 件每场战斗首轮行动点基线增量);bonus_aux_item_count: int [1,9](魔法道具件授予件数) - bonus_weight_attack / bonus_weight_max_hp / bonus_weight_armor / bonus_weight_gp: int [1,99](加权洗牌抽样权重,默认各 3);bonus_weight_aux / bonus_weight_ap: int [1,99](默认 1) - bonus_cap_attack / bonus_cap_max_hp / bonus_cap_armor: int [0,99](属性件全冒险获取次数上限,默认各 3——达上限件先剔出牌库永不再现);bonus_cap_ap: int [0,99](AP 件上限,默认 2——全冒险最紧) + ~~bonus_weight_*(E-S 加权洗牌权重族)~~ / ~~bonus_cap_*(种类次数上限族)~~:**v2.2 令牌池换代退役(迭代 12 TP0——2026-10-03 追认 b 实施批;洗牌改令牌 Fisher-Yates、池消耗即上限)** + token_{kind}_{tier}: int [0,99](**TP0 令牌配额族**——kind×tier 计数:attack 18/5 · max_hp 17/5 · armor 17/5 · gp 18/5 · heal 0/5 · ap 0/0/3〔common/rare/rarest〕;未列组合=0;档位结构锚 70/25/3=98 张入 schema validate 硬断言〔罕见档原列「道具件 2」随 aux_item 池种退役收窄——台账自决披露项〕) + bonus_attack_flat_large / bonus_max_hp_flat_large / bonus_armor_flat_large / bonus_gp_amount_large: int [0,999](稀有档大数值对——+2/+10/+2/50);bonus_heal_flat: int [0,999](恢复件 +6 当即回血——新入池种类 rare 档);MC 策略 316-f(autoplay 1 级最稀有/2 级最左)随批落地 transitional_sync_slots: int [0,9](同调位过渡读数源,默认 1——道具持有上限;正主 = attunement_growth.tres 里程碑谓词表,同调实施批落地后切换读数源) mvp_content: 单行全局默认值即 MVP 行(provisional 归 曲线调参批——迭代 10 扩列批注) extension_reserve: 大奖励形态扩列位已消费(迭代 10 奖励重构批落地三选一/攻击型——本键使命完成退役;冒险结构 OQ2 收口) diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 621785cf..5164adfc 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -79,7 +79,8 @@ var attune_charges := {} # 同调主动件冒险充能账本( var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省) var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) # ---- 三选一停点态(迭代 10 奖励重构批)---- -var _bonus_taken := {} # 件获取次数计数(attack/max_hp/armor/ap——次数上限判定不可由加算区累加值反推,牌库剔除态依赖);迭代 11 第②批起兼作共享池库存账本(stock=cap−taken,商店购买同账本消费) +var _bonus_taken := {} # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载;池库存正主=_bonus_deck) +var _bonus_deck: Array = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账) var _bonus_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点 var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付) var _bonus_aux_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗) @@ -167,6 +168,14 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: var bp_taken: Dictionary = bp.get("bonus_taken", {}) as Dictionary for id in _bonus_taken.keys(): _bonus_taken[id] = maxi(0, int(bp_taken.get(id, 0))) + # 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径) + var bp_deck = bp.get("bonus_deck", null) + if bp_deck is Array: + var restored: Array = [] + for t in (bp_deck as Array): + var tok: Dictionary = t as Dictionary + restored.append({"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))}) + _bonus_deck = restored for v in (bp.get("bonus_aux_carry", []) as Array): _bonus_aux_carry.append(String(v)) _keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true) @@ -189,7 +198,14 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary if not bp_pending.is_empty(): _pending_next_level = true - _bonus_pending = {"candidates": (bp_pending.get("candidates", []) as Array).duplicate(true)} + # 候选令牌化迁移(TP0——旧版断点候选为 kind 字符串,按普通档令牌防御归一;单版本周期口径) + var migrated: Array = [] + for c in (bp_pending.get("candidates", []) as Array): + if c is Dictionary: + migrated.append({"kind": String((c as Dictionary).get("kind", "")), "tier": String((c as Dictionary).get("tier", ""))}) + else: + migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON}) + _bonus_pending = {"candidates": migrated} if bp_pending.has("aux_detail"): _bonus_pending["aux_detail"] = (bp_pending.get("aux_detail", []) as Array).duplicate(true) # 掷定前置随停点快照续接 var payload: Dictionary = (bp_pending.get("payload", {}) as Dictionary).duplicate(true) @@ -221,9 +237,10 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: return _start_current_level() -# 三选一停点态归零(冒险开始/结算收束共用) +# 三选一停点态归零(冒险开始/结算收束共用;令牌池按表配额重建——池消耗即上限,账本随池重生) func _reset_bonus_state() -> void: - _bonus_taken = {"attack": 0, "max_hp": 0, "armor": 0, "ap": 0} + _bonus_taken = {"attack": 0, "max_hp": 0, "armor": 0, "gp": 0, "ap": 0, "heal": 0} + _bonus_deck = _build_bonus_deck() _bonus_pending = {} _bonus_result = {} _bonus_aux_carry = [] @@ -231,6 +248,17 @@ func _reset_bonus_state() -> void: _node_bonus_pending = {} +# 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = BONUS_IDS×TIERS 逐层, +# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻) +func _build_bonus_deck() -> Array: + var deck: Array = [] + for kind in db.adventure.BONUS_IDS: + for tier in db.adventure.TIERS: + for i in int(db.adventure.bonus_token_count(String(kind), String(tier))): + deck.append({"kind": String(kind), "tier": String(tier)}) + return deck + + # TunePanel 百分比通道(测试面板覆盖——每关新 runner 都要应用;正式侧归元进度) func set_hero_override(atk_pct: int, hp_pct: int) -> void: _override = [atk_pct, hp_pct] @@ -279,32 +307,47 @@ func continue_to_next_level() -> bool: # ---- 提升三选一(迭代 10 奖励重构批——选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)---- -# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):id ∈ 停点候选。属性件写加算区+计数、AP 件写第四轴、 -# GP 件走钱包「奖励入账」入口、道具件受同调位约束(满位转替换子交互)。 -func pick_bonus(id: String) -> bool: +# 候选令牌匹配(kind+tier 恒等——同 kind 大小两档可同屏,kind 单键不构成唯一身份) +func _find_token(candidates: Array, kind: String, tier: String) -> Dictionary: + for t in candidates: + var tok: Dictionary = t as Dictionary + if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier: + return tok + return {} + + +# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(kind+tier 精确匹配)。 +# 属性件写加算区、AP 件写第四轴、GP 件走钱包「奖励入账」入口、恢复件当即回血、 +# 道具件受同调位约束(满位转替换子交互——随池种退役不可达,机械惰性保留)。 +# 消费=选中令牌踢池(未选回池由「抽签不摘牌、兑付才摘牌」语义承载——TP0 v2.2)。 +func pick_bonus(kind: String, tier: String = "") -> bool: if state != "running" or _bonus_pending.is_empty(): push_error("[冒险结构] pick_bonus 拒绝——无待选停点(state=%s)" % state) return false var candidates: Array = _bonus_pending.get("candidates", []) as Array - if not candidates.has(id): - push_error("[冒险结构] pick_bonus 拒绝——件不在候选: %s" % id) + if _find_token(candidates, kind, tier).is_empty(): + push_error("[冒险结构] pick_bonus 拒绝——令牌不在候选: %s|%s" % [kind, tier]) return false - var value: int = int(db.adventure.bonus_value(id)) + var value: int = int(db.adventure.bonus_value(kind, tier)) var hp_before := int(ledger.get("hero_hp", 0)) - if id == "aux_item": - return _pick_aux_item(value) # 道具件分派通关渠道(持有容器 = 跨关携带——与节点渠道分轨) - if not _apply_bonus_pick(id, value): - push_error("[冒险结构] pick_bonus 拒绝——未知件: %s" % id) + if kind == "aux_item": + return _pick_aux_item(value) # 道具件分派通关渠道(持有容器 = 跨关携带——与节点渠道分轨;池种退役后不可达) + if not _apply_bonus_pick(kind, tier, value): + push_error("[冒险结构] pick_bonus 拒绝——未知件: %s|%s" % [kind, tier]) return false - _finish_bonus_pick(id, value, hp_before) + _finish_bonus_pick(kind, value, hp_before, tier) return true -# 三选一件兑付共享体(2026-10-02 修复批——通关/奖励节点双渠道 match 双胞胎收口): -# 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包。道具件不入此体——两渠道持有容器 -# 语义不同(跨关携带 vs 关卡域库存),分派回调用方渠道处理;未知件返回 false 由调用方按渠道名报错。 -func _apply_bonus_pick(id: String, value: int) -> bool: - match id: +# 三选一件兑付共享体(2026-10-02 修复批双胞胎收口;TP0 改令牌消费——摘牌单点): +# 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包、恢复件当即回血(封顶生效上限—— +# 与账本置满/通关回血同式 effective_max_hp)。兑付即从 _bonus_deck 摘除该令牌(选中踢池; +# 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。 +func _apply_bonus_pick(kind: String, tier: String, value: int) -> bool: + if not _consume_deck_token(kind, tier): + push_error("[冒险结构] 令牌消费失败——牌库无此令牌: %s|%s" % [kind, tier]) + return false + match kind: "attack": bonus["atk"] = int(bonus.get("atk", 0)) + value _bonus_taken["attack"] = int(_bonus_taken.get("attack", 0)) + 1 @@ -320,31 +363,46 @@ func _apply_bonus_pick(id: String, value: int) -> bool: "gp": if _runner != null: _runner.grant_bonus_gp(value) + _bonus_taken["gp"] = int(_bonus_taken.get("gp", 0)) + 1 + "heal": + ledger["hero_hp"] = mini(int(ledger.get("hero_hp", 0)) + value, effective_max_hp()) + _bonus_taken["heal"] = int(_bonus_taken.get("heal", 0)) + 1 _: return false return true -# ---- 商店共享池消费面(迭代 11 第②批 T3——P1 共享池单点账本)---- +# 从牌库摘除一枚令牌(TP0——选中踢池单点;找不到 = false 由调用方拒绝) +func _consume_deck_token(kind: String, tier: String) -> bool: + for i in _bonus_deck.size(): + var tok: Dictionary = _bonus_deck[i] as Dictionary + if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier: + _bonus_deck.remove_at(i) + return true + return false -# 库存余量(stock = cap − taken;gp 无上限种返回大数——货架词表封闭不含 gp,本口仅供账面/防御读)。 -# 「上限即库存」(P1-a):奖励三选一达上限剔牌 与 商店购买扣库存 由同一 taken 账本承载——两渠道 -# 跨关共享 11 份有限库存(属性 3+3+3+AP 2),全冒险一次性(P1-b)。 + +# ---- 商店共享池消费面(迭代 11 第②批 T3——P1 共享池单点账本;TP0 令牌池换代——库存=牌库剩余令牌)---- + +# 库存余量(TP0:stock = 牌库内该 kind 剩余令牌数——「池消耗即上限」,种类次数上限退役; +# 奖励三选一兑付摘牌 与 商店购买消费 由同一 _bonus_deck 承载——两渠道跨关共库,全冒险一次性)。 func pool_stock(kind: String) -> int: - var cap := int(db.adventure.bonus_cap(kind)) - if cap == db.adventure.BONUS_CAP_UNLIMITED: - return 1 << 30 - return cap - int(_bonus_taken.get(kind, 0)) + var n := 0 + for t in _bonus_deck: + if String((t as Dictionary).get("kind", "")) == kind: + n += 1 + return n # 商店购买消费 handler(LevelRunner.buy_pool_item 经 Callable 调用——ADR-0001 S6 方法引用): -# 校验有限库存种且有库存 → 走 _apply_bonus_pick 同一入口写加算区+账本(与奖励三选一真单点—— -# 买一件=奖励面少一件的库存语义由同账本成立)。gp 件/未知件拒绝(货架词表封闭的前置防御)。 -func _consume_pool_purchase(kind: String) -> bool: - if not db.adventure.BONUS_IDS.has(kind) or pool_stock(kind) <= 0: - push_error("[冒险结构] 商店购买消费拒绝——件不在有限库存池或库存尽: %s" % kind) +# 消费在架令牌本体(kind+tier 精确——货架卡即令牌,TP0 令牌级货架)→ _apply_bonus_pick 同口 +# 写加算区+账本+摘牌(与奖励三选一真单点——买一件=奖励面少一件的库存语义由同库成立)。 +# 未知 kind 拒绝(货架词表封闭的前置防御)。 +func _consume_pool_purchase(kind: String, tier: String) -> bool: + if not db.adventure.BONUS_IDS.has(kind): + push_error("[冒险结构] 商店购买消费拒绝——件不在池词表: %s" % kind) return false - return _apply_bonus_pick(kind, int(db.adventure.bonus_value(kind))) + return _apply_bonus_pick(kind, tier, int(db.adventure.bonus_value(kind, tier))) # 终局池快照(_settle 清账前调用——_reset_bonus_state 会重置 _bonus_taken,摘要 additive 键 @@ -395,11 +453,13 @@ func resolve_aux_replace(discard_index: int) -> bool: return true -# 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付) -func _finish_bonus_pick(picked: String, value: int, hp_before: int) -> void: +# 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。 +# picked = kind;tier 随载荷(TP0——大小档位读数面) +func _finish_bonus_pick(picked: String, value: int, hp_before: int, tier: String = "") -> void: _bonus_pending = {} _bonus_result = { "picked": picked, + "tier": tier, "value": value, "hp_before": hp_before, "hp_after": int(ledger.get("hero_hp", 0)), @@ -424,22 +484,22 @@ func present_node_bonus(candidates: Array, aux_detail: Array = []) -> void: _node_bonus_pending = {"candidates": candidates.duplicate(true), "aux_detail": aux_detail.duplicate(true)} -# 点选兑付(节点域——无部分回血无钥石;件获取计数与牌库剔除与通关三选一同账) -func apply_node_bonus(id: String) -> bool: +# 点选兑付(节点域——无部分回血无钥石;令牌消费与牌库摘牌与通关三选一同库) +func apply_node_bonus(kind: String, tier: 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) + if _find_token(candidates, kind, tier).is_empty(): + push_error("[冒险结构] apply_node_bonus 拒绝——令牌不在候选: %s|%s" % [kind, tier]) return false - var value: int = int(db.adventure.bonus_value(id)) - if id == "aux_item": - return _apply_node_aux(value) # 道具件分派节点渠道(持有容器 = 关卡域库存——与通关渠道分轨) - if not _apply_bonus_pick(id, value): - push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s" % id) + var value: int = int(db.adventure.bonus_value(kind, tier)) + if kind == "aux_item": + return _apply_node_aux(value) # 道具件分派节点渠道(持有容器 = 关卡域库存——与通关渠道分轨;池种退役后不可达) + if not _apply_bonus_pick(kind, tier, value): + push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s|%s" % [kind, tier]) return false - _finish_node_bonus(id, value) + _finish_node_bonus(kind, value, tier) return true @@ -579,40 +639,42 @@ func resolve_bonus_replace(discard: int) -> bool: return resolve_aux_replace(discard) -func _finish_node_bonus(picked: String, value: int) -> void: +func _finish_node_bonus(picked: String, value: int, tier: String = "") -> void: _node_bonus_pending = {} bonus_resolved.emit({ "context": "reward_node", "picked": picked, + "tier": tier, "value": value, "level_index": level_index, }) -# 加权洗牌不放回掷 3(E-S weight-key:每件 key = U(0,1)^(1/权重) 取前 3——单次零重复、逐件按权重出 -# 概率;达次数上限件先剔牌库〔AP 用尽 2 次/属性件达 3 次永不再现;GP/道具件无次数上限,牌库恒非空〕; -# 道具件解锁池空集〔白板/门控态〕不入牌库。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同三候选; -# 道具件具体道具在本调用内掷定(_last_draw_aux——掷定前置,卡片直显,追加批二)。 +# 令牌池洗牌不放回抽 3(TP0 v2.2——Fisher-Yates 全库洗牌后按序收集;**同 kind 去重每 kind 至多 +# 一枚**〔先到先得,档位取该 kind 首枚——「三选一=三种不同选项」体验延续,与货架抽签同规则〕; +# kind 出现率∝剩余令牌数〔档位配额即分布——无独立权重 knob〕;抽签不摘牌、兑付才摘牌=未选 +# 自动回池;池耗即枯竭——余库不足 N kind 时出齐剩余,空库 = 零候选合法态)。候选 = 令牌字典 +# {"kind","tier"}。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同候选;道具件掷定前置 +# 随池种退役拆除(_last_draw_aux 恒空集,机械惰性承载)。 func _draw_bonus_candidates() -> Array: - var deck: Array = [] _last_draw_aux = [] - for id in db.adventure.BONUS_IDS: - var cap := int(db.adventure.bonus_cap(String(id))) - if cap != db.adventure.BONUS_CAP_UNLIMITED and int(_bonus_taken.get(String(id), 0)) >= cap: - continue - if String(id) == "aux_item" and _bonus_aux_pool().is_empty(): - continue - deck.append(String(id)) + var pool := _bonus_deck.duplicate() var rg: RandomNumberGenerator = rng.open_stream("reward_pick") - var keyed: Array = [] - for id in deck: - var w := float(maxi(1, int(db.adventure.bonus_weight(id)))) - keyed.append({"id": id, "key": pow(rg.randf(), 1.0 / w)}) - keyed.sort_custom(func(a, b): return float(a["key"]) > float(b["key"])) + for i in pool.size(): + var j := rg.randi_range(i, pool.size() - 1) + var tmp = pool[i] + pool[i] = pool[j] + pool[j] = tmp var out: Array = [] - for i in mini(int(db.adventure.bonus_candidate_count), keyed.size()): # 候选数读表(2026-10-02 魔法数结构化——默认 3 = 现行行为) - out.append(String(keyed[i]["id"])) - # 迭代 11 第①批 T7——aux_item 掷定前置分支随池种退役拆除(BONUS_IDS 已除名,候选恒无 aux 卡) + var seen: Array = [] + for tok in pool: + var k := String((tok as Dictionary).get("kind", "")) + if seen.has(k): + continue + seen.append(k) + out.append({"kind": k, "tier": String((tok as Dictionary).get("tier", ""))}) + if out.size() >= int(db.adventure.bonus_candidate_count): + break return out @@ -702,7 +764,7 @@ func _start_current_level() -> bool: _runner.set_attune_charges(attune_charges) # 迭代 11 第①批 T6——充能账本下传(能力件骑库存面注入) if _shop_mode == "pool": # 商店共享池注入(迭代 11 第②批 T3——库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动) - _runner.set_shop_pool(_bonus_taken, Callable(self, "_consume_pool_purchase")) + _runner.set_shop_pool(_bonus_deck, Callable(self, "_consume_pool_purchase")) # 三选一道具件跨关携带注入(迭代 10)——迭代 11 第①批 T7 退役:aux 卡出池后 carry 恒空集, # 注入面随停点渠道退役拆除(容器字段保留=替换子交互机械惰性承载,零投放零行为) level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) @@ -843,7 +905,8 @@ func _snapshot_breakpoint() -> void: "keystone": keystone, "visited_ids": _runner.visited_ids() if _runner != null else [], "aux_items": aux_items, - "bonus_taken": _bonus_taken.duplicate(true), # 三选一件获取次数(上限判定不可由加算区反推——迭代 10) + "bonus_taken": _bonus_taken.duplicate(true), # 三选一件获取次数(kind 维度派生账本——断点附随件与读数承载) + "bonus_deck": _bonus_deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) "bonus_pending": _bonus_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约) "bonus_result": _bonus_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付) "bonus_aux_carry": _bonus_aux_carry.duplicate(true), # 道具件跨关携带(注入前中断防静默丢失) diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index e9ea6ac4..ad201114 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -109,9 +109,10 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: if not adv.BONUS_IDS.has(kind): push_diag.call("error", "shelf_dangling_kind", "ShopTable.pool_shelf kind %s 不在 AdventureTable.BONUS_IDS(共享池牌库外种)" % kind) continue - var cap: int = int(adv.bonus_cap(kind)) - if cap < 1 or cap > 99: - push_diag.call("error", "shelf_cap_domain", "ShopTable.pool_shelf kind %s 次数上限越域 [1,99]: %d(货架件须有限库存种)" % [kind, cap]) + # TP0 令牌池——货架件须为有限令牌种(配额 >0;种类次数上限退役后库存=剩余令牌数) + var quota := int(adv.bonus_token_count(kind, adv.TIER_COMMON)) + int(adv.bonus_token_count(kind, adv.TIER_RARE)) + int(adv.bonus_token_count(kind, adv.TIER_RAREST)) + if quota < 1: + push_diag.call("error", "shelf_zero_quota", "ShopTable.pool_shelf kind %s 令牌配额为 0(牌库空种入货架 = 库存无锚)" % kind) # 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用): diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index c5e4dd2b..17a9dfb4 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -67,11 +67,11 @@ var _aux_unlocked := {} # aux 解锁门控集(迭代 7 var _attunement: Array = [] # 同调装配集(迭代 11 第①批 T5——adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动) var _attune_charges: Dictionary = {} # 同调主动件充能账本(迭代 11 第①批 T6——adventure 共享 dict 引用;settlement use_aux 扣减直改同引用) # 关卡末清空+快照计数入 level_finished 载荷) -# ---- 商店共享池模式态(迭代 11 第②批 T3——缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费)---- +# ---- 商店共享池模式态(迭代 11 第②批 T3——缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池换代)---- var _shop_pool_mode := false # pool 模式旗(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传) -var _pool_taken: Dictionary = {} # 共享池库存账本共享引用(AdventureRunner._bonus_taken——stock=cap−taken 单点) -var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_bonus_pick 同口写加算区+账本) -var _shop_shelf: Array[String] = [] # 当前货架(进店抽 3/买后全重抽——kind_id 序) +var _pool_deck: Array = [] # 共享池令牌库共享引用(AdventureRunner._bonus_deck——库存=剩余令牌数单点) +var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_bonus_pick 同口写加算区+账本+摘牌) +var _shop_shelf: Array = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡) var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零) var hero_atk_pct := 0 # 测试面板覆盖值(切片专用) @@ -706,10 +706,10 @@ func player_request_refresh() -> bool: # ---- 商店共享池模式(迭代 11 第②批 T3——P1 共享池/P4 ceil;legacy 固定三件路径上方原样保留为 off 对照承载)---- -# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):库存账本共享引用 + +# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 + # 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。 -func set_shop_pool(taken_ref: Dictionary, consume_handler: Callable) -> void: - _pool_taken = taken_ref +func set_shop_pool(deck_ref: Array, consume_handler: Callable) -> void: + _pool_deck = deck_ref _pool_consume = consume_handler _shop_pool_mode = true @@ -720,35 +720,46 @@ func _shop_open_pool() -> void: _shop_draw_shelf() -# 货架抽签(进店 + 每次买后全重抽共用):牌库 = 货架词表四种中库存 >0 者(库存=上限即库存—— -# 与奖励三选一达上限剔牌同一账本承载);E-S weight-key 加权洗牌不放回取 3(数学同奖励洗牌族, -# 自包含实现防奖励侧 RNG 序漂移);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 -# = 空货架合法态(池空硬闸——买光即枯竭既定后果)。 +# 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余 +# 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率 +# 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡, +# 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架 +# 合法态(池空硬闸——买光即枯竭既定后果)。 func _shop_draw_shelf() -> void: _shop_shelf = [] - var deck: Array = [] + var sellable: Array = [] for row in db.shop_pool_rows(): - var kind := String(row.get("kind_id", "")) - if pool_stock(kind) > 0: - deck.append(kind) - if deck.is_empty(): + var shelf_kind := String(row.get("kind_id", "")) + for t in _pool_deck: + var tok: Dictionary = t as Dictionary + if String(tok.get("kind", "")) == shelf_kind: + sellable.append({"kind": shelf_kind, "tier": String(tok.get("tier", ""))}) + if sellable.is_empty(): return var stream: RandomNumberGenerator = rng.open_stream("shop_draw") - var keyed: Array = [] - for kind in deck: - var w := float(maxi(1, int(db.adventure.bonus_weight(kind)))) - keyed.append({"id": kind, "key": pow(stream.randf(), 1.0 / w)}) - keyed.sort_custom(func(a, b): return float(a["key"]) > float(b["key"])) - for i in mini(3, keyed.size()): - _shop_shelf.append(String(keyed[i]["id"])) + for i in sellable.size(): + var j := stream.randi_range(i, sellable.size() - 1) + var tmp = sellable[i] + sellable[i] = sellable[j] + sellable[j] = tmp + var seen: Array = [] + for tok in sellable: + var k := String((tok as Dictionary).get("kind", "")) + if seen.has(k): + continue + seen.append(k) + _shop_shelf.append(tok) + if _shop_shelf.size() >= 3: + break -# 库存余量(账本单点读——gp 无上限种不入货架,防御读返回大数) +# 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0) func pool_stock(kind: String) -> int: - var cap := int(db.adventure.bonus_cap(kind)) - if cap == db.adventure.BONUS_CAP_UNLIMITED: - return 1 << 30 - return cap - int(_pool_taken.get(kind, 0)) + var n := 0 + for t in _pool_deck: + if String((t as Dictionary).get("kind", "")) == kind: + n += 1 + return n # 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向; @@ -762,34 +773,43 @@ func pool_price(kind: String) -> int: # 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。 -# 件值=奖励件本体值(db.adventure.bonus_value——池件与三选一件同一本体,买一件=奖励面少一件)。 +# 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式维持批②裁定 tier-blind)。 func shop_shelf() -> Array: var out: Array[Dictionary] = [] - for kind in _shop_shelf: + for tok in _shop_shelf: + var kind := String((tok as Dictionary).get("kind", "")) + var tier := String((tok as Dictionary).get("tier", "")) var row: Dictionary = db.shop_pool_row(kind) out.append({ "kind_id": kind, + "tier": tier, "display_name": String(row.get("display_name", kind)), - "value": int(db.adventure.bonus_value(kind)), + "value": int(db.adventure.bonus_value(kind, tier)), "price": pool_price(kind), "stock_left": pool_stock(kind), }) return out -# 购买(pool 模式唯一购买口):在架/余额前置校验零副作用 → handler 消费池库存(_apply_bonus_pick -# 同口写加算区+账本——跨渠道共账)→ 出账 → 进店购买次计数 → wallet 事件 → 三件全重抽(未买 -# 不保留——重抽绑购买,无独立刷新动作) +# 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架 +# 令牌本体(kind+tier——_apply_bonus_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买 +# 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作) func buy_pool_item(kind: String) -> bool: - if not _shop_pool_mode or not _shop_shelf.has(kind): + var shelf_tok := {} + for tok in _shop_shelf: + if String((tok as Dictionary).get("kind", "")) == kind: + shelf_tok = tok + break + if not _shop_pool_mode or shelf_tok.is_empty(): push_error("[关卡节点地图] 商店购买拒绝——非池模式或件不在架: %s" % kind) return false var price := pool_price(kind) if gp < price: push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [kind, price, gp]) return false - if _pool_consume.is_null() or not bool(_pool_consume.call(kind)): - push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s" % kind) + var tier := String(shelf_tok.get("tier", "")) + if _pool_consume.is_null() or not bool(_pool_consume.call(kind, tier)): + push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s|%s" % [kind, tier]) return false gp -= price _shop_visit_buys += 1 diff --git a/game/data/schemas/adventure_table.gd b/game/data/schemas/adventure_table.gd index 4d6019f3..634f9546 100644 --- a/game/data/schemas/adventure_table.gd +++ b/game/data/schemas/adventure_table.gd @@ -1,94 +1,132 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 三关连探冒险(地精洞窟 → 幽暗地域 → 灰矮人矿城)能否以一条命走完,通关兑付的 # 部分回血与提升三选一、Boss 逐杀钥石掉落在冒险摘要里成立(P0) -# Date: 2026-09-21(冒险结构实现批建表;2026-10-01 迭代 10 奖励重构批扩列——registry adventure_table 条目同批改写) +# Date: 2026-09-21(冒险结构实现批建表;2026-10-01 迭代 10 奖励重构批扩列;2026-10-03 TP0 令牌池换代) # 数据配置 AdventureTable schema(冒险结构 关卡大奖励行值;单行全局,Rule 4 豁免——HeroTable/SettlementTable 先例)。 -# 迭代 10 重构(2026-09-30 用户拍板案丙):大奖励「固定三件套」退役——level_clear_hp_bonus(血回满+上限提升件) -# 与 keystone_per_level(钥石每关入账)两字段退役;改三层奖励框架 = 部分回血(clear_heal_pct)+ 提升三选一 -# (池六种:件值/权重/次数上限三组列)+ Boss/精英击杀钥石掉落(行值归 meta.tres boss_keystone,不在本表)。 +# 迭代 10 重构(2026-09-30 用户拍板案丙):大奖励「固定三件套」退役——改三层奖励框架。 +# TP0 令牌池换代(2026-10-03 用户全裁 b——node-reward v2.2):三选一池改固定配额令牌池——普通 70/稀有 25/ +# 罕见 5=100 张;洗牌不放回抽 3、选中踢池未选回池、池耗即枯竭;种类次数上限退役(池消耗即上限)。 +# 档位构成:普通〔小GP 18/小增伤 18/小护甲 17/小生命 17〕+稀有〔大GP/大增伤/大护甲/大生命/恢复 各 5〕 +# +罕见〔AP 3〕——罕见档原列「道具件 2」随 aux_item 池种退役(迭代 11 第①批 T7)不投放,配额缺省收窄。 +# 恢复件=新入池种类(当即回血)。旧权重/次数上限字段整代退役。 # 全部 knob provisional-pending-曲线调参批(数值所有权归 难度曲线与Boss,上调须重扫通关率带)。 class_name AdventureTable extends Resource -## 三选一提升池件 id 词表(封闭——断点附随件与牌库剔除态依赖它;扩件走本表+registry 修订)。 -## aux_item 池种退役(迭代 11 第①批 T7——同调批出池:魔法道具件改走营地同调装配面,不入三选一牌库) -const BONUS_IDS: Array[String] = ["attack", "max_hp", "armor", "gp", "ap"] -## 无次数上限件的 cap 哨兵值(gp——牌库恒非空的结构保证) -const BONUS_CAP_UNLIMITED := -1 +## 三选一提升池 kind 词表(封闭——断点附随件与牌库剔除态依赖它;扩件走本表+registry 修订)。 +## aux_item 池种退役(迭代 11 第①批 T7——同调批出池);heal 恢复件新入池(v2.2 令牌池追认 b)。 +const BONUS_IDS: Array[String] = ["attack", "max_hp", "armor", "gp", "ap", "heal"] +## 令牌档位词表(v2.2 三档位——稀有度命名对齐 MC 策略判读序 316-f:1 级最稀有=rarest 优先) +const TIERS: Array[String] = ["common", "rare", "rarest"] +const TIER_COMMON := "common" +const TIER_RARE := "rare" +const TIER_RAREST := "rarest" +## 档位配额结构锚(v2.2 追认 b 定稿——validate() 硬断言;改总量=重裁非调参)。 +## 罕见档 5→3:原「道具件 2」席位随 aux_item 池种退役(迭代 11 第①批 T7,落地晚于追认 b)收窄, +## 池总量 100→98——TP0 台账自决+人工清单披露项。 +const TIER_QUOTAS := {"common": 70, "rare": 25, "rarest": 3} # —— 通关部分回血(固定型,非末关通关兑付)—— @export var clear_heal_pct: int = 20 # [0,100] knob——回复生效上限的百分比,封顶上限不回满 -# —— 三选一候选数(2026-10-02 修复批 T2 魔法数结构化:自 adventure_runner 抽签字面量 3 收口进表; -# 默认 3 = 现行行为零改动——加权洗牌不放回掷 N 的 N,改值须重扫三选一体验与牌库剔除节奏)—— -@export var bonus_candidate_count: int = 3 # [1,6] knob——候选数上界受 BONUS_IDS 词表封闭约束 +# —— 三选一候选数(2026-10-02 修复批 T2 魔法数结构化)—— +@export var bonus_candidate_count: int = 3 # [1,6] knob——令牌洗牌不放回抽取张数上界 -# —— 三选一件值(兑付面)—— -@export var bonus_attack_flat: int = 1 # [0,999] knob——攻击轴累加(覆盖值通道) -@export var bonus_max_hp_flat: int = 5 # [0,999] knob——生命上限轴累加(不连带当前生命) -@export var bonus_armor_flat: int = 1 # [0,999] knob——护甲轴累加 -@export var bonus_gp_amount: int = 20 # [0,999] knob——GP 袋定额入钱包 +# —— 令牌配额(kind × tier 计数;未列组合 = 0——heal 只入稀有/ap 只入罕见,v2.2 档位构成)—— +@export var token_attack_common: int = 18 # [0,99] +@export var token_attack_rare: int = 5 # [0,99] +@export var token_max_hp_common: int = 17 # [0,99] +@export var token_max_hp_rare: int = 5 # [0,99] +@export var token_armor_common: int = 17 # [0,99] +@export var token_armor_rare: int = 5 # [0,99] +@export var token_gp_common: int = 18 # [0,99] +@export var token_gp_rare: int = 5 # [0,99] +@export var token_heal_rare: int = 5 # [0,99]——恢复件(新入池种类,当即回血) +@export var token_ap_rarest: int = 3 # [0,99]——AP 件(罕见档;道具件 2 席位随池种退役收窄) + +# —— 令牌件值(小/大数值对——v2.2 追认 b 定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)—— +@export var bonus_attack_flat: int = 1 # [0,999] knob——小增伤 +@export var bonus_attack_flat_large: int = 2 # [0,999] knob——大增伤 +@export var bonus_max_hp_flat: int = 5 # [0,999] knob——小生命 +@export var bonus_max_hp_flat_large: int = 10 # [0,999] knob——大生命 +@export var bonus_armor_flat: int = 1 # [0,999] knob——小护甲 +@export var bonus_armor_flat_large: int = 2 # [0,999] knob——大护甲 +@export var bonus_gp_amount: int = 20 # [0,999] knob——小GP +@export var bonus_gp_amount_large: int = 50 # [0,999] knob——大GP +@export var bonus_heal_flat: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限) @export var bonus_ap_flat: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量 -# —— 三选一权重(加权洗牌不放回,E-S weight-key)—— -@export var bonus_weight_attack: int = 3 # [1,99] knob -@export var bonus_weight_max_hp: int = 3 # [1,99] knob -@export var bonus_weight_armor: int = 3 # [1,99] knob -@export var bonus_weight_gp: int = 3 # [1,99] knob -@export var bonus_weight_ap: int = 1 # [1,99] knob——AP 件(全冒险最紧上限) -# —— 全冒险获取次数上限(达上限件先剔出牌库;gp/aux_item 无上限)—— -@export var bonus_cap_attack: int = 3 # [0,99] knob -@export var bonus_cap_max_hp: int = 3 # [0,99] knob -@export var bonus_cap_armor: int = 3 # [0,99] knob -@export var bonus_cap_ap: int = 2 # [0,99] knob——全冒险最紧 - - -func bonus_value(id: String) -> int: - match id: - "attack": return bonus_attack_flat - "max_hp": return bonus_max_hp_flat - "armor": return bonus_armor_flat - "gp": return bonus_gp_amount - "ap": return bonus_ap_flat +## 令牌配额读数(未列 kind×tier 组合 = 0——heal 无普通档/ap 无普通稀有档按 v2.2 构成) +func bonus_token_count(kind: String, tier: String) -> int: + match [kind, tier]: + ["attack", TIER_COMMON]: return token_attack_common + ["attack", TIER_RARE]: return token_attack_rare + ["max_hp", TIER_COMMON]: return token_max_hp_common + ["max_hp", TIER_RARE]: return token_max_hp_rare + ["armor", TIER_COMMON]: return token_armor_common + ["armor", TIER_RARE]: return token_armor_rare + ["gp", TIER_COMMON]: return token_gp_common + ["gp", TIER_RARE]: return token_gp_rare + ["heal", TIER_RARE]: return token_heal_rare + ["ap", TIER_RAREST]: return token_ap_rarest return 0 -func bonus_weight(id: String) -> int: - match id: - "attack": return bonus_weight_attack - "max_hp": return bonus_weight_max_hp - "armor": return bonus_weight_armor - "gp": return bonus_weight_gp - "ap": return bonus_weight_ap +## 令牌件值(kind × tier;v2.2 数值对——小/大两档) +func bonus_value(kind: String, tier: String = TIER_COMMON) -> int: + match [kind, tier]: + ["attack", TIER_COMMON]: return bonus_attack_flat + ["attack", TIER_RARE]: return bonus_attack_flat_large + ["max_hp", TIER_COMMON]: return bonus_max_hp_flat + ["max_hp", TIER_RARE]: return bonus_max_hp_flat_large + ["armor", TIER_COMMON]: return bonus_armor_flat + ["armor", TIER_RARE]: return bonus_armor_flat_large + ["gp", TIER_COMMON]: return bonus_gp_amount + ["gp", TIER_RARE]: return bonus_gp_amount_large + ["heal", TIER_RARE]: return bonus_heal_flat + ["ap", TIER_RAREST]: return bonus_ap_flat return 0 -## 次数上限(gp 返回 BONUS_CAP_UNLIMITED = 无上限,牌库恒非空的结构保证) -func bonus_cap(id: String) -> int: - match id: - "attack": return bonus_cap_attack - "max_hp": return bonus_cap_max_hp - "armor": return bonus_cap_armor - "ap": return bonus_cap_ap - return BONUS_CAP_UNLIMITED +## 档位判读序(MC 策略 316-f——1 级最稀有:rarest > rare > common;并列同档取最左由调用方稳定序保证) +func tier_rank(tier: String) -> int: + match tier: + TIER_RAREST: return 3 + TIER_RARE: return 2 + TIER_COMMON: return 1 + return 0 + + +## 档位令牌总数(货架/校验读面) +func tier_total(tier: String) -> int: + var total := 0 + for kind in BONUS_IDS: + total += bonus_token_count(kind, tier) + return total + + +## 牌库总量(结构锚——罕见档随道具件退役收窄后 98 张) +func deck_total() -> int: + return tier_total(TIER_COMMON) + tier_total(TIER_RARE) + tier_total(TIER_RAREST) func validate() -> Array: var errs: Array[Dictionary] = [] if clear_heal_pct < 0 or clear_heal_pct > 100: errs.append({"category": "domain", "message": "clear_heal_pct 越域 [0,100]: %d" % clear_heal_pct}) - for id in BONUS_IDS: - var v := bonus_value(id) - if v < 0 or v > 999: - errs.append({"category": "domain", "message": "bonus_value(%s) 越域 [0,999]: %d" % [id, v]}) - var w := bonus_weight(id) - if w < 1 or w > 99: - errs.append({"category": "domain", "message": "bonus_weight(%s) 越域 [1,99]: %d" % [id, w]}) - for id in ["attack", "max_hp", "armor", "ap"]: - var c := bonus_cap(id) - if c < 0 or c > 99: - errs.append({"category": "domain", "message": "bonus_cap(%s) 越域 [0,99]: %d" % [id, c]}) - if bonus_candidate_count < 1 or bonus_candidate_count > BONUS_IDS.size(): - errs.append({"category": "domain", "message": "bonus_candidate_count 越域 [1,%d]: %d" % [BONUS_IDS.size(), bonus_candidate_count]}) + for kind in BONUS_IDS: + for tier in TIERS: + var c := bonus_token_count(kind, tier) + if c < 0 or c > 99: + errs.append({"category": "domain", "message": "token_count(%s,%s) 越域 [0,99]: %d" % [kind, tier, c]}) + var v := bonus_value(kind, tier) + if v < 0 or v > 999: + errs.append({"category": "domain", "message": "bonus_value(%s,%s) 越域 [0,999]: %d" % [kind, tier, v]}) + for tier in TIERS: + var total := tier_total(tier) + if total != int(TIER_QUOTAS.get(tier, -1)): + errs.append({"category": "domain", "message": "档位配额结构失锚(v2.2 追认 b——%s 应=%d 实=%d;改总量须重裁)" % [tier, int(TIER_QUOTAS.get(tier, -1)), total]}) + if bonus_candidate_count < 1 or bonus_candidate_count > 6: + errs.append({"category": "domain", "message": "bonus_candidate_count 越域 [1,6]: %d" % bonus_candidate_count}) return errs diff --git a/game/data/tables/adventure.tres b/game/data/tables/adventure.tres index e8ed4bbb..6144b9f0 100644 --- a/game/data/tables/adventure.tres +++ b/game/data/tables/adventure.tres @@ -5,29 +5,40 @@ [resource] script = ExtResource("1") clear_heal_pct = 20 +bonus_candidate_count = 3 +token_attack_common = 18 +token_attack_rare = 5 +token_max_hp_common = 17 +token_max_hp_rare = 5 +token_armor_common = 17 +token_armor_rare = 5 +token_gp_common = 18 +token_gp_rare = 5 +token_heal_rare = 5 +token_ap_rarest = 3 bonus_attack_flat = 1 +bonus_attack_flat_large = 2 bonus_max_hp_flat = 5 +bonus_max_hp_flat_large = 10 bonus_armor_flat = 1 +bonus_armor_flat_large = 2 bonus_gp_amount = 20 +bonus_gp_amount_large = 50 +bonus_heal_flat = 6 bonus_ap_flat = 1 -bonus_weight_attack = 3 -bonus_weight_max_hp = 3 -bonus_weight_armor = 3 -bonus_weight_gp = 3 -bonus_weight_ap = 1 -bonus_cap_attack = 3 -bonus_cap_max_hp = 3 -bonus_cap_armor = 3 -bonus_cap_ap = 2 -; 迭代 10 奖励重构批(2026-10-01 扩列——registry adventure_table 条目同批改写): -; 大奖励 = 非末关通关恰一次:部分回血(clear_heal_pct,封顶上限不回满、满血回 0 合法) -; + 提升三选一(池六种加权洗牌不放回 E-S weight-key 掷 3,玩家点选兑付恰一件;消费 reward_pick 封闭流)。 -; 权重:属性/GP 各 3、AP 1;次数上限:属性件各 3、AP 件 2(达上限件先剔牌库)——gp 无次数上限。 -; 迭代 11 第①批 同调实施批 T7(2026-10-03):aux_item 池种退役出三选一牌库(bonus_weight_aux/ -; bonus_aux_item_count 两字段随批退役);transitional_sync_slots 过渡键退役(正主 = attunement_growth.tres -; 谓词表求值,Attunement.slots——迭代 10 欠账③清偿)。 +; TP0 令牌池换代(2026-10-03,node-reward v2.2 追认 b 落地):三选一池 = 固定配额令牌池—— +; 普通 70/稀有 25/罕见 3 = 98 张(罕见档原列「道具件 2」随 aux_item 池种退役收窄——台账自决披露项); +; 洗牌不放回抽 3、选中踢池未选回池、池耗即枯竭; +; 种类次数上限退役(池消耗即上限;货架库存同账本=牌库剩余令牌数)。 +; 档位构成:普通〔小GP 18/小增伤 18/小护甲 17/小生命 17〕+ 稀有〔大GP/大增伤/大护甲/大生命/恢复 各 5〕 +; + 罕见〔AP 3〕——罕见档原列「道具件 2」随 aux_item 池种退役(迭代 11 第①批 T7)不投放。 +; 数值对:小GP 20/大GP 50、小增伤 +1/大 +2、小护甲 +1/大 +2、小生命 +5/大 +10、恢复 +6(当即回血)、AP +1。 +; 恢复件 = 新入池种类(rare 档)。 +; 退役字段:bonus_weight_*(E-S 权重族)/ bonus_cap_*(种类次数上限族)/ BONUS_CAP_UNLIMITED 哨兵—— +; 洗牌改令牌 Fisher-Yates(不放回抽 3,消费 reward_pick 封闭流)。 +; 迭代 11 第②批 商店共享池注记:货架 = 令牌级抽签(同 kind 去重每 kind 至多一卡、价格公式 tier-blind +; ceil(基价×b^(关序−1)×A^visit) 维持批②裁定、购买消费在架令牌本体)。 ; 末关通关不发三选一与回血(奖励只朝下一战生效)。 -; 退役字段:level_clear_hp_bonus(血回满+上限提升件)、keystone_per_level(钥石改 Boss/精英击杀掉落,行值归 meta.tres boss_keystone)、 -; transitional_sync_slots / bonus_weight_aux / bonus_aux_item_count(本批 T7 退役三件)。 -; 全部 provisional-pending-曲线调参批(值锚与上调约束归 难度曲线与Boss;回血 20% 系难度上压方向,带重扫随批呈报)。 +; 全部 provisional-pending-曲线调参批(值锚与上调约束归 难度曲线与Boss;档位配额总量 70/25/5 系追认 b +; 结构锚——schema validate 硬断言,改总量须重裁)。 diff --git a/game/presentation/battle_narrator.gd b/game/presentation/battle_narrator.gd index 2bac84f1..550dab0c 100644 --- a/game/presentation/battle_narrator.gd +++ b/game/presentation/battle_narrator.gd @@ -92,18 +92,26 @@ static func bonus_pick_header(payload: Dictionary) -> String: return "\n".join(lines) -# 三选一选项卡文案(件名 + 效果读数;值 = 冒险表行值,组合根喂入;道具件掷定前置时 -# aux_line = 具体道具「名 · 效果摘要」行——追加批二,空 = 无预掷回退通用文案) -static func bonus_card_text(id: String, value: int, aux_line: String = "") -> String: +# 三选一选项卡文案(件名 + 档位 + 效果读数;值 = 冒险表行值,组合根喂入;TP0 令牌池——档位词 +# 对齐 v2.2 追认 b 口径:小/大前缀(小GP/大GP 族)、恢复/AP 不带档位词;道具件掷定前置时 +# aux_line = 具体道具「名 · 效果摘要」行——池种退役后恒空走通用分支,机械惰性保留) +static func bonus_card_text(id: String, tier: String, value: int, aux_line: String = "") -> String: + var ts := "" + if tier == "common": + ts = " · 小" + elif tier == "rare": + ts = " · 大" match id: "attack": - return "攻击力提升\n攻击 +%d" % value + return "攻击力提升%s\n攻击 +%d" % [ts, value] "max_hp": - return "生命上限提升\n生命上限 +%d" % value + return "生命上限提升%s\n生命上限 +%d" % [ts, value] "armor": - return "护甲等级提升\n护甲 +%d" % value + return "护甲等级提升%s\n护甲 +%d" % [ts, value] "gp": - return "金币袋\n金币 +%d" % value + return "金币袋%s\n金币 +%d" % [ts, value] + "heal": + return "恢复药剂\n生命 +%d(当即回复)" % value "aux_item": if aux_line != "": return "魔法道具\n%s" % aux_line @@ -113,9 +121,9 @@ static func bonus_card_text(id: String, value: int, aux_line: String = "") -> St return id -# 三选一兑付反馈行(选中件效果 + 丢弃提示) +# 三选一兑付反馈行(选中件效果 + 丢弃提示;tier 缺省普通档——旧载荷防御口径) static func bonus_result_text(result: Dictionary) -> String: - var name := bonus_card_text(String(result.get("picked", "")), int(result.get("value", 0))).replace("\n", " · ") + var name := bonus_card_text(String(result.get("picked", "")), String(result.get("tier", "")), int(result.get("value", 0))).replace("\n", " · ") return "已选择:%s\n其余选项已丢弃(不可反悔)" % name diff --git a/game/presentation/main.gd b/game/presentation/main.gd index a157611b..bf5d3978 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -542,17 +542,21 @@ var _last_replace_incoming := {} # 同上——incoming 记录 func _bonus_card_texts(candidates: Array, aux_detail: Array = []) -> Array: + # TP0 令牌池——候选 = {"kind","tier"} 令牌字典;卡片 id = "kind|tier" 复合键(同 kind 大小两档 + # 可同屏,kind 单键不构成唯一身份),确认回程拆键(_on_bonus_pick_confirmed) var out: Array = [] var aux_i := 0 - for id in candidates: - var cid := String(id) + for tok in candidates: + var kind := String((tok as Dictionary).get("kind", "")) + var tier := String((tok as Dictionary).get("tier", "")) + var cid := "%s|%s" % [kind, tier] var text := "" - if cid == "aux_item": + if kind == "aux_item": var iid := String(aux_detail[aux_i]) if aux_i < aux_detail.size() else "" aux_i += 1 - text = Narrator.bonus_card_text(cid, int(db.adventure.bonus_value(cid)), _aux_card_line(iid)) + text = Narrator.bonus_card_text(kind, tier, int(db.adventure.bonus_value(kind, tier)), _aux_card_line(iid)) else: - text = Narrator.bonus_card_text(cid, int(db.adventure.bonus_value(cid))) + text = Narrator.bonus_card_text(kind, tier, int(db.adventure.bonus_value(kind, tier))) out.append({"id": cid, "text": text}) return out @@ -596,7 +600,11 @@ func _on_bonus_refresh() -> void: func _on_bonus_pick_confirmed(item_id: String) -> void: if adventure == null: return - var ok: bool = adventure.apply_node_bonus(item_id) if _bonus_context == "reward_node" else adventure.pick_bonus(item_id) + # TP0 令牌池——卡片 id "kind|tier" 拆键(缺 | = 旧载荷防御归普通档) + var parts := item_id.split("|") + var kind := parts[0] + var tier := parts[1] if parts.size() > 1 else "" + var ok: bool = adventure.apply_node_bonus(kind, tier) if _bonus_context == "reward_node" else adventure.pick_bonus(kind, tier) if not ok: push_error("[组合根] 三选一兑付被拒——见输出日志") diff --git a/game/smoke/level_autoplay.gd b/game/smoke/level_autoplay.gd index d5a0942f..26c86ea8 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/smoke/level_autoplay.gd @@ -204,16 +204,17 @@ 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": - # 奖励节点三选一(迭代 10 追加批——共享池确定性策略:候选取首件、替换放弃新件; - # 兑付后 continue_after_node 回图选路) + # 奖励节点三选一(迭代 10 追加批——共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、 + # 同档并列取最左;替换放弃新件;兑付后 continue_after_node 回图选路) var ndraw: Dictionary = adr.node_bonus_candidates() var ncands: Array = ndraw.get("candidates", []) as Array st.reward_events.append({"node_bonus_pick": ncands.duplicate(true)}) if ncands.is_empty(): - r.continue_after_node() # 牌库空合法态直通(结构上不可达——gp/道具件无上限,防御保留) + r.continue_after_node() # 牌库空合法态直通(池耗即枯竭——防御保留) else: adr.present_node_bonus(ncands, ndraw.get("aux_detail", []) as Array) - if adr.apply_node_bonus(String(ncands[0])): + var npick: Dictionary = _pick_token(ncands) + if adr.apply_node_bonus(String(npick.get("kind", "")), String(npick.get("tier", ""))): if not adr._node_bonus_pending.is_empty() and adr._node_bonus_pending.has("incoming"): adr.resolve_bonus_replace(-1) # 满位替换:放弃新件 else: @@ -242,12 +243,38 @@ static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, logger.log_wallet(event, gp_val, extra) -# 三选一停点驱动(迭代 10——确定性策略:候选取首件;奖励事件随 reward_events 留档) +# 三选一停点驱动(迭代 10——确定性策略;TP0 起 316-f 判读:1 级最稀有、同档并列取最左; +# 奖励事件随 reward_events 留档) static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void: st.reward_events.append({"bonus_pick": payload.duplicate(true)}) var candidates: Array = payload.get("candidates", []) as Array - if candidates.is_empty() or not adr.pick_bonus(String(candidates[0])): + if candidates.is_empty(): st.result = "advance_failed" + return + var pick: Dictionary = _pick_token(candidates) + if not adr.pick_bonus(String(pick.get("kind", "")), String(pick.get("tier", ""))): + st.result = "advance_failed" + + +# 三选一确定性选件(TP0 316-f——1 级优先度=最稀有〔档位最高 rarest>rare>common〕、2 级=最左; +# 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径) +static func _pick_token(candidates: Array) -> Dictionary: + var best: Dictionary = {} + var best_rank := -1 + for tok in candidates: + var t: Dictionary = tok as Dictionary + var rank := 0 + match String(t.get("tier", "")): + "rarest": + rank = 3 + "rare": + rank = 2 + "common": + rank = 1 + if best.is_empty() or rank > best_rank: + best = t + best_rank = rank + return best # 同调位满位替换子交互(确定性策略:放弃新件不获取——分发面按停点上下文裁决节点/通关渠道) diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 5b70c25e..88cf6fd0 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -195,24 +195,35 @@ func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int: # ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)---- -# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;迭代 10 扩列域检) +# 令牌候选匹配(TP0——kind+tier 复合身份;冒烟断言面共用) +func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Dictionary: + for t in candidates: + var tok: Dictionary = t as Dictionary + if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier: + return tok + return {} + + +# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池换代:配额结构/件值对/池总量域检) func _smoke_adventure_table() -> void: - print("--- 冒险表(AdventureTable——三层奖励框架行值单点:回血/件值/权重/上限/同调位)---") + print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)") if _db.adventure == null: return - check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检在案)") + check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)") check(int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100, "clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct)) - for id in _db.adventure.BONUS_IDS: - var v := int(_db.adventure.bonus_value(id)) - var w := int(_db.adventure.bonus_weight(id)) - check(v >= 0 and v <= 999 and w >= 1 and w <= 99, "三选一件 %s:值 ∈[0,999](%d)· 权重 ∈[1,99](%d)" % [id, v, w]) - check(int(_db.adventure.bonus_cap_ap) == 2 and int(_db.adventure.bonus_cap_attack) == int(_db.adventure.bonus_cap_max_hp) - and int(_db.adventure.bonus_cap_max_hp) == int(_db.adventure.bonus_cap_armor), - "次数上限口径:AP 件=%d(全冒险最紧)· 属性件=%d(三件同)" % [int(_db.adventure.bonus_cap_ap), int(_db.adventure.bonus_cap_attack)]) - check(int(_db.adventure.bonus_cap("gp")) == _db.adventure.BONUS_CAP_UNLIMITED, - "GP 件无次数上限(牌库恒非空的结构保证;aux_item 池种已随同调批 T7 除名)") + for tier in _db.adventure.TIERS: + check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]), + "档位配额结构锚(v2.2 追认 b):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])]) + check(int(_db.adventure.deck_total()) == 98, "令牌池总量 = 98 张(普通 70/稀有 25/罕见 3——罕见档随道具件池种退役收窄)") + check(int(_db.adventure.bonus_value("gp", "common")) == 20 and int(_db.adventure.bonus_value("gp", "rare")) == 50 + and int(_db.adventure.bonus_value("attack", "common")) == 1 and int(_db.adventure.bonus_value("attack", "rare")) == 2 + and int(_db.adventure.bonus_value("max_hp", "common")) == 5 and int(_db.adventure.bonus_value("max_hp", "rare")) == 10 + and int(_db.adventure.bonus_value("armor", "common")) == 1 and int(_db.adventure.bonus_value("armor", "rare")) == 2, + "数值对(v2.2):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2") + check(int(_db.adventure.bonus_value("heal", "rare")) == 6 and int(_db.adventure.bonus_value("ap", "rarest")) == 1, + "恢复件 +6 当即回血(新入池种类,rare 档)· AP +1(rarest 档)") check(_db.attunement_growth != null and int(_db.attunement_slot_cap()) == 3 and Attunement.slots(_db, null) == 0, "同调位读数源=attunement_growth 谓词表(slot_cap 3;未注入档求值 0——transitional_sync_slots 过渡键已退役,迭代 11 第①批 T7)") @@ -337,7 +348,7 @@ func _smoke_adventure() -> void: 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 件累加(回血封顶断言的生效上限推导) + var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计) var ks_prefix := 0 for i in picks.size(): var p: Dictionary = picks[i] @@ -346,9 +357,10 @@ func _smoke_adventure() -> void: cand_ok = false var seen := {} for c in cands: - if seen.has(String(c)): + var ckey := "%s|%s" % [String((c as Dictionary).get("kind", "")), String((c as Dictionary).get("tier", ""))] + if seen.has(ckey): cand_ok = false - seen[String(c)] = true + seen[ckey] = true var heal: int = int(p.get("heal_gained", -1)) var hp_before: int = int(p.get("hp_before", -1)) var hp_after: int = int(p.get("hp_after", -1)) @@ -363,18 +375,20 @@ func _smoke_adventure() -> void: if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 15) * 5.0)): heal_ok = false ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) - if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: + if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: ks_gain_ok = false if resolve_ok: 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", "")))): + var rk := String(r.get("picked", "")) + var rt := String(r.get("tier", "")) + if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.adventure.bonus_value(rk, rt)): resolve_ok = false - if String(r.get("picked", "")) == "max_hp": - hp_acc += max_flat - check(cand_ok, "三选一候选 2–3 件且零重复(加权洗牌不放回)") + if rk == "max_hp": + hp_acc += int(r.get("value", max_flat)) + check(cand_ok, "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)") check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役;购血容差窗口)") check(ks_gain_ok, "钥石逐杀入账(首杀档位 + 累计逐位)") - check(resolve_ok, "点选恰兑付选中一件(值=表值;未选项零生效)") + check(resolve_ok, "点选恰兑付选中一件(值=令牌档位表值;未选项零生效)") var seq: Array = st["encounter_max_hp_seq"] var k := 5.0 # hp+400 ⇒ TunePanel 百分比后乘 ×5(加算先加、百分比后乘——生效次序口径) var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k))) @@ -488,9 +502,10 @@ func _smoke_adventure() -> void: check(not a8._bonus_pending.is_empty(), "L2 通关三选一停点在案(直调兑付前——零自动推进)") check(not a8.continue_to_next_level(), "未兑付推进拒绝(选择停点零自动推进契约)") var l2_cands: Array = a8._bonus_pending["candidates"] - check(a8.pick_bonus(String(l2_cands[0])), "三选一点选兑付受理(候选取首件)") + var l2_pick: Dictionary = l2_cands[0] + check(a8.pick_bonus(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", ""))), "三选一点选兑付受理(候选取首件)") var picked2: String = String(a8._bonus_result.get("picked", "")) - var expect_acc: int = acc_shop + (int(_db.adventure.bonus_max_hp_flat) if picked2 == "max_hp" else 0) + var expect_acc: int = acc_shop + (int(a8._bonus_result.get("value", 0)) if picked2 == "max_hp" else 0) check(int(a8.bonus.get("max_hp", -1)) == expect_acc, "三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.bonus.get("max_hp", -1)), acc_shop, picked2]) var max_eff8: int = a8.effective_max_hp() # T17 口径修复随批:账本生效上限含 TunePanel pct(a8 已 set 400) @@ -519,23 +534,30 @@ func _smoke_adventure() -> void: g10 -= 1 autoplay._autoplay_battle(a10.runner(), b10, st10, null) check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理") - # ⑪ 上限剔牌库 + 同调位替换两路 + 满血回 0(迭代 10——white-box:计数/携带面直设) + # ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现; + # 种类次数上限退役,「达上限剔除」语义由牌库耗尽承载) var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11)) check(a11.start_adventure(11, {"first_script": true, "aux_unlocked": {"blast_kit": true}}), "剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 blast_kit)") - a11._bonus_taken["attack"] = int(_db.adventure.bonus_cap_attack) # 直设计数(white-box——计数面本身即断点承载) - a11._bonus_taken["ap"] = int(_db.adventure.bonus_cap_ap) + for di in range(a11._bonus_deck.size() - 1, -1, -1): + var dk := String((a11._bonus_deck[di] as Dictionary).get("kind", "")) + if dk == "attack" or dk == "ap": + a11._bonus_deck.remove_at(di) var draw: Array = a11._draw_bonus_candidates() - check(not (draw as Array).has("attack") and not (draw as Array).has("ap"), - "达上限件剔牌库(attack 计满 %d / ap 计满 %d 不出现在候选——%s)" % [int(_db.adventure.bonus_cap_attack), int(_db.adventure.bonus_cap_ap), str(draw)]) - check((draw as Array).size() >= 2, "剔牌库后候选恒 ≥2(GP 件无次数上限——防「三选项全不可选」死角)") + var draw_kinds := {} + for t in draw: + draw_kinds[String((t as Dictionary).get("kind", ""))] = true + check(not draw_kinds.has("attack") and not draw_kinds.has("ap"), + "池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw)) + check((draw as Array).size() >= 2, "池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)") # 池收缩(迭代 11 同调批 T7——aux 卡退役出三选一牌库:重复抽样零 aux 候选;同调位替换两路 # white-box 测试随停点渠道退役拆除——机械惰性承载,零投放零行为) var aux_seen := false for i in 40: - if (a11._draw_bonus_candidates() as Array).has("aux_item"): - aux_seen = true - break + for t in a11._draw_bonus_candidates(): + if String((t as Dictionary).get("kind", "")) == "aux_item": + aux_seen = true + break check(not aux_seen, "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)") # 满血回 0 合法(通关回血封顶口径) var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12)) @@ -547,23 +569,27 @@ 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 件走同一入账收口) + # ⑬ 奖励节点共享池(迭代 10 追加批——同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现) 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) + for di13 in range(a13._bonus_deck.size() - 1, -1, -1): + if String((a13._bonus_deck[di13] as Dictionary).get("kind", "")) == "attack": + a13._bonus_deck.remove_at(di13) var ndraw: Dictionary = a13.node_bonus_candidates() var ncands13: Array = ndraw.get("candidates", []) as Array - check(not ncands13.has("attack"), - "奖励节点共享池:达上限件同样剔牌库(同 _draw 同上限——%s)" % str(ncands13)) - if ncands13.has("aux_item"): - check(not (ndraw.get("aux_detail", []) as Array).is_empty(), - "掷定前置:道具件候选自带具体道具(aux_detail 随抽签掷定——卡片直显数据源)") + var has_attack13 := false + for t in ncands13: + if String((t as Dictionary).get("kind", "")) == "attack": + has_attack13 = true + break + check(not has_attack13, + "奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13)) a13.present_node_bonus(ncands13, ndraw.get("aux_detail", []) as Array) - a13.present_node_bonus(["gp"]) + a13.present_node_bonus([{"kind": "gp", "tier": "common"}]) 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.apply_node_bonus("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.bonus_value("gp", "common")), + "奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.bonus_value("gp", "common"))) 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)), @@ -579,10 +605,11 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: if String(st["result"]) != "": return false if a._pending_next_level: - # 三选一停点确认(迭代 10——候选取首件后推进,与 run_adventure 自动策略同口径) + # 三选一停点确认(迭代 10——确定性选件后推进,与 run_adventure 自动策略同口径;TP0 令牌化) if not a._bonus_pending.is_empty(): var bcands: Array = a._bonus_pending["candidates"] - a.pick_bonus(String(bcands[0])) + var bpick: Dictionary = autoplay._pick_token(bcands) + a.pick_bonus(String(bpick.get("kind", "")), String(bpick.get("tier", ""))) a.continue_to_next_level() continue var r = a.runner() @@ -595,12 +622,13 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: continue var t := String(r.current_node().get("type", "")) if t == "reward": - # 奖励节点三选一(迭代 10 追加批——候选取首件、替换放弃新件、兑付确认回图) + # 奖励节点三选一(迭代 10 追加批——确定性选件、替换放弃新件、兑付确认回图;TP0 令牌化) var ndraw: Dictionary = a.node_bonus_candidates() var ncands: Array = ndraw.get("candidates", []) as Array if not ncands.is_empty(): a.present_node_bonus(ncands, ndraw.get("aux_detail", []) as Array) - if a.apply_node_bonus(String(ncands[0])): + var npick: Dictionary = autoplay._pick_token(ncands) + if a.apply_node_bonus(String(npick.get("kind", "")), String(npick.get("tier", ""))): if not a._node_bonus_pending.is_empty() and a._node_bonus_pending.has("incoming"): a.resolve_bonus_replace(-1) r.continue_after_node() @@ -3399,7 +3427,7 @@ func _smoke_shop_pool() -> void: var expect_p: int = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) check(int(c.get("price", -1)) == expect_p, "首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [str(c.get("kind_id", "")), int(c.get("price", -1)), expect_p]) - # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1) + # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体) var buy_c: Dictionary = {} for c in r8.shop_shelf(): if int(c.get("price", 1 << 30)) <= int(r8.gp): @@ -3408,14 +3436,15 @@ func _smoke_shop_pool() -> void: check(not buy_c.is_empty(), "可购卡在架(GP %d——种子扫描保证首购空间)" % int(r8.gp)) if not buy_c.is_empty(): var kind := String(buy_c.get("kind_id", "")) + var tier := String(buy_c.get("tier", "")) var val_map := {"attack": "atk", "max_hp": "max_hp", "armor": "armor_class", "ap": "ap"} var gp0: int = int(r8.gp) var taken0: int = int(a8._bonus_taken.get(kind, 0)) var val0: int = int(a8.bonus.get(String(val_map[kind]), -999)) - check(r8.buy_pool_item(kind), "购买受理(pool 口——%s)" % kind) + check(r8.buy_pool_item(kind), "购买受理(pool 口——%s|%s)" % [kind, tier]) check(int(r8.gp) == gp0 - int(buy_c.get("price", 0)), "钱包扣现价(%d → %d)" % [gp0, int(r8.gp)]) - check(int(a8.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.bonus_value(kind)), - "加算区 +件值(池件=奖励件本体——%s %+d)" % [kind, int(_db.adventure.bonus_value(kind))]) + check(int(a8.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.bonus_value(kind, tier)), + "加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.bonus_value(kind, tier))]) check(int(a8._bonus_taken.get(kind, 0)) == taken0 + 1, "共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._bonus_taken.get(kind, 0))]) # A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式) var second: Dictionary = {} @@ -3437,23 +3466,30 @@ func _smoke_shop_pool() -> void: if String(c.get("kind_id", "")) == "gp" or String(c.get("kind_id", "")) == "aux_item": cut_seen = true check(not cut_seen, "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)") - # ⑤ 池空硬闸(库存尽剔出牌库):white-box 计满 attack 后重抽零 attack + 消费口拒绝 - a8._bonus_taken["attack"] = int(_db.adventure.bonus_cap_attack) + # ⑤ 池空硬闸(库存尽剔出牌库——TP0:white-box 耗尽 attack 令牌后货架零 attack + 消费口拒绝) + for di8 in range(a8._bonus_deck.size() - 1, -1, -1): + if String((a8._bonus_deck[di8] as Dictionary).get("kind", "")) == "attack": + a8._bonus_deck.remove_at(di8) var attack_seen := false for i in 40: r8._shop_draw_shelf() if (r8.shop_shelf() as Array).any(func(c): return String(c.get("kind_id", "")) == "attack"): attack_seen = true break - check(not attack_seen, "库存尽剔牌库(attack 计满 %d——上限即库存跨渠道共账 reward→shop 向)" % int(_db.adventure.bonus_cap_attack)) - var st_consume: int = int(a8._bonus_taken["attack"]) - check(not a8._consume_pool_purchase("attack") and int(a8._bonus_taken["attack"]) == st_consume, + check(not attack_seen, "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)") + var stock_before: int = a8.pool_stock("attack") + check(not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before, "池空消费拒绝(handler 零副作用——买光即枯竭硬闸)") - # ⑥ 跨渠道共账 shop→reward 向:消费口写账本后奖励牌库同步剔牌 - a8._bonus_taken["attack"] = int(_db.adventure.bonus_cap_attack) - 1 - check(a8._consume_pool_purchase("attack"), "商店消费口受理(_apply_bonus_pick 同口——真单点账本)") - check(not (a8._draw_bonus_candidates() as Array).has("attack"), - "商店购买进奖励牌库剔除(买一件=奖励面少一件——shop→reward 向共账)") + # ⑥ 跨渠道共库 shop→reward 向:回填一枚 attack 令牌 → 消费口受理 → 奖励牌库同步摘牌 + a8._bonus_deck.append({"kind": "attack", "tier": "common"}) + check(a8._consume_pool_purchase("attack", "common"), "商店消费口受理(_apply_bonus_pick 同口——真单点库)") + var reward_attack_seen := false + for t6 in a8._draw_bonus_candidates(): + if String((t6 as Dictionary).get("kind", "")) == "attack": + reward_attack_seen = true + break + check(not reward_attack_seen, + "商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)") # ⑦ 断点扩键(P2 甲——save v4 域内:shop_shelf/shop_visit_buys 入快照;离开店=空架合法态) var sm = load("res://core/save_manager.gd").new("user://_smoke_shop_pool_save.json") sm.load_from_disk() @@ -3471,10 +3507,12 @@ func _smoke_shop_pool() -> void: check(ps.size() == 4, "终局池读数四键出讫(run_adventure pool 面——读数只锚一次载体)") var ps_ok := true for k in ps.keys(): - var cap := int(_db.adventure.bonus_cap(String(k))) - if int(ps[k]) < 0 or int(ps[k]) > cap: + var quota := 0 + for tier in _db.adventure.TIERS: + quota += int(_db.adventure.bonus_token_count(String(k), tier)) + if int(ps[k]) < 0 or int(ps[k]) > quota: ps_ok = false - check(ps_ok, "终局池余量在域 [0,cap](%s)" % str(ps)) + check(ps_ok, "终局池余量在域 [0,配额](%s)" % str(ps)) # 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限) @@ -3794,7 +3832,7 @@ func _meta_settle_and_workshop() -> void: a12._attunement = ["pick_refresh"] a12._attune_refresh_x = 1 a12._attune_refresh_y = 2 - a12._bonus_pending = {"candidates": ["gp", "attack", "max_hp"], "payload": {}} + a12._bonus_pending = {"candidates": [{"kind": "gp", "tier": "common"}, {"kind": "attack", "tier": "common"}, {"kind": "max_hp", "tier": "common"}], "payload": {}} check(a12.refresh_bonus() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2, "刷新双计数器——X 优先消耗(x1y2 → x0y2)") check(a12.refresh_bonus() and a12.refresh_bonus() and a12._attune_refresh_y == 0 diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index 5edf6cdd..5e814f0e 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -112,7 +112,8 @@ func _init() -> void: Kit.check_quiet(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)") Kit.check_quiet(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)") Kit.check_quiet(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)") - Kit.check_quiet(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理") + var pick_e: Dictionary = cands_e[0] + Kit.check_quiet(adr_f.pick_bonus(String(pick_e.get("kind", "")), String(pick_e.get("tier", ""))), "P5 点选兑付受理") var picked_e: String = String(adr_f._bonus_result.get("picked", "")) if adr_f._bonus_taken.has(picked_e): Kit.check_quiet(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1),