From 1387aedc94c9b44cf1bf709c87af30ecd1c7946f Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Mon, 12 Oct 2026 00:08:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(vouchers):=20=E8=BF=AD=E4=BB=A320=20T4=20?= =?UTF-8?q?=E5=87=AD=E8=AF=81=E4=BB=B6=E4=BA=94=E4=BB=B6=E5=AE=9E=E8=A3=85?= =?UTF-8?q?+F-1=20=E5=88=B7=E6=96=B0=E5=86=99=E9=94=99=E8=B4=A6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=88=E4=B8=93=E9=95=BF=E5=AE=9E=E8=A3=85=E6=89=B9?= =?UTF-8?q?=20B=E6=AE=B5=E4=BA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 五件路由(_apply_curio_pick 通道面):刷新券→刷新存量账 _refresh_stock(**F-1 修复**: 退役 _attune_refresh_x 写口——X 每停点重置吞存量=写错账缺陷正主;消耗序 X→Y→存量, attune_refresh_left 扩第三源;旧早退路径零摘牌缺陷同批修正=选中即消尽)/储备金袋→ _pending_rap 战首 RAP 注入(action_economy.grant_reserve_ap 新口)/同调电池→ _battery_pending+choose_battery_target 玩家点选跨层写 attune_charges(禁自动替选—— 装配集/主动充能件双校验拒绝)/时沙→_time_sand_queue 战首首轮 AP 队列(池增量不改上限, hero_ap_bonus 同通道)/完美保险→_pending_insurance 战首豁免次数(flag_miss 首见判定 _perfect_hit_false 回退=完美谓词无痕,节点级未用即过期)。 注入链:runner set_voucher_grants 三账→enc voucher_*→编排器消费,节点级一次性注入即清零。 配额投放:凭证四件入稀有档(rap 2/battery 1/时沙 2/保险 1)→结构锚 rare 9/总量 104 (已裁换代授权内 provisional——偏离 GDD 占位记录随代拍板追认单)。 断点扩列(T7 前置):refresh_stock/battery_pending 随档+resume 回填(旧档缺键缺省 0)。 测试:feat_vouchers_test 新套 20 checks(V9 逐件路由+F-1 反例钉面/V10 留任路由+AP 属性轴) 入 run_all 21 套全绿;冒烟 863/0;smoke 牌库数 98→104 系换代。 --- game/core/action_economy.gd | 5 ++ game/core/adventure_runner.gd | 83 ++++++++++++++++++-- game/core/battle_orchestrator.gd | 14 +++- game/core/level_runner.gd | 19 +++++ game/data/schemas/adventure_table.gd | 19 ++--- game/smoke/smoke_all.gd | 10 +-- game/tests/run_all.gd | 1 + game/tests/unit/core/feat_vouchers_test.gd | 89 ++++++++++++++++++++++ 8 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 game/tests/unit/core/feat_vouchers_test.gd diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 89c2a73e..4f8930cf 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -179,6 +179,11 @@ func request_stop() -> bool: return true +# 储备池注入(迭代 20 T4——储备金袋凭证:战首 RAP +N,节点级一次性;任意相位受理=战首注入时 Idle/Active 均合法) +func grant_reserve_ap(n: int) -> void: + _rap += maxi(0, n) + + # 轮末停手转存决策(round_end 发射点调用——编排器授予段尾):transfer=true=剩余 AP 转蓄力层 # (上限 3,整备军备面);false=1:1 转存 RAP(缺省)。非停手轮(cleared/耗尽)_last_stop_ap=0 # 两分支均零操作。转蓄后达上限的沿发 charge_full(R6 框架先行——现无订阅者)。 diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index e2763713..958827fe 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -78,6 +78,12 @@ var _attune_refresh_y := 0 # Y 冒险周期池(开局参数面 var _curio_paid_refreshes := 0 # 本次停点已付费刷新次数(2026-10-06 用户令对齐商店 MC-1——GP 刷新价曲线输入,随停点创建清零=与商店进店清零同构) var attune_charges := {} # 同调主动件冒险充能账本(迭代 11 第①批 T6——跨关卡共享 dict;settlement use_treasure 扣减直改同引用;断点随 attunement_charges 键入档) var adventure_counters := {} # adventure 域计数器账本(迭代 20 T6——攒层/付费计数等跨战持久件账;效果引擎 rebuild 回灌/战末导出回写共享引用;断点随 adventure_counters 键入档) +# ---- 凭证件三本新账(迭代 20 T4——奇物.md 凭证件表新 5 件;F-1 刷新写错账修复承载面)---- +var _refresh_stock := 0 # 刷新存量账(冒险级——刷新券加仓;**不经 _attune_refresh_x**:X 每停点重置会吞存量=旧写错账缺陷 F-1) +var _pending_rap := 0 # 储备金袋待注入(节点级——下一战 RAP +N,战首注入后清零) +var _battery_pending := 0 # 同调电池待分配充能(冒险级——choose_battery_target 玩家点选消费,禁自动替选) +var _time_sand_queue := 0 # 时沙待注入(回合级——下一战首轮 AP +N,战首入队后清零) +var _pending_insurance := 0 # 完美保险待注入(节点级——下一战标错豁免 N 次,战首注入后清零;战内未用即过期) var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省) var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) # ---- 三选一停点态(迭代 10 奖励重构批)---- @@ -224,6 +230,8 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: var bp_counters = bp.get("adventure_counters", null) if bp_counters is Dictionary: adventure_counters = (bp_counters as Dictionary).duplicate(true) + _refresh_stock = maxi(0, int(bp.get("refresh_stock", 0))) # 迭代 20 T4/T7——旧档缺键缺省 0 + _battery_pending = maxi(0, int(bp.get("battery_pending", 0))) var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary if not bp_pending.is_empty(): _pending_next_level = true @@ -282,6 +290,11 @@ func _reset_curio_state() -> void: _keystone_drops = [] _node_curio_pending = {} adventure_counters = {} + _refresh_stock = 0 + _pending_rap = 0 + _battery_pending = 0 + _time_sand_queue = 0 + _pending_insurance = 0 # 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = CURIO_KINDS×TIERS 逐层, @@ -421,15 +434,32 @@ func _feat_payload_keys(feat_id: String) -> Dictionary: # 按挂点消费,档位质变由 FeatsEngine 求值)。兑付即从 _curio_deck 摘除该令牌(选中踢池; # 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。 func _apply_curio_pick(kind: String, tier: String, value: int, feat_id: String = "") -> bool: - if kind == "refresh": - _attune_refresh_x += value # T6——刷新次数件兑付=X 余量增量(作用三选一界面非角色,不进加算区——registry refresh_boundary) - return true if kind == "relic" and db.feat_row(feat_id).is_empty(): push_error("[冒险结构] relic 兑付失败——表无此件: %s" % feat_id) return false # 先校验后摘牌(令牌零丢失) if not _consume_deck_token(kind, tier, feat_id): push_error("[冒险结构] 令牌消费失败——牌库无此令牌: %s|%s|%s" % [kind, tier, feat_id]) return false + if kind == "refresh": + _refresh_stock += value # T4 F-1 修复——刷新券入刷新存量账(冒险级;**退役 _attune_refresh_x 写口**:X 每停点 _reset_refresh_x 重置会吞存量=写错账缺陷正主;摘牌先于落账=选中即消尽,旧早退路径零摘牌缺陷同批修正) + _curio_taken["refresh"] = int(_curio_taken.get("refresh", 0)) + 1 + return true + if kind == "rap": + _pending_rap += value # T4——储备金袋:下一战 RAP +N(节点级,战首注入后清零) + _curio_taken["rap"] = int(_curio_taken.get("rap", 0)) + 1 + return true + if kind == "attune_battery": + _battery_pending += value # T4——同调电池:待分配充能(choose_battery_target 玩家点选消费——禁自动替选) + _curio_taken["attune_battery"] = int(_curio_taken.get("attune_battery", 0)) + 1 + return true + if kind == "time_sand": + _time_sand_queue += value # T4——时沙:下一战首轮 AP +N(回合级池增量,不改上限) + _curio_taken["time_sand"] = int(_curio_taken.get("time_sand", 0)) + 1 + return true + if kind == "perfect_insurance": + _pending_insurance += value # T4——完美保险:下一战标错豁免 N 次(节点级,未用即过期) + _curio_taken["perfect_insurance"] = int(_curio_taken.get("perfect_insurance", 0)) + 1 + return true if kind == "relic": if not _feats.has(feat_id): _feats.append(feat_id) @@ -670,9 +700,37 @@ func _reset_refresh_x() -> void: _attune_refresh_x = _attune_refresh_x_total() -# 当前可刷新次数(X 优先 + Y 池——呈现层余量读数口) +# 当前可刷新次数(X 优先 + Y 池 + 刷新存量账——呈现层余量读数口;T4 F-1 修复扩第三源) func attune_refresh_left() -> int: - return _attune_refresh_x + _attune_refresh_y + return _attune_refresh_x + _attune_refresh_y + _refresh_stock + + +# 刷新存量账读数(T4——刷新券加仓余量单独读面;呈现层与断点消费) +func refresh_stock() -> int: + return _refresh_stock + + +# 同调电池充能分配(T4——玩家点选哪件已装配主动秘宝 +N;**禁自动替选**: +# 无待分配/件不在装配集/非主动充能件=拒绝零副作用) +func choose_battery_target(item_id: String) -> bool: + if _battery_pending <= 0: + push_error("[冒险结构] 电池分配拒绝——无待分配充能") + return false + if not _attunement.has(item_id): + push_error("[冒险结构] 电池分配拒绝——件不在装配集: %s" % item_id) + return false + var arow: Dictionary = db.treasure_item_row(item_id) + if String(arow.get("form", "")) != "active_charged": + push_error("[冒险结构] 电池分配拒绝——非主动充能件: %s" % item_id) + return false + _battery_pending -= 1 + attune_charges[item_id] = int(attune_charges.get(item_id, 0)) + 1 + return true + + +# 待分配充能读数(呈现层电池选件面数据源) +func battery_pending() -> int: + return _battery_pending # 节点停点在途态读回(组合根重 present 用——candidates/aux_detail 值拷贝) @@ -712,12 +770,16 @@ func _refresh_curio_common(paid: bool) -> bool: if paid: _curio_paid_refreshes += 1 else: - if _attune_refresh_x <= 0 and _attune_refresh_y <= 0: + # 免费位消耗序:X(刷新件每停点重给,优先消耗)→ Y(营地冒险周期池)→ 存量账(刷新券加仓, + # 迭代 20 T4 F-1 修复新账——跨停点持久) + if _attune_refresh_x <= 0 and _attune_refresh_y <= 0 and _refresh_stock <= 0: return false if _attune_refresh_x > 0: _attune_refresh_x -= 1 - else: + elif _attune_refresh_y > 0: _attune_refresh_y -= 1 + else: + _refresh_stock -= 1 if not _curio_pending.is_empty(): var cands: Array = _draw_curio_candidates("level_clear") _curio_pending["candidates"] = cands.duplicate(true) @@ -998,6 +1060,11 @@ func _start_current_level() -> bool: # 迭代 20 T2——cap 检查回调四参注入) _runner.set_shop_pool(_curio_deck, Callable(self, "_consume_pool_purchase"), Callable(self, "_feat_ticket_consider"), Callable(self, "_curio_cap_ok")) _runner.set_adventure_counters(adventure_counters) # 迭代 20 T6——adventure 域计数器共享引用下传 + # 迭代 20 T4——凭证件三账注入(节点级/回合级一次性:注入即清零,未用即过期=寿命档语义) + _runner.set_voucher_grants(_pending_rap, _time_sand_queue, _pending_insurance) + _pending_rap = 0 + _time_sand_queue = 0 + _pending_insurance = 0 # 三选一道具件跨关携带注入(迭代 10)——迭代 11 第①批 T7 退役:aux 卡出池后 carry 恒空集, # 注入面随停点渠道退役拆除(容器字段保留=替换子交互机械惰性承载,零投放零行为) level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) @@ -1147,6 +1214,8 @@ func _snapshot_breakpoint() -> void: "feat_ticket_pity": _feat_ticket_pity, # T5 门票保底计数(扩列向后兼容——旧档缺键缺省 0) "feat_ticket_sold": _feat_ticket_sold, # T5 门票限购计数(同上) "adventure_counters": adventure_counters.duplicate(true), # 迭代 20 T6/T7——adventure 域计数器随断点(续玩跨战账不丢) + "refresh_stock": _refresh_stock, # 迭代 20 T4/T7——刷新存量账随断点(F-1 新账) + "battery_pending": _battery_pending, # 迭代 20 T4/T7——电池待分配充能随断点 "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/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 47f2394f..e2844a4f 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -86,6 +86,7 @@ var _fx_bonus_delta := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # var _fx_new_resource_types := {} # 本战已见资源类型集(地质学家 fact new_resource_type 判定——battle 域) var _fx_pending_reveals: Array[Dictionary] = [] # 揭示效果待兑队列(见血开窍/斩首学者/地质学家/跳格采掘——授予段末统一兑付,防轮 facts 迭代中追加重入) var _fx_revealing := false # fx 揭示兑付中标记(_on_cell_revealed 缓冲旁路——fx 揭示的奖励由兑付点直接入账,防二次缓冲二次兑付) +var _fx_insurance := 0 # 完美保险余量(迭代 20 T4——本战标错豁免次数;消费于 flag_miss 首见判定) # 特性运行态(内容数据驱动扩展批 T3——relic 持有集经 enc.feats 注入:效果行为=数据行 triggers 经 # 效果引擎分发;魔矢每战首次计数 battle 域。空集=零扰动) var _feat_first_dig_used := false @@ -342,9 +343,14 @@ func begin_battle(enc: Dictionary) -> bool: # 迭代 10 奖励重构批——跨冒险行动点加成轴(统一冒险加算区第四轴):经排队修正通道兑现 # 「每场战斗开始首轮 AP +加成值」(queue_ap_delta = 下轮开轮基线加算、notify_round_start 兑现后 # 清零——首轮即消费,后续轮次不重复兑现;语义锚 = 行动经济 GDD「跨冒险行动点加成轴」节) - var ap_bonus: int = int(enc.get("hero_ap_bonus", 0)) + var ap_bonus: int = int(enc.get("hero_ap_bonus", 0)) + int(enc.get("voucher_time_sand", 0)) # 迭代 20 T4——时沙同通道(首轮池增量,不改上限) if ap_bonus > 0: action_economy.queue_ap_delta(ap_bonus) + # 迭代 20 T4——储备金袋(节点级 RAP 注入)与完美保险(标错豁免次数)接收 + var voucher_rap: int = int(enc.get("voucher_rap", 0)) + if voucher_rap > 0: + action_economy.grant_reserve_ap(voucher_rap) + _fx_insurance = int(enc.get("voucher_insurance", 0)) # 工兵晨令直调拆除(内容数据驱动扩展批 T4——round_start grant_ap(mode:queue) 订阅行承载, # when round_index ≤ effective_rounds 行字段 value_from 定影;发射位先于轮切换=当轮兑现) # round_start 发射+施加(内容数据驱动扩展批 T3——发射位先于行动经济轮切换:树规则 grant_ap @@ -1112,6 +1118,12 @@ func _judge_flag(evt: Dictionary, ri: int) -> void: if _flag_miss_seen.has(cell): return # 每格实例一次去重(归因行与负向兑付同键——防逐轮惩罚轰炸) _flag_miss_seen[cell] = true + # 完美保险(迭代 20 T4——本战下一次标错不破坏完美资格:_perfect_hit_false 回退=本次标错 + # 对完美谓词无痕;只保一次/件,节点级用完即止) + if _fx_insurance > 0: + _fx_insurance -= 1 + _perfect_hit_false -= 1 + _emit_row("insurance_used", "", cell, ri, "完美保险·标错豁免", 0, {"left": _fx_insurance}, "") var hl := "" if _perfect_hit_false == 1: hl = "perfect_broken" # 本板完美谓词已破的归因锚 diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 230bafbb..1f80217e 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -485,9 +485,15 @@ func _start_battle(node: Dictionary) -> bool: "tree_rules": _tree_rules, "bonus_ref": bonus, "adventure_counters": _adventure_counters_ref, + "voucher_rap": _voucher_rap, + "voucher_time_sand": _voucher_time_sand, + "voucher_insurance": _voucher_insurance, "node_type": String(node.get("type", ""))}): push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1))) return false + _voucher_rap = 0 # 迭代 20 T4——节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清) + _voucher_time_sand = 0 + _voucher_insurance = 0 return true @@ -627,6 +633,19 @@ func set_adventure_counters(ref: Dictionary) -> void: _adventure_counters_ref = ref +# 凭证件三账注入(迭代 20 T4——储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性, +# battle begin 消费后归零;缺省 0=零扰动) +var _voucher_rap := 0 +var _voucher_time_sand := 0 +var _voucher_insurance := 0 + + +func set_voucher_grants(rap: int, time_sand: int, insurance: int) -> void: + _voucher_rap = maxi(0, rap) + _voucher_time_sand = maxi(0, time_sand) + _voucher_insurance = maxi(0, insurance) + + # 同调主动件充能账本注入(迭代 11 第①批 T6——共享 dict 引用直改;能力件骑库存面注入: # 差额注入防续玩双份——resume 路径 restore_inventory 已含断点内 attune 记录) func set_attune_charges(charges: Dictionary) -> void: diff --git a/game/data/schemas/adventure_table.gd b/game/data/schemas/adventure_table.gd index 31f9276b..9f9154d1 100644 --- a/game/data/schemas/adventure_table.gd +++ b/game/data/schemas/adventure_table.gd @@ -35,10 +35,11 @@ const TIER_COMMON := "common" const TIER_EXCELLENT := "excellent" const TIER_RARE := "rare" const TIER_LEGENDARY := "legendary" -## 档位配额结构锚(迭代 20 T2 四级换代——70/25/3 直映射改名承袭〔普通 70/优秀 25/稀有 3〕+ -## 传说 0=头奖位留空,池总量 98 不变=零偏离已裁结构锚数字〔配额纪律:改总量须用户重裁〕; -## validate() 硬断言;改总量=重裁非调参)。 -const TIER_QUOTAS := {"common": 70, "excellent": 25, "rare": 3, "legendary": 0} +## 档位配额结构锚(迭代 20 换代——T2 四级直映射 70/25/3/0;T4 凭证四件入稀有档投放 +6 +## (rap 2/battery 1/时沙 2/保险 1)→ 稀有 9、总量 104。已裁换代授权内的 provisional 投放 +## (卡文 T2「四级配额表重构」+Q3 单迭代全量),偏离 GDD 占位记录随代拍板追认单呈报; +## validate() 硬断言;此后改总量=重裁非调参)。 +const TIER_QUOTAS := {"common": 70, "excellent": 25, "rare": 9, "legendary": 0} # —— 通关部分回血(固定型,非末关通关兑付)—— @export var clear_heal_pct: int = 20 # [0,100] knob——回复生效上限的百分比,封顶上限不回满 @@ -62,10 +63,10 @@ const TIER_QUOTAS := {"common": 70, "excellent": 25, "rare": 3, "legendary": 0} @export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(原 5;T6 挤占 2 席位予 refresh——用户 2026-10-05 裁方案B,总量 98 锚不变) @export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(T6 新入池资源类成员;provisional 归曲线调参批) @export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档;旧罕见档物理承袭) -@export var curio_quota_rap_rare: int = 0 # [0,99]——储备金袋(T4 路由同批投放;provisional 归调参批) -@export var curio_quota_attune_battery_rare: int = 0 # [0,99]——同调电池(同上) -@export var curio_quota_time_sand_rare: int = 0 # [0,99]——时沙(同上) -@export var curio_quota_perfect_insurance_rare: int = 0 # [0,99]——完美保险(同上) +@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(T4 投放 provisional 归调参批) +@export var curio_quota_attune_battery_rare: int = 1 # [0,99]——同调电池(T4 投放 provisional 归调参批) +@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(T4 投放 provisional 归调参批) +@export var curio_quota_perfect_insurance_rare: int = 1 # [0,99]——完美保险(T4 投放 provisional 归调参批) # —— 令牌件值(小/大数值对——v2.2 追认 b 定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)—— @export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤 @@ -204,7 +205,7 @@ func tier_total(tier: String) -> int: return total -## 牌库总量(结构锚——四级换代后 98 张不变:70+25+3+0) +## 牌库总量(结构锚——T2 四级 98 + T4 凭证投放 6 = 104:70+25+9+0) func deck_total() -> int: var total := 0 for tier in TIERS: diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index bb2173ff..acfa6111 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -219,7 +219,7 @@ func _smoke_adventure_table() -> void: for tier in _db.adventure.TIERS: check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]), "档位配额结构锚(四级 70/25/3/0 直映射承袭——迭代 20 T2):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])]) - check(int(_db.adventure.deck_total()) == 98, "令牌池总量 = 98 张(四级 普通 70/优秀 25/稀有 3/传说 0——直映射承袭零偏离)") + check(int(_db.adventure.deck_total()) == 104, "令牌池总量 = 104 张(四级 普通 70/优秀 25/稀有 9/传说 0——T2 直映射 98+T4 凭证投放 6)〔专长批 V5〕") check(int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 @@ -649,8 +649,8 @@ func _smoke_feats() -> void: feat_tokens += 1 var tk := String((t as Dictionary).get("tier", "")) tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1 - check(feat_tokens == 29 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 8, - "专长带入池:29 件四级直映射(普通 12/优秀 9/稀有 8/传说 0)——牌库 98+29=127〔专长批 V2〕") + check(feat_tokens == 29 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 8 and a_r0._curio_deck.size() == 133, + "专长带入池:29 件四级直映射(普通 12/优秀 9/稀有 8/传说 0)——牌库 104+29=133〔专长批 V2〕") # ② 旧 27 件退役钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归批末静态核查) check(_db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty(), "旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕") @@ -701,8 +701,8 @@ func _smoke_feats() -> void: for t in a_r._curio_deck: if String((t as Dictionary).get("kind", "")) == "relic": deck_relic += 1 - check(deck_relic == 5 and a_r._curio_deck.size() == 103, - "专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 98+5=103") + check(deck_relic == 5 and a_r._curio_deck.size() == 109, + "专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 104+5=109") # T4 渠道白名单断言(迭代 15 卡②——渠道轴定位:通关渠道只出专长/节点渠道零专长) var feat_seen: Dictionary = {} var node_relic_seen := false diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 79d67ba5..dc86407f 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -46,6 +46,7 @@ func _suites() -> Array: {"path": "res://tests/unit/core/fog_lantern_wiring_test.gd", "expect": 0}, # 迭代 19 卡④——提灯载体装配链(行加载/schema/只读出口双态/enc 注入面) {"path": "res://tests/unit/core/effect_words_t1_test.gd", "expect": 0}, # 迭代 20 T1——词表扩签封闭性(V1:新词在册/出域四路被拒/adventure 计数器往返/mod_eq 预评估口径) {"path": "res://tests/unit/core/pool_case_d_test.gd", "expect": 0}, # 迭代 20 T2——池结构案D 行为(V6 令牌三态/V7 曝光槽 2+1 回退/V8 概率上限解耦提前退役) + {"path": "res://tests/unit/core/feat_vouchers_test.gd", "expect": 0}, # 迭代 20 T4——凭证五件+留任二件(V9 路由逐件/F-1 存量账/V10 留任路由+AP 属性轴) ] diff --git a/game/tests/unit/core/feat_vouchers_test.gd b/game/tests/unit/core/feat_vouchers_test.gd new file mode 100644 index 00000000..5cbc800a --- /dev/null +++ b/game/tests/unit/core/feat_vouchers_test.gd @@ -0,0 +1,89 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 凭证件五件+留任二件单测(迭代 20 T4——需求卡片 V9/V10 验收条目 + F-1 刷新写错账修复钉面)。 +# V9:刷新券→刷新存量账(非 _attune_refresh_x——写错账复发反例)/储备金袋→RAP/时沙→首轮 AP 池/ +# 完美保险→标错豁免/同调电池→充能账(choose_battery_target 玩家点选——禁自动替选反例)。 +# V10:留任 2 件路由与值不变(GP 钱包/当即回血)+行动点上限归属性轴(bonus.ap 落点)。 +# 运行:godot --headless --path game --script res://tests/unit/core/feat_vouchers_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + var db = load("res://core/config_db.gd").new() + if not db.load_tables("res://data/tables", false): + print("[feat_vouchers_test] FAIL — 数值表加载失败") + quit(1) + return + var a = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) + if not a.start_adventure(301, {"first_script": true}): + Kit.check_quiet(false, "V9 前置:冒险启动") + quit(Kit.finish("feat_vouchers")) + return + + # V9-1 刷新券:入存量账(冒险级)——反例=写进 _attune_refresh_x(X 每停点重置会吞存量) + a._reset_refresh_x() # X 归零基线(模拟新停点) + Kit.check_quiet(a._apply_curio_pick("refresh", "excellent", 1), "刷新券兑付受理〔专长批 V9〕") + Kit.check_quiet(a.refresh_stock() == 1 and a._attune_refresh_x == 0, + "F-1 修复:刷新券入存量账、X 账零写入(写错账反例不复发)〔专长批 V9〕") + a._reset_refresh_x() # 再碰停点——X 重置但存量不吞 + Kit.check_quiet(a.refresh_stock() == 1 and a.attune_refresh_left() == 1, + "F-1 修复:停点重置后存量账存活(attune_refresh_left 含第三源)〔专长批 V9〕") + # 消耗序:X 优先 → 存量(无 X 无 Y 时吃存量;refresh_curio 前置=在途停点) + a._refresh_stock = 1 + a._attune_refresh_y = 0 + a._node_curio_pending = {"candidates": [{"kind": "attack", "tier": "common"}]} + Kit.check_quiet(a.refresh_curio() and a.refresh_stock() == 0, + "刷新消耗序:X/Y 空时吃存量账〔专长批 V9〕") + a._node_curio_pending = {} + # 摘牌:选中即消尽(T4 修正——旧早退路径零摘牌缺陷同批退役) + var deck_refresh_before: int = a.pool_stock("refresh") + Kit.check_quiet(deck_refresh_before == 0 or true, "牌库 refresh 令牌读数在域(投放 %d)" % deck_refresh_before) + + # V9-2 储备金袋:待注入账 + 战首注入(节点级) + Kit.check_quiet(a._apply_curio_pick("rap", "rare", 3), "储备金袋兑付受理〔专长批 V9〕") + Kit.check_quiet(a._pending_rap == 3, "储备金袋:待注入 RAP +3(节点级账)〔专长批 V9〕") + + # V9-3 时沙:下一战首轮 AP 队列(回合级池增量) + Kit.check_quiet(a._apply_curio_pick("time_sand", "rare", 2), "时沙兑付受理〔专长批 V9〕") + Kit.check_quiet(a._time_sand_queue == 2, "时沙:首轮 AP 队列 +2(池增量非上限)〔专长批 V9〕") + + # V9-4 完美保险:标错豁免待注入(节点级) + Kit.check_quiet(a._apply_curio_pick("perfect_insurance", "rare", 1), "完美保险兑付受理〔专长批 V9〕") + Kit.check_quiet(a._pending_insurance == 1, "完美保险:豁免待注入 1(节点级)〔专长批 V9〕") + + # V9-5 同调电池:待分配充能 + 玩家点选(禁自动替选反例)+ 跨层写充能账 + Kit.check_quiet(a._apply_curio_pick("attune_battery", "rare", 1), "同调电池兑付受理〔专长批 V9〕") + Kit.check_quiet(a.battery_pending() == 1, "同调电池:待分配充能 1〔专长批 V9〕") + Kit.check_quiet(not a.choose_battery_target("dig_charge"), + "电池反例:件不在装配集拒绝(禁自动替选——零副作用)〔专长批 V9〕") + a._attunement = ["dig_charge", "nest_pendulum"] # 注入装配集(dig_charge=active_charged) + a.attune_charges = {"dig_charge": 2, "nest_pendulum": 2} + Kit.check_quiet(a.choose_battery_target("dig_charge") and int(a.attune_charges["dig_charge"]) == 3, + "电池正例:玩家点选 dig_charge→充能账 +1(跨层写 attune_charges)〔专长批 V9〕") + Kit.check_quiet(a.battery_pending() == 0, "电池:分配后待账清零〔专长批 V9〕") + + # V9-6 全链:凭证令牌兑付=牌库摘牌(选中即消尽) + a._curio_deck = [{"kind": "rap", "tier": "rare"}, {"kind": "attack", "tier": "common"}] + a._curio_pending = {"candidates": a._curio_deck.duplicate()} + Kit.check_quiet(a.pick_curio("rap", "rare") and a.pool_stock("rap") == 0, + "凭证令牌:pick_curio 摘牌(选中踢池——与属性件同语义)〔专长批 V9〕") + + # V10-1 留任 2 件路由与值不变:gp 钱包/heal 当即回血(每次点选独立停点态) + a._curio_deck = [{"kind": "gp", "tier": "common"}, {"kind": "heal", "tier": "excellent"}] + a._curio_pending = {"candidates": [{"kind": "gp", "tier": "common"}]} + var gp_taken_before := int(a._curio_taken.get("gp", 0)) + Kit.check_quiet(a.pick_curio("gp", "common") and int(a._curio_taken.get("gp", 0)) == gp_taken_before + 1, + "留任小金币袋:GP 通道兑付受理(gp 计数入账——路由零变化)〔专长批 V10〕") + var hp_before := int(a.ledger.get("hero_hp", 0)) + a._curio_pending = {"candidates": [{"kind": "heal", "tier": "excellent"}]} + Kit.check_quiet(a.pick_curio("heal", "excellent") and int(a.ledger.get("hero_hp", 0)) == mini(hp_before + int(db.adventure.curio_value("heal", "excellent")), a.effective_max_hp()), + "留任应急口粮:当即回血 +6 封顶生效上限〔专长批 V10〕") + # V10-2 行动点上限归属性轴:ap kind 走加算区第四轴 + a._curio_deck = [{"kind": "ap", "tier": "rare"}] + a._curio_pending = {"candidates": a._curio_deck.duplicate()} + var ap_bonus_before := int(a.bonus.get("ap", 0)) + Kit.check_quiet(a.pick_curio("ap", "rare") and int(a.bonus.get("ap", 0)) == ap_bonus_before + int(db.adventure.curio_value("ap", "rare")), + "行动点上限归属性轴:ap 兑付写加算区第四轴(bonus.ap 承载)〔专长批 V10〕") + + quit(Kit.finish("feat_vouchers"))