diff --git a/design/registry/entities.yaml b/design/registry/entities.yaml index f3a6db44..ce4bb7b2 100644 --- a/design/registry/entities.yaml +++ b/design/registry/entities.yaml @@ -601,6 +601,7 @@ entities: acquisition: 三条渠道的**目标分工(交叉开关,v2.2 收口)**——专长:主渠道=**关底 Boss 击败=通过这一关**(**唯一直接给的渠道**;不新增节点类型、挂 Boss 节点兑付面;末关的 Boss 击败后不给——无后续关卡可消费)。**兑付频率**:奇物不是打一场战斗给一份,关内普通战斗节点不给任何奇物,一关之内只兑付一次(奖励节点与商店同理,各节点各自兑付),奖励节点与商店**只给「专长获取」门票**——**拿到即开、不可囤积**,不进持有集(选中/买下同一时刻开专长三选一;可囤积会让玩家无限推迟关键决策,且把持有物语义从后门放回)。门票归专长(给的是一件以前不会的事,只是把选择推迟)。罕见**只在概率不在价格**,一局限购一次,**计步保底**:连续 5 次三选一展示未出现→第 6 次必出(计数=展示次数、刷新重展计入、奖励节点与商店共享、触发清零、限购用满后不再触发);属性值提升:奖励节点 + 商店(口径「训练」);资源:奖励节点 + 商店(**金币除外、回复在内**);后两类均不出现在 Boss 渠道。形状=「一块独占 + 两块并行」,专长靠打赢、其余靠抽取与购买。**默认原则**:除已登记的特殊规则外,分类内的东西都要在它该出现的渠道池子里——任何不出现都须登记理由**现行实现三渠道共用同一候选池、三类混编**,分工尚未实施,登记于 06-待办清单 BD 分层条目 boundary: 判据走生命周期——**一次冒险内获得、冒险收束即清零的是奇物;跨冒险跟着你走的是魔法道具**。奇物永不进同调位,魔法道具永不进当局随机渠道;与元进度天赋树两层互不代位 data_sources: feats.tres(专长定义行)/ adventure.tres(属性值提升与资源的种类配额与值)/ shop.tres(货架取舍) + refresh_boundary: **刷新次数边界写死(T6 奇物批迭代15②——防后续混淆结算)**:「商店独立刷新机制」(shop_refresh_free/shop_refresh_cost_base/shop_refresh_cost_curve 三旋钮——进店后的免费次数+GP 付费刷新)系**商店自身服务**,作用对象=货架重抽;奇物 refresh 件(资源类成员,作为奇物授予的重抽次数)作用对象=**三选一界面**(兑付即 _attune_refresh_x 增量,X 余量=三选一重抽次数),不进加算区(§2.2 加算区四轴不含它)。两者不是一回事,禁止混算。 open_questions: ①类内异质性已登记——金币与回复是即时兑付、不进持有集不持续生效,与行动点上限/刷新次数生命周期不同,靠这条描述把"资源"从泛用词收回到本类定义内;②专长门票尚未入表(出现概率/配额/限购次数/保底阈值四参数均无事实源);③确定性渠道一局两次专长 vs 档位阈值(同标签 ≥2 档一 / ≥4 档二)——**档二在三关结构下几乎不可达**,用户定延后复裁(触发=第四关内容批后实测);④charge 标签仅 3 件、永远达不到档二(数据层缺陷,独立登记,勿被上条盖掉);⑤断点存档键 5 个(bonus_deck / bonus_taken / bonus_pending / bonus_result / bonus_aux_carry)是否随存档版本迁移换名待裁 naming_note: 2026-10-05 命名统一 + 两次重构——此前按内部分类被叫成三个名字(规则类称「流派件」、属性类称「数值件」、界面称「本关强化」),v1.0 统一为「特性」,v2.0 改名为「奖励」,v2.4 定名为「奇物」(因筐里也装金币这类一次性兑付物,"自身提升 vs 持有物"已切不开,判据改由生命周期承担)。v2.5 第三类定名为「资源」(用户拍板)。**v2.7 三个子节点全部正式定名、去「类」字后缀**:专长(Feat)/ 属性值提升(ASI)/ 资源——此前是描述式叫法不是专名。**代码侧三套词族已于奇物批对齐(2026-10-05 迭代15②批 T1-T3)**:`feature_*` → `curio_*`(CURIO_KINDS/curio_quota/curio_value)、`relic_*` → `feat_*`(feat_table.gd/feats.tres/feats_engine.gd/feat_flags/feat_id——kind 值 "relic" 与身份前缀 "relic:" 系令牌池数据契约键沿用不动)、运行层 `bonus_*` → `curio_*`(curio_interact_controller/curio_pick_overlay/CurioPickOverlay/pick_curio);断点存档键(bonus_deck/bonus_taken/bonus_pending/bonus_result/bonus_aux_carry/relics)按甲案沿用历史名不迁移(存档兼容) referenced_by: [冒险结构, 关卡节点地图, 战斗结算, 行动经济, 地形变异, HUD框架, 魔法道具, 元进度, 数据配置] diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 0861f5b6..24fcd0ff 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -89,6 +89,9 @@ var _bonus_treasure_carry: Array[String] = [] # 道具件跨关携带(兑付 var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源) var _last_draw_treasure: Array[String] = [] # 最近一次抽签掷定的道具件(掷定前置——卡片直显具体道具;追加批二) var _node_curio_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽) +var _feat_ticket_pity := 0 # 门票保底计数(T5——展示次数:节点开面/货架抽签/刷新重展各计一次;触发清零) +var _feat_ticket_sold := 0 # 门票限购计数(T5——选中/买下即 +1;拿到即开不进持有集) +var _feat_ticket_appeared := 0 # 门票出现计数(T5——quota 上限面) var _shop_mode := "legacy" # 商店模式(迭代 11 第②批 T3——"legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool) @@ -213,6 +216,8 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: _seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义) # 三选一停点续玩分支(迭代 10——选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付) var bp_pending: Dictionary = bp.get("bonus_pending", {}) as Dictionary + _feat_ticket_pity = int(bp.get("feat_ticket_pity", 0)) # T5 扩列——旧档缺键缺省 0(存档兼容) + _feat_ticket_sold = int(bp.get("feat_ticket_sold", 0)) var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary if not bp_pending.is_empty(): _pending_next_level = true @@ -365,6 +370,8 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: if _find_token(candidates, kind, tier, feat_id).is_empty(): push_error("[冒险结构] pick_curio 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, feat_id]) return false + if kind == "feat_ticket": + return _redeem_feat_ticket() # T5 拿到即开——停点候选替换为专长三选一(零摘牌/不进持有集) var value: int = int(db.adventure.curio_value(kind, tier)) var hp_before := int(ledger.get("hero_hp", 0)) if kind == "treasure_item": @@ -461,6 +468,8 @@ func pool_stock(kind: String) -> int: # 写加算区+账本+摘牌(与奖励三选一真单点——买一件=奖励面少一件的库存语义由同库成立)。 # 未知 kind 拒绝(货架词表封闭的前置防御)。 func _consume_pool_purchase(kind: String, tier: String) -> bool: + if kind == "feat_ticket": + return _redeem_feat_ticket() # T5——门票买下即开(零摘牌不进持有集;临时停点开专长三选一面) if not db.adventure.CURIO_KINDS.has(kind): push_error("[冒险结构] 商店购买消费拒绝——件不在池词表: %s" % kind) return false @@ -549,6 +558,8 @@ func present_node_curio(candidates: Array, treasure_detail: Array = []) -> void: # 点选兑付(节点域——无部分回血无钥石;令牌消费与牌库摘牌与通关三选一同库;relic 同权入持有集) func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: + if kind == "feat_ticket": + return _redeem_feat_ticket() # T5 拿到即开——节点停点候选替换为专长三选一(呈现层本地重绘) if _node_curio_pending.is_empty(): push_error("[冒险结构] apply_node_curio 拒绝——无待选停点") return false @@ -753,9 +764,50 @@ func _draw_curio_candidates(channel: String = "reward_node") -> Array: out.append(cand) if out.size() >= int(db.adventure.curio_candidate_count): break + # T5 门票注入(非 Boss 渠道唯一专长入口——替换末位候选占一席,候选数不变;判定含 pity 清零记账) + if channel == "reward_node" and not out.is_empty() and _feat_ticket_consider(rg): + out[out.size() - 1] = {"kind": "feat_ticket", "tier": "rare"} return out +# —— 专长获取门票(T5 迭代 15 卡②——非 Boss 渠道的专长入口;四口径契约④:拿到即开不可囤积/ +# 罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出第 6 必出——计数=展示次数、刷新 +# 重展计入、节点与商店共享、触发清零、限购满不再触发。调用即一次展示判定与记账)—— +func _feat_ticket_consider(rg: RandomNumberGenerator) -> bool: + if _feat_ticket_sold >= int(db.adventure.feat_ticket_limit): + return false # 限购满——不再触发(保底冻结) + if _feat_ticket_appeared >= int(db.adventure.feat_ticket_quota): + return false # 配额满——本局不再出现 + var offer: bool = _feat_ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance) + if offer: + _feat_ticket_pity = 0 + _feat_ticket_appeared += 1 + else: + _feat_ticket_pity += 1 + return offer + + +# 门票兑付(拿到即开——停点候选替换为专长三选一;不进持有集/零摘牌;限购计数 +1): +# 节点停点=本地替换(呈现层读在途态重绘);店内无停点=临时停点 + payload.context 标记(呈现层 +# 重 present 无胜利总结面,确认后只关面回店)。 +func _redeem_feat_ticket() -> bool: + _feat_ticket_sold += 1 + _feat_ticket_pity = 0 + var cands: Array = _draw_curio_candidates("level_clear") # 专长池(与通关渠道同白名单) + if cands.is_empty(): + push_error("[冒险结构] 门票兑付失败——专长池空集") + _feat_ticket_sold -= 1 + return false + if not _node_curio_pending.is_empty(): + _node_curio_pending["candidates"] = cands.duplicate(true) + return true + var payload: Dictionary = {"context": "feat_ticket", "candidates": cands.duplicate(true)} + _curio_pending = {"candidates": cands.duplicate(true), "payload": payload.duplicate(true)} + curio_choice_presented.emit(payload) + _snapshot_breakpoint() + return true + + func _roll_from(rg: RandomNumberGenerator) -> String: var pool: Array = _bonus_treasure_pool() return String(pool[rg.randi_range(0, pool.size() - 1)]) @@ -844,7 +896,7 @@ func _start_current_level() -> bool: _runner.set_tree_rules(_tree_rules) # 迭代 12 T10——树 rule 键集下传(A/B 面消费) if _shop_mode == "pool": # 商店共享池注入(迭代 11 第②批 T3——库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动) - _runner.set_shop_pool(_curio_deck, Callable(self, "_consume_pool_purchase")) + _runner.set_shop_pool(_curio_deck, Callable(self, "_consume_pool_purchase"), Callable(self, "_feat_ticket_consider")) # 三选一道具件跨关携带注入(迭代 10)——迭代 11 第①批 T7 退役:aux 卡出池后 carry 恒空集, # 注入面随停点渠道退役拆除(容器字段保留=替换子交互机械惰性承载,零投放零行为) level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) @@ -990,6 +1042,8 @@ func _snapshot_breakpoint() -> void: "bonus_deck": _curio_deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) "relics": _feats.duplicate(), # 特性持有集(迭代 12 T1——局内随机渠道获得态续玩防丢失) "bonus_pending": _curio_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约) + "feat_ticket_pity": _feat_ticket_pity, # T5 门票保底计数(扩列向后兼容——旧档缺键缺省 0) + "feat_ticket_sold": _feat_ticket_sold, # T5 门票限购计数(同上) "bonus_result": _curio_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付) "bonus_aux_carry": _bonus_treasure_carry.duplicate(true), # 道具件跨关携带(注入前中断防静默丢失) "keystone_drops": _keystone_drops.duplicate(true), # 逐杀掉落明细(摘要归因续接——迭代 10) diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 583c3d2f..ce7a6fd8 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -106,6 +106,8 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: if adv != null and shp != null: for row in shp.pool_shelf: var kind: String = String(row.get("kind_id", "")) + if kind == "feat_ticket": + continue # T5 门票豁免——货架专属机制种(非 deck 池种,零摘牌拿到即开),不入 CURIO_KINDS if not adv.CURIO_KINDS.has(kind): push_diag.call("error", "shelf_dangling_kind", "ShopTable.pool_shelf kind %s 不在 AdventureTable.CURIO_KINDS(共享池牌库外种)" % kind) continue diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 06582dd3..e1c6776e 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -71,6 +71,7 @@ var _attune_charges: Dictionary = {} # 同调主动件充能账本( var _shop_pool_mode := false # pool 模式旗(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传) var _pool_deck: Array = [] # 共享池令牌库共享引用(AdventureRunner._curio_deck——库存=剩余令牌数单点) var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_curio_pick 同口写加算区+账本+摘牌) +var _pool_ticket := Callable() # 门票展示判定 handler(T5——AdventureRunner._feat_ticket_consider;节点与商店共享计数) var _shop_shelf: Array = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡) var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零) var _shop_paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零) @@ -735,9 +736,10 @@ func player_request_refresh() -> bool: # 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 + # 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。 -func set_shop_pool(deck_ref: Array, consume_handler: Callable) -> void: +func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable()) -> void: _pool_deck = deck_ref _pool_consume = consume_handler + _pool_ticket = ticket_consider # T5——门票展示判定(AdventureRunner._feat_ticket_consider;同流取数复用 shop_draw scope) _shop_pool_mode = true @@ -779,6 +781,10 @@ func _shop_draw_shelf() -> void: _shop_shelf.append(tok) if _shop_shelf.size() >= 3: break + # T5 门票注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。 + # 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移) + if _pool_ticket.is_valid() and not _shop_shelf.is_empty() and bool(_pool_ticket.call(stream)): + _shop_shelf[_shop_shelf.size() - 1] = {"kind": "feat_ticket", "tier": "rare"} # 商店保底退役(迭代 15 卡② T4——渠道轴定位裁决:商店渠道只出属性值提升+资源,专长在非 Boss # 渠道只以门票形态出现(奇物.md v2.7/A4 断言)。迭代 12 T6「货架零特性挤卡换吞噬流件」简化口径 # 与渠道白名单直接冲突(devour=规则改写型=专长系),随渠道分离退役;专长商店入口=T5 门票。 diff --git a/game/data/schemas/adventure_table.gd b/game/data/schemas/adventure_table.gd index 94ec89b4..efc01d04 100644 --- a/game/data/schemas/adventure_table.gd +++ b/game/data/schemas/adventure_table.gd @@ -68,6 +68,15 @@ const TIER_QUOTAS := {"common": 70, "rare": 25, "rarest": 3} @export var curio_value_heal_rare: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限) @export var curio_value_ap_rarest: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量 +# —— 专长获取门票(迭代 15 卡② T5——非 Boss 渠道的专长入口形态,奇物.md v2.7 渠道轴定位; +# 四口径契约④:拿到即开不可囤积/罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出 +# 第 6 必出(计数=展示次数、刷新重展计入、节点与商店共享、触发清零、限购满不再触发)。 +# 四参数全部 provisional 归曲线调参批;limit/pity_step 系口径定死值调参批可复议)—— +@export var feat_ticket_chance: int = 20 # [0,100] knob(provisional)——每次非 Boss 渠道展示门票出现概率 % +@export var feat_ticket_quota: int = 2 # [0,9] knob(provisional)——每局门票出现次数上限 +@export var feat_ticket_limit: int = 1 # [1,9] knob(口径定死=1)——一局限购(选中/买下)次数 +@export var feat_ticket_pity_step: int = 5 # [1,99] knob(口径定死=5)——保底步数(连续未出展示次数) + ## 令牌配额读数(未列 kind×tier 组合 = 0——heal 无普通档/ap 无普通稀有档按 v2.2 构成) func curio_quota(kind: String, tier: String) -> int: @@ -125,6 +134,9 @@ func deck_total() -> int: func validate() -> Array: var errs: Array[Dictionary] = [] + for kv in [["feat_ticket_chance", feat_ticket_chance, 0, 100], ["feat_ticket_quota", feat_ticket_quota, 0, 9], ["feat_ticket_limit", feat_ticket_limit, 1, 9], ["feat_ticket_pity_step", feat_ticket_pity_step, 1, 99]]: + if int(kv[1]) < int(kv[2]) or int(kv[1]) > int(kv[3]): + errs.append({"category": "domain", "message": "%s 出域 [%d,%d]: %d" % [kv[0], kv[2], kv[3], kv[1]]}) 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 kind in CURIO_KINDS: diff --git a/game/data/schemas/shop_table.gd b/game/data/schemas/shop_table.gd index f8e5ac70..f6de3465 100644 --- a/game/data/schemas/shop_table.gd +++ b/game/data/schemas/shop_table.gd @@ -28,7 +28,7 @@ extends Resource const GP_TIERS := ["normal", "elite", "boss"] const PRODUCT_ATTRS := ["atk", "max_hp", "armor_class"] -const SHELF_KINDS := ["attack", "max_hp", "armor", "ap"] # 货架候选词表封闭(四切边后种——切边面翻案须用户) +const SHELF_KINDS := ["attack", "max_hp", "armor", "ap", "feat_ticket", "heal"] # 货架候选词表(四切边后种 + T5 门票行 + T6 heal 补回复〔默认原则:分类内成员都入该渠道池〕——奇物批迭代15②) func validate() -> Array: diff --git a/game/data/tables/adventure.tres b/game/data/tables/adventure.tres index aa3390b1..4f7178ff 100644 --- a/game/data/tables/adventure.tres +++ b/game/data/tables/adventure.tres @@ -26,6 +26,10 @@ curio_value_gp_common = 20 curio_value_gp_rare = 50 curio_value_heal_rare = 6 curio_value_ap_rarest = 1 +feat_ticket_chance = 20 +feat_ticket_quota = 2 +feat_ticket_limit = 1 +feat_ticket_pity_step = 5 ; TP0 令牌池换代(2026-10-03,node-reward v2.2 追认 b 落地):三选一池 = 固定配额令牌池—— ; 普通 70/稀有 25/罕见 3 = 98 张(罕见档原列「道具件 2」随 treasure_item 池种退役收窄——台账自决披露项); diff --git a/game/data/tables/shop.tres b/game/data/tables/shop.tres index bd1203c1..ca4ce3f2 100644 --- a/game/data/tables/shop.tres +++ b/game/data/tables/shop.tres @@ -81,6 +81,14 @@ pool_shelf = [{ "kind_id": "ap", "display_name": "行动点件", "base_price": 5 +}, { +"kind_id": "feat_ticket", +"display_name": "奇物门票", +"base_price": 5 +}, { +"kind_id": "heal", +"display_name": "应急回复", +"base_price": 5 }] price_a = 1.8 price_b = 1.1 @@ -93,7 +101,7 @@ price_b = 1.1 ; (4+2)+(2+2)+(4+2)=16、全购欲望 ≈ 26–34——取舍空间成立;量级与方差全部 provisional-pending-曲线调参批 ;(用户拍板骨架先行、数值整体后置,行值归 难度曲线与Boss)。 ; GP 商店房重构批(M-4 迭代 11 第②批 T1,2026-10-03——任务书 v1.3 全裁 P1 共享池/P4 ceil/P5 甲): -; pool_shelf 行族 = 共享池货架(kind_id 词表封闭四切边后种 attack/max_hp/armor/ap——GP 袋负交易切边、 +; pool_shelf 行族 = 共享池货架(kind_id 词表 attack/max_hp/armor/ap/feat_ticket/heal 六行——heal 系 T6 商店补回复〔默认原则:分类内成员都要在它该出现的渠道池里,现行缺〕、GP 袋负交易切边计价物不自售、feat_ticket 系 T5 门票行〔非 Boss 渠道专长入口,价与其他商品相当=契约④口径②〕、 ; 规则改写型特性三轴 doctrine 切边;库存=上限即库存,账本在 冒险结构 _bonus_taken 单点共享,行族不记库存); ; 价格公式换代(pool 模式)= ceil(base_price × b^(关序−1) × A^(本次进店已购次数)),price_a=1.8/price_b=1.1 ; provisional 投值(零人工令改 provisional+后置追认,原 P5 甲研究拍板后置);products 行族 + 加法定价 diff --git a/game/presentation/curio_interact_controller.gd b/game/presentation/curio_interact_controller.gd index 696eaecc..e3d3c346 100644 --- a/game/presentation/curio_interact_controller.gd +++ b/game/presentation/curio_interact_controller.gd @@ -72,6 +72,13 @@ func resume_if_pending() -> bool: # 三选一停点(非末关通关恰一次):Boss 胜利先经战斗总结结算面(settlement-summary v1.1——钥石 # 增量行在其上),确认后进三选一面(level-clear.md v2);续玩停点恢复同入口(零自动推进契约) func on_presented(payload: Dictionary) -> void: + if String(payload.get("context", "")) == "feat_ticket": + # T5 店内门票兑付——临时停点开专长三选一面(零胜利总结面;确认后只关面回店) + _pending_curio = payload.duplicate(true) + _curio_context = "feat_ticket" + _open_modal.call(curio_overlay) + curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(payload.get("candidates", []) as Array)) + return if int(payload.get("aux_cleared", 0)) > 0: toast.show_toast("上关剩余 %d 件秘宝已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 迭代 7 T4 清空反馈 _pending_curio = payload.duplicate(true) @@ -122,7 +129,10 @@ func _curio_card_texts(candidates: Array, treasure_detail: Array = []) -> Array: var rid := String((tok as Dictionary).get("feat_id", "")) var cid := "%s|%s|%s" % [kind, tier, rid] var text := "" - if kind == "relic": + if kind == "feat_ticket": + text = "奇物门票 +选定即开一次奇物三选一(T5 门票——拿到即开不进持有集)" + elif kind == "relic": text = Narrator.feat_card_text(db.feat_row(rid)) elif kind == "treasure_item": var iid := String(treasure_detail[treasure_i]) if treasure_i < treasure_detail.size() else "" @@ -183,6 +193,12 @@ func on_pick_confirmed(item_id: String) -> void: var ok: bool = adventure.apply_node_curio(kind, tier, rid) if _curio_context == "reward_node" else adventure.pick_curio(kind, tier, rid) if not ok: push_error("[bonus_interact] 三选一兑付被拒——见输出日志") + return + # T5——节点渠道门票拿到即开:候选已被替换为专长面,本地读在途态重绘(零 emit——context 保持 reward_node, + # 后续兑付/确认仍走节点路径回图选路) + if _curio_context == "reward_node" and kind == "feat_ticket": + var st2: Dictionary = adventure.node_curio_pending_state() + curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st2.get("candidates", []) as Array)) func on_treasure_replace_presented(items: Array, incoming: Dictionary) -> void: @@ -221,6 +237,8 @@ func on_continue() -> void: _close_modal.call(curio_overlay) curio_overlay.close_pick() var adventure = _adventure_get.call() + if _curio_context == "feat_ticket": + return # T5 店内门票面——专长兑付完成后关面回店(无推进/无回图) if _curio_context == "reward_node": var runner = _runner_get.call() if runner != null: diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 129dfb27..25066713 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -663,6 +663,43 @@ func _smoke_feats() -> void: node_relic_seen = true check(feat_seen.size() >= 2, "通关渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)") check(not node_relic_seen, "节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以门票形态出现,T4 渠道白名单)") + # T5 门票行为断言(A5 拿到即开 / A6 保底计数——契约④四口径;独立 runner 防污染 a_r 牌库——后续 relic 兑付断言按 5 枚锚定) + var a_t = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(91)) + a_t.start_adventure(91, {"first_script": true}) + var stock_before := {} + for kk in ["attack", "gp", "heal"]: + stock_before[kk] = a_t.pool_stock(kk) + a_t._node_curio_pending = {"candidates": [{"kind": "feat_ticket", "tier": "rare"}, {"kind": "attack", "tier": "common"}], "aux_detail": []} + check(a_t.apply_node_curio("feat_ticket", "rare"), "A5 门票兑付受理(节点渠道——拿到即开)") + var tkt_cands: Array = (a_t.node_curio_pending_state().get("candidates", []) as Array) + var tkt_all_relic := tkt_cands.size() > 0 + for t in tkt_cands: + if String((t as Dictionary).get("kind", "")) != "relic": + tkt_all_relic = false + check(tkt_all_relic, "A5 拿到即开——停点候选已替换为专长三选一(全 relic)") + check(a_t.pool_stock("attack") == int(stock_before["attack"]) and a_t.pool_stock("gp") == int(stock_before["gp"]), + "A5 零摘牌——门票兑付不动令牌库(不进持有集)") + check(not (a_t.feats_held() as Array).has("feat_ticket"), "A5 无门票残留持有集") + var feats_n := (a_t.feats_held() as Array).size() + a_t.apply_node_curio("relic", String((tkt_cands[0] as Dictionary).get("tier", "rare")), String((tkt_cands[0] as Dictionary).get("feat_id", ""))) + check((a_t.feats_held() as Array).size() == feats_n + 1, "A5 专长面兑付受理(门票开出候选可正常入持有集)") + # A6 保底:pity 直填 5 → 下次展示必出(第 6 次必出口径);限购满 → 恒不触发 + a_t._feat_ticket_sold = 0 # A5 已兑付一枚门票(sold=1=limit)——A6 段重置计数隔离验证 + a_t._feat_ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + var rg_t: RandomNumberGenerator = a_t.rng.open_stream("reward_pick") + check(a_t._feat_ticket_consider(rg_t), "A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)") + check(a_t._feat_ticket_pity == 0, "A6 触发后清零") + a_t._feat_ticket_sold = int(a_t.db.adventure.feat_ticket_limit) + a_t._feat_ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + check(not a_t._feat_ticket_consider(rg_t), "A6 限购满不再触发(保底冻结)") + a_t._feat_ticket_sold = 0 + a_t._feat_ticket_pity = 0 + var offered := false + for i in 200: + if a_t._feat_ticket_consider(rg_t): + offered = true + break + check(offered, "A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)") a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "m3"}, {"kind": "gp", "tier": "common"}], "payload": {}} var deck_m3 := 0 for t in a_r._curio_deck: @@ -3942,7 +3979,7 @@ func _smoke_shop_pool() -> void: # ⑧ 终局池读数(run_adventure pool 面——商店与奖励跨渠道消耗后的终局余量出讫) var st_pool: Dictionary = autoplay.run_adventure(_db, 42, 0, 0, false, null, "normal", "min", {}, {}, {"mode": "pool"}) var ps: Dictionary = st_pool.get("pool_stock", {}) - check(ps.size() == 4, "终局池读数四键出讫(run_adventure pool 面——读数只锚一次载体)") + check(ps.size() == 6, "终局池读数六键出讫(run_adventure pool 面——四池种+T5 门票+T6 heal 行,读数只锚一次载体)") var ps_ok := true for k in ps.keys(): var quota := 0