TP0令牌池落地(迭代12条件卡——node-reward v2.2追认b实施批):三选一池改固定配额令牌池70/25/3=98张(罕见档道具件2席随aux池种退役收窄=台账自决披露项;schema validate结构锚硬断言)+抽签Fisher-Yates同kind去重+选中踢池未选回池+池耗即枯竭+种类次数上限退役+恢复件+6新入池rare档+数值对全落;MC策略316-f落地(autoplay 1级最稀有/2级最左_pick_token单点);货架=令牌级抽签价格tier-blind维持批②裁定+消费在架令牌本体;断点扩键bonus_deck余库逐枚还原(旧断点缺键=满库重建防御单版本周期);跨表校验⑥改令牌配额检查;文档四正主=node-reward v2.4+冒险结构v0.14+registry+06落地状态;normal档重锚runsum_m4iter12_tp0_n20_20261003(seed0=90000配对读数同带0/5/0零结构位移+316-f构成佐证)——闸=冒烟670/0三闸+run_all 15套+探针五绿click存量红零新增+doc三闸零新增+熵增PASS

This commit is contained in:
DKChan committed 2026-10-04 00:17:39 +08:00
1 parent cc34531048
commit fd6a8325f0
14 files changed
+525 -303

No files matched your search

+6 -4
View File
@@ -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 数值批)
**状态**:已全裁待执行(设计定稿待实施;现行平铺池+权重随机维持到实施批)
+16 -13
View File
@@ -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) |
+6 -5
View File
@@ -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 档配对读数同带) |
+3 -2
View File
@@ -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 收口)
+132 -69
View File
@@ -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), # 道具件跨关携带(注入前中断防静默丢失)
+4 -3
View File
@@ -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 诊断通道与冒烟直验共用):
+57 -37
View File
@@ -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
+103 -65
View File
@@ -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
+30 -19
View File
@@ -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 硬断言,改总量须重裁)。
+17 -9
View File
@@ -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
+14 -6
View File
@@ -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("[组合根] 三选一兑付被拒——见输出日志")
+33 -6
View File
@@ -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
# 同调位满位替换子交互(确定性策略:放弃新件不获取——分发面按停点上下文裁决节点/通关渠道)
+102 -64
View File
@@ -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
@@ -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),