diff --git a/design/qa/m4iter12-t10-batch2-exit-2026-10-04.md b/design/qa/m4iter12-t10-batch2-exit-2026-10-04.md new file mode 100644 index 00000000..f3317776 --- /dev/null +++ b/design/qa/m4iter12-t10-batch2-exit-2026-10-04.md @@ -0,0 +1,34 @@ +# 迭代 12 批次二 T10 出口包(2026-10-04) + +- 锚件:`runsum_m4iter12_t10aba_n20_20261004.jsonl`(A 侧)/ `runsum_m4iter12_t10abb_n20_20261004.jsonl`(B 侧)——N=20/侧,seed0=90000 同种子配对,normal 档 +- 采集档位:**二档·数值修改验证**(本触发点总量 40=每侧 20×2 贴边合规;T7 批次一触发点 40 独立计) +- A/B 口径:Fork posture 对两侧 rule 键集透传(A=先手压制 posture_first_strike=战斗首轮 AP+1 / B=稳健拓荒 posture_bulwark=受击减 1)——「两侧不同强非强弱」验收载体 + +## A/B 面两侧差读数(验收 <~3pp) + +| 指标 | A 先手压制 | B 稳健拓荒 | Δ | +|---|---|---|---| +| 全通率 | 0.0% | 0.0% | **0.0pp** ✓ | +| L1 通关率 | 5.0% | 5.0% | **0.0pp** ✓ | +| L2 通关率 | 0.0% | 0.0% | **0.0pp** ✓ | +| 局均承伤 | 25.0 | 22.9 | B 侧减伤方向兑现(rule 键真实生效佐证)| + +**验收判定:过**(两侧差 0.0pp << ~3pp 阈——A-4 反例条件不触发)。 + +## 批末闸全量留痕 + +冒烟 **714/0** 三闸 PASS(批次一 696+树段 18 断言)+ run_all **15 套全绿** + 探针(ui_input/ui_probe/ui_layout/fx/t17_defer 五绿+click 存量红零新增)+ doc 三闸基线同款(gdd_lint 2 存量/doc_term 4 存量/doc_link 0)+ 熵增 PASS。迁移幂等断言在冒烟树段(重复迁移零变化)。MC 触发点全程:TP0 20+T7 40+T10 40=100 局(三触发点各自 ≤40 合规)。 + +## 完成定义对账(任务书 §一五条) + +| # | 定义 | 证据 | 判定 | +|---|---|---|---| +| ① | 七流派各 ≥3 件入局内随机渠道+引擎判成型 | relics.tres 27 件(4/4/4/4/4/3/4);RelicTable.validate 扫雷端铁律;档位激活引擎冒烟断言(T1/T4) | ✅ | +| ② | 七流派成型态 MC 读数可判+红线自检①全流派过 | T7 off/formed 配对锚(L1 5→75% 分岔方向);红线①方向判定过(T7 报告) | ✅ | +| ③ | 天赋树可购可切可迁移+存档往返幂等 | tree_buy/tree_buy_fork/tree_toggle_fork/migrate_tree 冒烟断言(row-gating/互斥/免费切换/幂等);save v5 迁移链 v1→v5 全链 | ✅ | +| ④ | 文档正主七处吸收落文 | 随落籍批提交(node-reward/冒险结构/registry/06 于 TP0+本批元进度/树新节——「吸收即死」退役面随批执行) | ✅*(正文七处=定稿 §文档面:元进度/node-reward/战斗结算/行动经济/地形变异/魔法道具/商店 spec——战斗结算/行动经济/地形变异/魔法道具四正主本批零语义变更(relic 挂点均为既有段槽消费),落文面=元进度树节+node-reward v2.4+冒险结构 v0.14+registry 三处+06 两条——披露差异待用户追认) | +| ⑤ | 批末闸零新增 | 本报告上节 | ✅ | + +## provisional 披露(批次二随批代拍板——人工检查清单母本) + +存档 v4→v5(任务书「v3→v4」系同调批未落时行文顺延披露);树 GP 节点钥石单币承载(GP 局外余额域缺位);「三柜台→一树」字面重构延后(树面板=图鉴柜台下段程序占位,锻造柜台保留一版周期);Fork 首期两 rule 键真实接线(posture 对)余三组 stat 面 provisional;hit 线不入 override 三字段通道(命中折扣档消费归数值批);迁移差额补偿=原样映射零亏损(钥石付价不变,无亏损面故无补偿金)。 diff --git a/design/qa/m4iter12-t7-batch1-readings-2026-10-04.md b/design/qa/m4iter12-t7-batch1-readings-2026-10-04.md new file mode 100644 index 00000000..1cce0d9f --- /dev/null +++ b/design/qa/m4iter12-t7-batch1-readings-2026-10-04.md @@ -0,0 +1,35 @@ +# 迭代 12 批次一 T7 MC 读数报告(2026-10-04) + +- 锚件:`runsum_m4iter12_t7off_n20_20261004.jsonl`(基线)/ `runsum_m4iter12_t7formed_n20_20261004.jsonl`(成型画像)——N=20/锚,seed0=90000 同种子配对,route=min,normal 档,attune=off,shop=legacy +- 采集档位:**二档·数值修改验证**(本触发点总量 40=每锚 20×2 贴边合规;会话累计 MC 分钟 ~6 ≤20) +- 成型画像口径:开局注入七流派各 2 件(14 件=档一成型集,`params.relics_seed` 测量面专用注入——真实局流派件只走局内随机渠道,红线②层级纪律不破);基线=零注入。锚内 autoplay=316-f(最稀有/最左)+候选 relic 优先(on 面) + +## 分岔读数(R3 乙单锚全量——一锚覆盖七流派) + +| 指标 | 基线(off) | 成型(formed) | 判读 | +|---|---|---|---| +| normal 全通率 | 0.0% | **10.0%** | 分岔方向成立 ✓ | +| L1 通关率 | 5.0% | **75.0%** | 流派成型显著变强(+70pp ≥40pp 可判阈值)✓ | +| L2 通关率 | 0.0% | **20.0%** | 方向成立 ✓ | +| 局均轮数 | 12.85 | 22.6 | 活得更久(battles 3.7→8.0)——非拖局 | +| 局均踩雷 | 5.9 | 11.2 | 随局时长自然增长;**每轮踩雷率 0.459→0.496(+8%)**=开格流件代价兑现(贪婪矿灯负向 +25% 惊动在画像内),非推理边缘化 | +| 局均承伤 | 23.4 | 45.5 | 同上随局时长 | + +## 红线自检①(硬断言方向面——阈值 provisional 归数值批) + +**「成型态场均安全开格数/旗占比不得塌陷」方向判定:过。** +- 开格面:成型局轮均挖格上界(rounds×AP/轮)不降反升(活更久挖更多)——流派件未使挖格降格为次要变量; +- 扫雷推理主因面:每轮踩雷率 +8% 系开格流派件设计内代价(正负双修兑现),非「按数字推理」被绕过;旗占比由 autoplay 固定策略承载,两态同构; +- 档位激活确定性:同集合同档位断言在冒烟(T1 引擎断言+T4 四流派档二断言)——MC 逐位可复放由同种子配对承载 ✓。 + +## 自然局捕捞率佐证(候选面) + +成型锚跑前曾跑自然局 on 面探针跑(20 局,**未留锚作废**):候选 relic 出现率 21.4% ≈ 池占比 27/125=21.6%(完美吻合——令牌级抽签等概率承载);normal 白板局死 L1 早期、三选一停点稀少(候选卡仅 28 张/20 局)——自然局到不了成型=成型态读数必须走注入画像(本锚口径)。 + +## 现行锚声明 + +本批两锚(off/formed 配对)=**批次一出口现行锚**。TP0 锚(runsum_m4iter12_tp0_n20)随池换代(98→125 张)作废只测不判。批次二 T10 树 A/B 面读数=下一触发点(树切面读数另跑)。 + +## provisional 披露(T2-T6 随批代拍板全目——人工检查清单母本) + +「每关首次开格」→每战首次(魔能共鸣)/贯穿射击半额弹射(原溢出追踪单分量模型不可行)/猎巢人全体附加值(原对其中敌)/战旗军规 default_target(原旗所在巢穴敌)/连珠矢段值固定 2 不乘 atk/深挖钻头每 4 格深层产出对价 +1 AP/商店保底 devour 广义映射+基价 15 固定锚/整备军备停手侧转蓄力(cleared 分支维持作废)/矿脉直觉连击 +10%/格上限 5/greedy 族同 hook 累加/锚点件双效果承载(共鸣 rider 提升以奥术矢+档位承载)。 diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 0ac8c035..a942f9f5 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -35,6 +35,14 @@ var last_round_end: Dictionary: var _settlement_consumed := false # AC-14 门:notify_settlement_done 消费后才受理 round_start var _next_round_mode := "" # 调试观测量(同板续轮 / 换新图) var _pending_ap_delta := 0 # 下轮行动点修正排队(格面资源八型批 2026-09-20——action 型 ±1 排队,轮开始兑现后清零) +# 蓄力流运行态(迭代 12 T5——relic 件注入面;false=关着零扰动): +# relic_ap_carry=整备军备(轮末剩余 AP 转蓄力层替代 RAP 转存——cleared 作废口径改写的停手侧承载); +# relic_dig_extra=深挖钻头(开掘额外 AP 成本——R4 甲非信息税形态);charge_layers=蓄力层账本。 +var relic_ap_carry := false +var relic_dig_extra := 0 +var _charge_layers := 0 +var charge_layers: int: + get: return _charge_layers func _init(db_ref: ConfigDb) -> void: @@ -54,7 +62,7 @@ func _cost(kind: String) -> int: var ae = db.action_economy match kind: "dig": - return ae.dig_ap_cost + return ae.dig_ap_cost + relic_dig_extra # 深挖钻头(迭代 12 T5——开掘额外 ~1 AP:R4 甲非信息税形态) "flag_place": return ae.flag_place_ap_cost "move": @@ -97,12 +105,17 @@ func _spend_and_arbitrate(action_kind: String, clears_map: bool) -> bool: return true -# 停手:剩余 AP 1:1 转存 RAP;行动经济 直发 stopped 路径 round_end(双发射源) +# 停手:剩余 AP 1:1 转存 RAP;行动经济 直发 stopped 路径 round_end(双发射源)。 +# 整备军备改写(迭代 12 T5——relic_ap_carry=true 时剩余 AP 转蓄力层〔上限 3〕替代 RAP 转存: +# 「不动也是行动」——cleared 作废口径改写的停手侧承载;cleared 分支 AP 恒清零维持既有作废语义) func request_stop() -> bool: if _phase != Phase.ACTIVE: _reject("phase") return false - _rap += _ap + if relic_ap_carry: + _charge_layers = mini(3, _charge_layers + _ap) + else: + _rap += _ap _ap = 0 _finish_round("stopped", "same_board") round_end_stopped.emit({"path_tag": "stopped", "continuation_hint": "same_board"}) diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 5164adfc..f5f0019f 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -80,7 +80,9 @@ var _save_manager = null # 断点承载注入(迭代 8 卡① var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) # ---- 三选一停点态(迭代 10 奖励重构批)---- var _bonus_taken := {} # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载;池库存正主=_bonus_deck) -var _bonus_deck: Array = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账) +var _bonus_deck: Array = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账;迭代 12 T1 起机制件带=relic 令牌随表追加) +var _relics: Array[String] = [] # 流派件持有集(迭代 12 T1——局内随机渠道获得、冒险级跨关携带、收束清零;🔴 层级纪律承载面:不入同调位不入局外树) +var _tree_rules: Array = [] # 树 rule 键集(迭代 12 T10——Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面) var _bonus_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点 var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付) var _bonus_aux_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗) @@ -126,6 +128,14 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive(迭代 11 第①批 T5) _attune_refresh_y = maxi(0, int(params.get("attune_refresh_y", 0))) # additive(迭代 11 第①批 T5b——Y 冒险周期池) _seed_attune_charges() # 迭代 11 第①批 T6——冒险开始充能账本按装备主动件重置(每冒险充能语义) + # 流派件开局注入(迭代 12 T7——MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派 + # 各 2 件档一成型——测量面专用;真实局流派件只走局内随机渠道,红线②层级纪律不破〕) + var relic_seed: Array = params.get("relics_seed", []) if params.get("relics_seed", []) is Array else [] + _tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # 迭代 12 T10——A/B 面 rule 键集 + if not relic_seed.is_empty(): + _relics = [] + for r in relic_seed: + _relics.append(String(r)) ledger["hero_hp"] = effective_max_hp() # 生命账本置满(生效上限口径——T17 修复:含 TunePanel pct,与战斗内同源) state = "running" return _start_current_level() @@ -168,14 +178,22 @@ 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——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径) + # 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径; + # 迭代 12 T1——relic 令牌 relic_id 随还原) 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", ""))}) + var rtok := {"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))} + if String(rtok["kind"]) == "relic": + rtok["relic_id"] = String(tok.get("relic_id", "")) + restored.append(rtok) _bonus_deck = restored + # 流派件持有集还原(迭代 12 T1——缺键 = 旧版断点防御空集) + _relics = [] + for r in (bp.get("relics", []) as Array): + _relics.append(String(r)) 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) @@ -241,6 +259,7 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: func _reset_bonus_state() -> void: _bonus_taken = {"attack": 0, "max_hp": 0, "armor": 0, "gp": 0, "ap": 0, "heal": 0} _bonus_deck = _build_bonus_deck() + _relics = [] _bonus_pending = {} _bonus_result = {} _bonus_aux_carry = [] @@ -249,16 +268,27 @@ func _reset_bonus_state() -> void: # 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = BONUS_IDS×TIERS 逐层, -# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻) +# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻。迭代 12 T1:机制件带=relic 活行每件 1 张追加 +# (rarity common/rare→稀有令牌档、rarest→罕见令牌档——relic 永不入普通档;空表=零追加零扰动)) 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)}) + for row in db.relic_rows(): + var rtier := "rarest" if String(row.get("rarity", "")) == "rarest" else "rare" + deck.append({"kind": "relic", "tier": rtier, "relic_id": String(row.get("relic_id", ""))}) return deck +# 令牌唯一身份(抽签去重/兑付匹配共用——数值件=kind,机制件=relic_id;同档多张不同 relic 可同屏) +func _token_identity(tok: Dictionary) -> String: + if String(tok.get("kind", "")) == "relic": + return "relic:" + String(tok.get("relic_id", "")) + return String(tok.get("kind", "")) + + # TunePanel 百分比通道(测试面板覆盖——每关新 runner 都要应用;正式侧归元进度) func set_hero_override(atk_pct: int, hp_pct: int) -> void: _override = [atk_pct, hp_pct] @@ -307,46 +337,66 @@ func continue_to_next_level() -> bool: # ---- 提升三选一(迭代 10 奖励重构批——选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)---- -# 候选令牌匹配(kind+tier 恒等——同 kind 大小两档可同屏,kind 单键不构成唯一身份) -func _find_token(candidates: Array, kind: String, tier: String) -> Dictionary: +# 候选令牌匹配(身份键恒等——数值件 kind+tier、机制件 relic_id;迭代 12 T1 机制件带) +func _find_token(candidates: Array, kind: String, tier: String, relic_id: 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 + if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier: + continue + if kind == "relic" and String(tok.get("relic_id", "")) != relic_id: + continue + return tok return {} -# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(kind+tier 精确匹配)。 -# 属性件写加算区、AP 件写第四轴、GP 件走钱包「奖励入账」入口、恢复件当即回血、 -# 道具件受同调位约束(满位转替换子交互——随池种退役不可达,机械惰性保留)。 +# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(身份键精确匹配——relic 件 +# 另须 relic_id)。属性件写加算区、AP 件写第四轴、GP 件走钱包「奖励入账」入口、恢复件当即回血、 +# relic 件入流派件持有集(🔴 层级纪律:只走局内随机渠道)、道具件受同调位约束(随池种退役不可达)。 # 消费=选中令牌踢池(未选回池由「抽签不摘牌、兑付才摘牌」语义承载——TP0 v2.2)。 -func pick_bonus(kind: String, tier: String = "") -> bool: +func pick_bonus(kind: String, tier: String = "", relic_id: 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 _find_token(candidates, kind, tier).is_empty(): - push_error("[冒险结构] pick_bonus 拒绝——令牌不在候选: %s|%s" % [kind, tier]) + if _find_token(candidates, kind, tier, relic_id).is_empty(): + push_error("[冒险结构] pick_bonus 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, relic_id]) return false var value: int = int(db.adventure.bonus_value(kind, tier)) var hp_before := int(ledger.get("hero_hp", 0)) 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]) + if not _apply_bonus_pick(kind, tier, value, relic_id): + push_error("[冒险结构] pick_bonus 拒绝——未知件: %s|%s|%s" % [kind, tier, relic_id]) return false - _finish_bonus_pick(kind, value, hp_before, tier) + _finish_bonus_pick(kind, value, hp_before, tier, relic_id) return true -# 三选一件兑付共享体(2026-10-02 修复批双胞胎收口;TP0 改令牌消费——摘牌单点): +# 兑付载荷 relic 伴随键(迭代 12 T1——反馈行取数面;非 relic 件零新增键) +func _relic_payload_keys(relic_id: String) -> Dictionary: + if relic_id == "": + return {} + var row: Dictionary = db.relic_row(relic_id) + return {"relic_id": relic_id, "relic_name": String(row.get("display_name", "")), "relic_archetype": String(row.get("archetype", ""))} + + +# 三选一件兑付共享体(2026-10-02 修复批双胞胎收口;TP0 改令牌消费——摘牌单点;迭代 12 T1 relic 分支): # 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包、恢复件当即回血(封顶生效上限—— -# 与账本置满/通关回血同式 effective_max_hp)。兑付即从 _bonus_deck 摘除该令牌(选中踢池; +# 与账本置满/通关回血同式 effective_max_hp)、relic 件入持有集(散件单体保底——效果接线面 +# 按挂点消费,档位质变由 RelicsEngine 求值)。兑付即从 _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]) +func _apply_bonus_pick(kind: String, tier: String, value: int, relic_id: String = "") -> bool: + if kind == "relic" and db.relic_row(relic_id).is_empty(): + push_error("[冒险结构] relic 兑付失败——表无此件: %s" % relic_id) + return false # 先校验后摘牌(令牌零丢失) + if not _consume_deck_token(kind, tier, relic_id): + push_error("[冒险结构] 令牌消费失败——牌库无此令牌: %s|%s|%s" % [kind, tier, relic_id]) return false + if kind == "relic": + if not _relics.has(relic_id): + _relics.append(relic_id) + _bonus_taken["relic"] = int(_bonus_taken.get("relic", 0)) + 1 + return true match kind: "attack": bonus["atk"] = int(bonus.get("atk", 0)) + value @@ -372,16 +422,24 @@ func _apply_bonus_pick(kind: String, tier: String, value: int) -> bool: return true -# 从牌库摘除一枚令牌(TP0——选中踢池单点;找不到 = false 由调用方拒绝) -func _consume_deck_token(kind: String, tier: String) -> bool: +# 从牌库摘除一枚令牌(TP0——选中踢池单点;迭代 12 T1 relic 按 relic_id 精确摘;找不到 = false) +func _consume_deck_token(kind: String, tier: String, relic_id: 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 + if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier: + continue + if kind == "relic" and String(tok.get("relic_id", "")) != relic_id: + continue + _bonus_deck.remove_at(i) + return true return false +# 流派件持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写) +func relics_held() -> Array: + return _relics.duplicate() + + # ---- 商店共享池消费面(迭代 11 第②批 T3——P1 共享池单点账本;TP0 令牌池换代——库存=牌库剩余令牌)---- # 库存余量(TP0:stock = 牌库内该 kind 剩余令牌数——「池消耗即上限」,种类次数上限退役; @@ -454,8 +512,8 @@ func resolve_aux_replace(discard_index: int) -> bool: # 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。 -# picked = kind;tier 随载荷(TP0——大小档位读数面) -func _finish_bonus_pick(picked: String, value: int, hp_before: int, tier: String = "") -> void: +# picked = kind;tier 随载荷(TP0——大小档位读数面);relic 伴随键随载荷(迭代 12 T1——反馈行取数) +func _finish_bonus_pick(picked: String, value: int, hp_before: int, tier: String = "", relic_id: String = "") -> void: _bonus_pending = {} _bonus_result = { "picked": picked, @@ -465,6 +523,7 @@ func _finish_bonus_pick(picked: String, value: int, hp_before: int, tier: String "hp_after": int(ledger.get("hero_hp", 0)), "level_index": level_index, } + _bonus_result.merge(_relic_payload_keys(relic_id)) bonus_resolved.emit(_bonus_result.duplicate(true)) _snapshot_breakpoint() @@ -484,22 +543,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(kind: String, tier: String = "") -> bool: +# 点选兑付(节点域——无部分回血无钥石;令牌消费与牌库摘牌与通关三选一同库;relic 同权入持有集) +func apply_node_bonus(kind: String, tier: String = "", relic_id: String = "") -> bool: if _node_bonus_pending.is_empty(): push_error("[冒险结构] apply_node_bonus 拒绝——无待选停点") return false var candidates: Array = _node_bonus_pending.get("candidates", []) as Array - if _find_token(candidates, kind, tier).is_empty(): - push_error("[冒险结构] apply_node_bonus 拒绝——令牌不在候选: %s|%s" % [kind, tier]) + if _find_token(candidates, kind, tier, relic_id).is_empty(): + push_error("[冒险结构] apply_node_bonus 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, relic_id]) return false 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]) + if not _apply_bonus_pick(kind, tier, value, relic_id): + push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s|%s|%s" % [kind, tier, relic_id]) return false - _finish_node_bonus(kind, value, tier) + _finish_node_bonus(kind, value, tier, relic_id) return true @@ -639,15 +698,17 @@ func resolve_bonus_replace(discard: int) -> bool: return resolve_aux_replace(discard) -func _finish_node_bonus(picked: String, value: int, tier: String = "") -> void: +func _finish_node_bonus(picked: String, value: int, tier: String = "", relic_id: String = "") -> void: _node_bonus_pending = {} - bonus_resolved.emit({ + var payload := { "context": "reward_node", "picked": picked, "tier": tier, "value": value, "level_index": level_index, - }) + } + payload.merge(_relic_payload_keys(relic_id)) + bonus_resolved.emit(payload) # 令牌池洗牌不放回抽 3(TP0 v2.2——Fisher-Yates 全库洗牌后按序收集;**同 kind 去重每 kind 至多 @@ -668,11 +729,14 @@ func _draw_bonus_candidates() -> Array: var out: Array = [] var seen: Array = [] for tok in pool: - var k := String((tok as Dictionary).get("kind", "")) - if seen.has(k): + var ident := _token_identity(tok) + if seen.has(ident): continue - seen.append(k) - out.append({"kind": k, "tier": String((tok as Dictionary).get("tier", ""))}) + seen.append(ident) + var cand := {"kind": String((tok as Dictionary).get("kind", "")), "tier": String((tok as Dictionary).get("tier", ""))} + if ident.begins_with("relic:"): + cand["relic_id"] = String((tok as Dictionary).get("relic_id", "")) + out.append(cand) if out.size() >= int(db.adventure.bonus_candidate_count): break return out @@ -762,6 +826,8 @@ func _start_current_level() -> bool: if ok: _runner.set_attunement(_attunement) # 迭代 11 第①批 T5——同调装配下传(enc 携带进战斗结算) _runner.set_attune_charges(attune_charges) # 迭代 11 第①批 T6——充能账本下传(能力件骑库存面注入) + _runner.set_relics(_relics) # 迭代 12 T1——流派件持有集下传(begin_battle enc.relics 求值面) + _runner.set_tree_rules(_tree_rules) # 迭代 12 T10——树 rule 键集下传(A/B 面消费) if _shop_mode == "pool": # 商店共享池注入(迭代 11 第②批 T3——库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动) _runner.set_shop_pool(_bonus_deck, Callable(self, "_consume_pool_purchase")) @@ -840,6 +906,7 @@ func _settle(result: String) -> void: "aux_cleared_last": _last_aux_cleared, # additive 扩签(迭代 7 T4——末关清空反馈;非末关走 bonus 载荷) "keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(迭代 10——逐杀掉落明细;settle 入账明细/复盘归因数据源) "pool_stock": _pool_stock_snapshot(), # additive 扩签(迭代 11 第②批 T5——终局池读数:清账前快照,商店买走 vs 奖励领走 vs 枯竭的终局面) + "relics": _relics.duplicate(), # additive 扩签(迭代 12 T1——流派件终局持有集:清账前快照,成型判读取数面) } # 败退明细(数值验证工具取数面 2026-09-22——tools/balance_verifier.py 止步节点分布数据源; # 仅失败局附加,键缺省 = 未败退,既有五件事消费方零感知) @@ -907,6 +974,7 @@ func _snapshot_breakpoint() -> void: "aux_items": aux_items, "bonus_taken": _bonus_taken.duplicate(true), # 三选一件获取次数(kind 维度派生账本——断点附随件与读数承载) "bonus_deck": _bonus_deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) + "relics": _relics.duplicate(), # 流派件持有集(迭代 12 T1——局内随机渠道获得态续玩防丢失) "bonus_pending": _bonus_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约) "bonus_result": _bonus_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付) "bonus_aux_carry": _bonus_aux_carry.duplicate(true), # 道具件跨关携带(注入前中断防静默丢失) diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 0ad0c96d..bb966600 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -72,6 +72,12 @@ var attunement: Array = [] # 装备中同调件 item_id 集 var _attune_charges_ref: Dictionary = {} # 充能账本共享引用(迭代 11 第①批 T6——use_aux 消费直改) var _attune_bank := 0 # 掘进蓄能积点(battle 域——每 charge_n 安全格 +1 点) var _attune_safe_seen := 0 # 掘进蓄能 battle 域安全开格计数 +# 流派件运行态(迭代 12 T2——relic 持有集经 enc.relics 注入;求值旗注 damage_controller.relic_flags; +# 魔矢每战首次计数 battle 域。空集=零扰动) +var _relic_first_dig_used := false +var _relic_combo := 0 # 矿脉直觉连击计数(battle 域——safe +1/mine 清零) +var _relic_deep_dug := 0 # 深挖钻头深层产出计数(battle 域——每 cap 格额外 grant 一份) +var _relic_magma_layers := 0 # 熔火之躯积层(battle 域——受地形伤 +1,轮末反打清零) func _attune_has(item_id: String) -> bool: @@ -85,6 +91,91 @@ func _attune_row(item_id: String) -> Dictionary: func _attune_mag(item_id: String) -> int: return int(_attune_row(item_id).get("magnitude", 0)) + + +# 流派件求值(迭代 12 T2——持有集 × relic_table → damage_controller.relic_flags 注入面): +# rider_flat = Σ rider 件 value(奥术余烬 +1 / 魔能共鸣 +2);magic 档一(≥2 件)rider +1、 +# 档二(≥4 件)rider 可暴击;clamp 两件直调键;first_dig_spell 旗(魔能共鸣第二效果——魔矢值 +# = 件 effects.value)。空集/空表 = 零旗零扰动。 +func _relic_flags_for(relic_ids: Array) -> void: + var flags: Dictionary = {} + if not relic_ids.is_empty() and db.relics != null and not db.relics.rows.is_empty(): + var hooks := RelicsEngine.hooks_for(relic_ids, db.relics) + var tiers := RelicsEngine.tiers_for(relic_ids, db.relics) + for h in hooks: + var hook: String = String(h.get("hook", "")) + var fx: Dictionary = h.get("effects", {}) as Dictionary + match hook: + "hero_attack_rider": + flags["rider_flat"] = int(flags.get("rider_flat", 0)) + int(fx.get("value", 0)) + "clamp_modifier": + # clamp 方向按件 note 词分流(魔网=magic-vs-magic 罚修正 / 湛蓝=magic-vs-heavy 加成) + if String(fx.get("polarity", "")) == "magic_vs_magic": + flags["clamp_magic_magic"] = int(flags.get("clamp_magic_magic", 0)) + int(fx.get("value", 0)) + elif String(fx.get("polarity", "")) == "magic_vs_heavy": + flags["clamp_magic_heavy"] = int(flags.get("clamp_magic_heavy", 0)) + int(fx.get("value", 0)) + elif String(fx.get("polarity", "")) == "pierce_vs_light": + flags["clamp_pierce_light"] = int(flags.get("clamp_pierce_light", 0)) + int(fx.get("value", 0)) + "first_dig_spell": + flags["first_dig_spell"] = true + flags["spell_value"] = int(fx.get("value", 0)) + "flag_resource": + flags["flag_resource_extra"] = int(fx.get("value", 0)) + "nest_aura": + flags["nest_aura_value"] = int(fx.get("value", 0)) + "flag_all_hit": + flags["flag_all_hit_mult"] = int(fx.get("value", 0)) + "flag_strike": + flags["flag_strike_value"] = int(fx.get("value", 0)) + "kill_overflow": + flags["kill_overflow"] = true + "dig_count_segments": + flags["chain_value"] = int(fx.get("value", 0)) + flags["chain_cap"] = int(fx.get("cap", 2)) + "roll_discount": + flags["discount_shift"] = int(fx.get("value", 0)) + "round_dig_bonus": + flags["round_dig_value"] = int(fx.get("value", 0)) + flags["round_dig_cap"] = int(fx.get("cap", 5)) + "hit_trade": + flags["hit_trade_atk"] = int(fx.get("trade_atk", 0)) + flags["hit_trade_hit"] = int(fx.get("trade_hit", 0)) + "armor_roll_rewrite": + flags["armor_roll_rewrite"] = int(fx.get("value", 0)) + "heavy_debuff": + flags["crack_value"] = int(fx.get("value", 0)) + "dig_yield_mult": + # 开格产出乘区族(贪婪矿灯/矿脉直觉/赌徒的血签——正负双向,同 hook 累加) + flags["dig_yield_pos_mult"] = int(flags.get("dig_yield_pos_mult", 0)) + int(fx.get("mult", 0)) + flags["dig_yield_neg_mult"] = int(flags.get("dig_yield_neg_mult", 0)) + int(fx.get("cost_mult", 0)) + if int(fx.get("cap", 0)) > 0: + flags["combo_cap"] = int(fx.get("cap", 0)) # 矿脉直觉连击上限 + "deep_dig": + flags["deep_dig_cost"] = int(fx.get("ap_cost", 0)) + flags["deep_dig_cap"] = int(fx.get("cap", 4)) + "ap_carry": + flags["ap_carry_value"] = int(fx.get("value", 0)) + "charge_shield": + flags["shield_per_layer"] = int(fx.get("shield_per_layer", 0)) + "charge_spike": + flags["spike_mult"] = int(fx.get("value", 0)) + "terrain_refund": + flags["terrain_refund"] = int(fx.get("value", 0)) + "terrain_dig_double": + flags["terrain_dig_double"] = int(fx.get("value", 0)) + "flag_neutralize": + flags["flag_neutralize"] = int(fx.get("value", 0)) + "variant_counter": + flags["variant_counter_value"] = int(fx.get("value", 0)) + var mt := int(tiers.get("magic", 0)) + if mt >= 1: + flags["rider_flat"] = int(flags.get("rider_flat", 0)) + 1 # 档一质变(≥2 件):rider +1(provisional) + if mt >= 2: + flags["rider_crit"] = true # 档二质变(≥4 件):rider 可暴击(crit 时乘暴击倍率) + _damage.relic_flags = flags + # 蓄力流/深挖钻头 action_economy 注入(求值即注入——冒烟直呼 _relic_flags_for 同面生效) + action_economy.relic_ap_carry = int(flags.get("ap_carry_value", 0)) > 0 + action_economy.relic_dig_extra = int(flags.get("deep_dig_cost", 0)) var _flag_miss_seen := {} # 标旗未中行去重(每安全格实例一次) var _perfect_hit_true := 0 # 板级完美排雷计数(install 重置) var _perfect_hit_false := 0 @@ -155,6 +246,20 @@ func begin_battle(enc: Dictionary) -> bool: _attune_safe_seen = 0 _damage.attune_venom = _attune_mag("venom_edge") if _attune_has("venom_edge") else 0 _reward_economy.luck_bias = _attune_mag("luck_coin") if _attune_has("luck_coin") else 0 + # 流派件运行态注入(迭代 12 T2 魔法流——enc.relics 携带持有集;RelicsEngine 求值后注伤害控制器 + # relic_flags;空集/空表=零扰动。魔矢每战首次开格计数同步归零) + _relic_flags_for((enc.get("relics", []) as Array)) + _relic_first_dig_used = false + _relic_combo = 0 + _relic_deep_dug = 0 + _relic_magma_layers = 0 + # 树 rule 键消费(迭代 12 T10——Fork 激活侧 effects;真实接线首期两键:先手压制=战斗首轮 + # AP+1(queue_ap_delta——先手压制语义)/稳健拓荒=敌方攻击减 1(伤害控制器 flags bulwark); + # 其余 rule 键=stat 面投影不入战斗(T8 表内容 provisional 口径披露)) + var tree_rules: Array = enc.get("tree_rules", []) as Array + if tree_rules.has("posture_first_strike"): + action_economy.queue_ap_delta(1) + _damage.relic_flags["bulwark"] = 1 if tree_rules.has("posture_bulwark") else 0 # 迭代 10 奖励重构批——跨冒险行动点加成轴(统一冒险加算区第四轴):经排队修正通道兑现 # 「每场战斗开始首轮 AP +加成值」(queue_ap_delta = 下轮开轮基线加算、notify_round_start 兑现后 # 清零——首轮即消费,后续轮次不重复兑现;语义锚 = 行动经济 GDD「跨冒险行动点加成轴」节) @@ -333,17 +438,37 @@ func _settle() -> void: # 作废(归因行在案);标空判定已随旗擦除自然缺席(被挖的旗不在轮末在场基线)。 _emit_row("reward_ineffective", "safe_cell", evt.cell, ri, "奖励作废(插旗格直接挖开——零惩罚零奖励)", 0, {}, "") continue - _grant_routed(snap, "positive", "safe_cell", evt.cell, ri, "") - # 掘进蓄能计数(迭代 11 同调批 T6——battle 域安全开格计数,每 charge_n 格积 1 点) - if _attune_has("dig_charge"): - _attune_safe_seen += 1 - var cn_b: int = int(db.aux_item_row("dig_charge").get("charge_n", 999)) - if cn_b > 0 and _attune_safe_seen % cn_b == 0: - _attune_bank += 1 + _grant_routed(_relic_yield_adjust(snap, true), "positive", "safe_cell", evt.cell, ri, "") + # 开格流连击/深层产出(迭代 12 T5):矿脉直觉连击 = safe +1(mine 清零在负向分支); + # 深挖钻头深层产出 = 每 cap 格额外 grant 一份(收益面补偿额外 AP 成本——R4 甲非信息税对价) + _relic_combo += 1 + var dd_cap := int(_damage.relic_flags.get("deep_dig_cap", 0)) + if dd_cap > 0: + _relic_deep_dug += 1 + if _relic_deep_dug % dd_cap == 0: + var dd_snap = _reward_economy.grant(evt.cell) + if dd_snap is Dictionary: + _grant_routed(dd_snap, "positive", "safe_cell", evt.cell, ri, "", "(深层资源)") + # 魔能共鸣·每战首次开安全格追加魔法矢(迭代 12 T2——relic 件「每关首次开格必出魔法矢」 + # 落地为每战首次〔战斗域单事务无跨战状态——「每关」字面改「每战」台账披露待追认〕; + # board_typed magic 必中,扫雷结算触发点=relic 挂 first_dig_spell 承载红线①) + if bool(_damage.relic_flags.get("first_dig_spell", false)) and not _relic_first_dig_used: + _relic_first_dig_used = true + var spell_t := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, "relic_first_dig_spell", + "奥术矢", "board", int(_damage.relic_flags.get("spell_value", 0)), + DamageController.TARGET_FRONT_ALIVE, ri, evt.cell, "magic") + _damage.submit(spell_t) + # 掘进蓄能计数(迭代 11 同调批 T6——battle 域安全开格计数,每 charge_n 格积 1 点) + if _attune_has("dig_charge"): + _attune_safe_seen += 1 + var cn_b: int = int(db.aux_item_row("dig_charge").get("charge_n", 999)) + if cn_b > 0 and _attune_safe_seen % cn_b == 0: + _attune_bank += 1 elif evt.kind == "mine": # 插旗不可逆四情况②:插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常兑付——惊动巢穴), # 标中不发(旗已随挖掘擦除、不入轮末判定基线)。 - _grant_routed(snap, "negative", "mine_hit", evt.cell, ri, "") + _grant_routed(_relic_yield_adjust(snap, false), "negative", "mine_hit", evt.cell, ri, "") + _relic_combo = 0 # 矿脉直觉连击清零(踩雷断连——「推理密度直接奖励」的对侧) # 地形变异 兑付段(授予段尾——触发枚举 on_open:挖开即伤、chain 通道同口径,轮末结算逐行兑现; # 信息型 hide_number 不进结算——呈现层读取,见 board_view 数字遮蔽;雷格恒无变异——布置段排除, # 双层叠罚不存在;插旗格直挖照吃变异伤害——地形后果同 mine_hit 口径,非奖励不作废) @@ -357,8 +482,55 @@ func _settle() -> void: var vt := _damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, "地形变异", String(vrow.get("display_name", vid)), "board", \ int(vrow.get("effect_value", 0)), DamageController.TARGET_HERO, ri, evt.cell, "", 0, "", {"variant_id": String(vid)}) _damage.submit(vt) + # 吞噬流四面(迭代 12 T6——地形从纯税到第三资源带;伤害仍发生=乙稿判据 3 承载): + # ①荆棘编织者=挖开伤害型地形格额外掉落一次格面资源(主动吃地形换资源) + if int(_damage.relic_flags.get("terrain_dig_double", 0)) > 0: + var tw_snap = _reward_economy.grant(evt.cell) + if tw_snap is Dictionary: + _grant_routed(tw_snap, "positive", "safe_cell", evt.cell, ri, "", "(荆棘编织)") + # ②地脉采撷=受地形伤掉落小额资源补偿(薄矿化——每次受伤兑一份) + if int(_damage.relic_flags.get("terrain_refund", 0)) > 0: + var lh_snap = _reward_economy.grant(evt.cell) + if lh_snap is Dictionary: + _grant_routed(lh_snap, "positive", "safe_cell", evt.cell, ri, "", "(地脉补偿)") + # ③熔火之躯=受地形伤积层(触发键=「受地形伤」通用不按种类特化——定稿红线承载) + if int(_damage.relic_flags.get("variant_counter_value", 0)) > 0: + _relic_magma_layers += 1 for evt in _facts_flags: _judge_flag(evt, ri) + # 战旗军规开单段(迭代 12 T3 标旗流——flag_judged 批后统一开单:轮末每面标中旗对最前存活敌 + # 结算 board_typed 必中伤害〔复用零新配方;「旗所在巢穴敌」简化为 default_target 最前存活敌—— + # 台账披露待追认〕;完美布防=本轮旗批全中(hit>0 且 miss==0)时旗伤 ×mult——全对推理奖励)。 + # 扫雷结算触发点=flag_strike/flag_all_hit/nest_aura 挂点承载红线①。 + if int(_damage.relic_flags.get("flag_strike_value", 0)) > 0: + var hits := 0 + var misses := 0 + for fevt in _facts_flags: + if bool(fevt.get("hit", false)): + hits += 1 + else: + misses += 1 + if hits > 0: + var mult := int(_damage.relic_flags.get("flag_all_hit_mult", 1)) + var strike_value := int(_damage.relic_flags.get("flag_strike_value", 0)) + if misses == 0 and mult > 1: + strike_value *= mult # 完美布防:同轮全中旗伤倍增(全对推理奖励) + for fevt in _facts_flags: + if not bool(fevt.get("hit", false)): + continue + var st_t := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, "relic_flag_strike", + "战旗军规", "board", strike_value, DamageController.TARGET_FRONT_ALIVE, ri, + fevt.cell, "normal") + _damage.submit(st_t) + # 熔火之躯轮末反打(迭代 12 T6 吞噬流——受地形伤积层,轮末每层对全体敌 ~1 必中反伤; + # 触发键=「受地形伤」通用不按地形种类特化——定稿红线承载;反打后清零) + var magma := int(_damage.relic_flags.get("variant_counter_value", 0)) + if magma > 0 and _relic_magma_layers > 0: + var mg_t := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "relic_magma_body", + "熔火反噬 ×%d" % _relic_magma_layers, "board", magma * _relic_magma_layers, + DamageController.TARGET_ALL_ALIVE, ri, minesweeper.hero_pos(), "", 0, "", {}) + _damage.submit(mg_t) + _relic_magma_layers = 0 if _facts_path == "cleared": _grant_clear_reward(ri) _damage.flush() # 授予段界结账(B 模式——段内开单攒批到此统一兑现;A 模式 no-op;清图批前另有同步结账点) @@ -392,6 +564,24 @@ func _settle() -> void: # ---- 授予段 helpers(极性路由)---- +# 开格产出乘区(迭代 12 T5 开格流——贪婪矿灯/矿脉直觉/赌徒的血签;duplicate 隔离不回写快照): +# positive=power ×(1+静态 mult% + 矿脉直觉连击加成 min(cap,combo)×10%);negative=power ×(1+cost%)。 +# 零旗=原快照原样返回(零扰动)。 +func _relic_yield_adjust(snap: Dictionary, positive: bool) -> Dictionary: + var pos_m := int(_damage.relic_flags.get("dig_yield_pos_mult", 0)) + var neg_m := int(_damage.relic_flags.get("dig_yield_neg_mult", 0)) + if positive and pos_m <= 0 and not _damage.relic_flags.has("combo_cap"): + return snap + if not positive and neg_m <= 0: + return snap + var out: Dictionary = snap.duplicate() + var power := int(out.get("power", int(_reward_economy.polarity_row(String(out.get("cell_type", "")), "positive" if positive else "negative").power))) + var pct := pos_m if positive else neg_m + if positive and _damage.relic_flags.has("combo_cap"): + pct += mini(int(_damage.relic_flags.get("combo_cap", 5)), _relic_combo) * 10 + out["power"] = maxi(1, int(round(float(power) * (1.0 + float(pct) / 100.0)))) + return out + # 「动作 × 格型」矩阵的执行面:snap = 缓冲期冻结身份快照(cell_type = 该格装板期摇定的类型身份), # polarity 由动作决定(开安全格/标中旗 = positive,开雷格/标错旗 = negative), # 兑付行 = 奖励字典(类型, 极性)行。返回 true = percent 行实际生效(flat 行恒 false——兑付结果由行协议承载)。 @@ -579,6 +769,28 @@ func _judge_flag(evt: Dictionary, ri: int) -> void: var fbt := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "attune_flag_burst", "标中殉爆", "board", _attune_mag("flag_burst"), DamageController.TARGET_ALL_ALIVE, ri, cell, "", 0, "highlight", {}) _submit(fbt, false) + # 净化工兵(迭代 12 T6 吞噬流——标中旗中和相邻伤害型地形格):🔴 R4 甲永不对冲边界= + # **仅限伤害型**(thorn/magma/rockfall——variation 表 effect=flat_damage 判据),信息型 + # (hide_number 族)永不清除(红线①看门税不可对冲);中和=变普通格(BoardData.clear_variants)。 + if int(_damage.relic_flags.get("flag_neutralize", 0)) > 0: + var board: BoardData = minesweeper.current_board() + for n in board.neighbors8(cell): + var vids_n: Array = board.variant_ids(n) + var harmful := false + for vid_n in vids_n: + var vrow_n: Dictionary = db.variation.row_by_id(String(vid_n)) + if String(vrow_n.get("effect", "")) == "flat_damage": + harmful = true + break + if harmful: + board.clear_variants(n) + _emit_row("terrain_neutralized", "relic_purge_sapper", n, ri, "净化工兵·地形中和", 0, {}, "") + # 雷区图谱(迭代 12 T3 标旗流——标中雷额外获得一份格面资源:插旗开始产出对标开格; + # 复用 grant 单点,额外兑付走同一 _grant_routed 正向路由) + if int(_damage.relic_flags.get("flag_resource_extra", 0)) > 0: + var extra_snap = _reward_economy.grant(cell) + if extra_snap is Dictionary: + _grant_routed(extra_snap, "positive", "flag_hit", cell, ri, "", "(雷区图谱)") var snap = _reward_economy.grant(cell) if not (snap is Dictionary): return @@ -661,6 +873,20 @@ func _roll_round_actions() -> void: # 停开(B 模式下若 flush 中主角倒下,由控制器死亡事件终止余单——两模式同一结果)。 func _combat_segment(ri: int) -> void: + # 本轮扫雷事实推导(迭代 12 T4——执拗 streak/连珠矢段数共用取数面):本轮 safe 事件数= + # streak;踩雷即清零(mine_hit 监听同口——执拗「踩雷清零」承载)。攻击在战斗段=授予段后 + # 同一次 _settle 内,facts 已定。 + var round_safe := 0 + var round_mine := false + for evt in _facts_reveals: + if evt.kind == "safe": + round_safe += 1 + elif evt.kind == "mine": + round_mine = true + _damage.relic_flags["dig_streak"] = 0 if round_mine else round_safe + # 蓄力层注入(迭代 12 T5——整备军备「每层下轮首段 +1」/厚积薄发满层 spike/铁壁整备减伤—— + # 三件同读 action_economy.charge_layers;battle 域每轮刷新) + _damage.relic_flags["charge_layers"] = action_economy.charge_layers # ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动;授予段负向直伤致死 → 致电短路不先手)。 # 单分量伤害构成(2026-09-19 充能退役批·用户拍板):主角攻击 = 唯一基础分量(atk,吃掷点含暴击; # 类型 = 主角 attack_type 表值——2026-09-18 营地扩展前置批:量与类型抽象分离;查甲效矩阵对应行取克制修正)。 @@ -674,6 +900,16 @@ func _combat_segment(ri: int) -> void: var ht := _damage.open_ticket(DamageController.KIND_HERO_ATTACK, "hero_attack", "主角攻击", "combat", _hero_atk, \ DamageController.TARGET_FRONT_ALIVE, ri, Vector2i(-1, -1), String(_enemy_system.hero_unit().get("attack_type", "normal"))) _damage.submit(ht) + # 连珠矢(迭代 12 T4 穿刺流——开格节奏变攻击段数):本轮每开 ~3 安全格 +1 段穿刺附加 + # (上限 cap;段值 provisional 固定值不乘 atk——台账披露)。扫描雷结算触发点=dig_count_segments。 + var chain_value := int(_damage.relic_flags.get("chain_value", 0)) + if chain_value > 0: + var segments := mini(int(_damage.relic_flags.get("chain_cap", 2)), int(_damage.relic_flags.get("dig_streak", 0)) / 3) + for s in segments: + var ct := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, "relic_chain_arrows", + "连珠矢", "board", chain_value, DamageController.TARGET_FRONT_ALIVE, ri, + minesweeper.hero_pos(), "pierce") + _damage.submit(ct) # 敌方行动(敌人技能池批——本拍动作开轮已摇定,此处按技能行兑现;行序 = 生成序)。 # 死亡即不动手:摇定后、兑现前死亡的敌不在存活序中——本拍技能作废,不 commit、不进冷却。 for u in _enemy_system.query_alive("enemy"): diff --git a/game/core/board_data.gd b/game/core/board_data.gd index a02ffacf..56dd7dd7 100644 --- a/game/core/board_data.gd +++ b/game/core/board_data.gd @@ -50,6 +50,11 @@ func variant_ids(c: Vector2i) -> Array: return variants.get(c, []) +# 中和格变异(迭代 12 T6 净化工兵——伤害型地形格变普通格;🔴 R4 甲仅限伤害型的过滤在调用方) +func clear_variants(c: Vector2i) -> void: + variants.erase(c) + + func neighbors8(c: Vector2i) -> Array: var out: Array[Vector2i] = [] for dy in range(-1, 2): diff --git a/game/core/config_db.gd b/game/core/config_db.gd index 9e252a10..22a2acae 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -23,9 +23,11 @@ const TABLE_MANIFEST := { "difficulty": "difficulty.tres", "shop": "shop.tres", # GP+商店房实施批 2026-09-21(表与 schema 落籍归并行数据面——本批只接线加载与消费) "meta": "meta.tres", # 元进度实现批 2026-09-21(MetaTable 四行族——schema 与表同批落籍) + "meta_tree": "meta_tree.tres", # 迭代 12 T8 双层 BD 天赋树(MetaTreeTable 定义表 Rule 4 豁免——R1 甲融合案 33 节点) "aux_items": "aux_items.tres", # 迭代 7 T1 魔法道具内容批(AuxItemTable 六行——定义表 Rule 4 豁免;罗盘 retired/失义件 deferred 标记见 schema 头注) "adventure": "adventure.tres", # 冒险结构实现批 2026-09-21(AdventureTable 建表——大奖励与钥石行值单点) "attunement_growth": "attunement_growth.tres", # 迭代 11 第①批 同调实施批 T1(AttunementGrowthTable 单行全局——同调位里程碑谓词表;单行先例 Rule 4 豁免) + "relics": "relics.tres", # 迭代 12 T1 双层 BD 机制件基建(RelicTable 定义表 Rule 4 豁免——AuxItemTable 先例;空表合法态=主时间线零扰动) } var action_economy: ActionEconomyTable @@ -44,9 +46,11 @@ var difficulty: DifficultyTable var shop: ShopTable = null # 元进度实现批(2026-09-21):MetaTable 类型化持用(schema 与表同批落籍,无 shop 先例的落籍间隙问题) var meta: MetaTable +var meta_tree: MetaTreeTable # 迭代 12 T8 类型化持用(消费面走 tree_node 访问器) var aux_items: AuxItemTable # 迭代 7 T1 类型化持用(消费面走 aux_* 访问器) var adventure: AdventureTable var attunement_growth: AttunementGrowthTable # 迭代 11 第①批 T1 类型化持用(消费面走 attunement_* 访问器) +var relics: RelicTable # 迭代 12 T1 类型化持用(消费面走 relic_* 访问器) var is_ready := false var _diagnostics: Array[Dictionary] = [] # {severity, category, message} @@ -138,10 +142,32 @@ func load_tables(root: String, collect_only: bool) -> bool: aux_items = loaded.get("aux_items") adventure = loaded.get("adventure") attunement_growth = loaded.get("attunement_growth") + relics = loaded.get("relics") + meta_tree = loaded.get("meta_tree") is_ready = not has_errors return not has_errors +# ---- relic 访问器(迭代 12 T1——消费面一律走本组,禁直读行集)---- + +func relic_row(relic_id: String) -> Dictionary: + return relics.row_by_id(relic_id) if relics != null else {} + + +# 机制件带令牌构建源(池接线——adventure_runner._build_bonus_deck 消费;行 rarity→投放档映射) +func relic_rows() -> Array: + return relics.rows if relics != null else [] + + +# 天赋树节点行(迭代 12 T8——MetaProgression 树段/营地树 UI 取数口) +func tree_node(node_id: String) -> Dictionary: + return meta_tree.row_by_id(node_id) if meta_tree != null else {} + + +func tree_rows() -> Array: + return meta_tree.rows if meta_tree != null else [] + + # 跨表校验(悬空引用 / 池护栏——全部加载期致命)本体自 2026-10-02 修复批迁出: # ConfigDbCrossValidator(core/config_db_cross_validator.gd)承载,门面公有签名零变化。 func _cross_validate(loaded: Dictionary) -> void: diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 43520b5e..f2ec3a43 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -60,6 +60,10 @@ var _outcomes := {} # ticket_id → "redeemed"/"zero"/"voided"( var _seq := 0 # 流水号 var _halted := false # 主角倒下终止旗(死亡事件置位——flush 余单作废) var _hero_ac_override := -1 # 主角护甲等级合流入参(begin_battle 显式设定——-1 = 表值兜底,口径同战斗编排器) +# 流派件求值旗(迭代 12 T2——begin_battle 编排器经 RelicsEngine 求值后注入;空字典=零扰动。 +# rider_flat=英雄侧必中附加值〔奥术余烬族〕/rider_crit=档二 rider 可暴击/first_dig_spell=每战首次 +# 开安全格追加魔法矢〔消费在编排器授予段〕/clamp_magic_light·clamp_magic_heavy=克制点直调) +var relic_flags: Dictionary = {} func _init(db_ref, rng_ref, units_ref, effects_ref) -> void: @@ -194,14 +198,46 @@ func _resolve_hero_attack(t: Dictionary) -> void: var atk_type: String = String(t.attack_type) var roll_read: Dictionary = _armor_roll() var threshold: int = defender_ac(target) + int(db.settlement.attack_bonus) + # 矿工重锤·命中交换(迭代 12 T4 普攻流——正负双修原生形态:命中判定减成 trade_hit 点 + # 〔更易命中〕换攻击增伤 trade_atk%——高 AC 负期望/低 AC 正期望) + var trade_hit := int(relic_flags.get("hit_trade_hit", 0)) + if trade_hit > 0: + threshold = maxi(1, threshold - trade_hit) var crit: bool = bool(roll_read.crit) var discount: int = _roll_discount(int(roll_read.roll), threshold) - var cdelta: int = _counter_delta(atk_type, armor) + # 专注(迭代 12 T4 普攻流——免掷骰恒定折扣:方差买断成期望——稳定器人格;roll 记 0 归因) + var rewrite := int(relic_flags.get("armor_roll_rewrite", 0)) + if rewrite > 0: + discount = rewrite + crit = false + roll_read["roll"] = 0 + # 厚积薄发满层必中(迭代 12 T5 蓄力流——spike ready 时 discount 恒全额、暴击面走倍乘不加掷) + if int(relic_flags.get("spike_mult", 0)) > 0 and int(relic_flags.get("charge_layers", 0)) >= 3: + discount = 100 + # 猎弱本能(迭代 12 T4 穿刺流——对轻甲敌折扣档改善 value 档:对冲 pierce 列 L1 −1 开局罚; + # 改善 = 折扣表向全额方向移档,nat 最大面/已全额不重复) + var shift := int(relic_flags.get("discount_shift", 0)) + if shift > 0 and armor == "light" and discount < 100: + discount = _discount_shift_up(discount, shift) + var cdelta: int = _counter_delta(atk_type, armor) + _relic_clamp_bonus(atk_type, armor) # 淬毒刃纹(迭代 11 同调批 T5——底稿 §3:对被克制敌人的穿刺/魔法直伤 +N;条件 = 克制成立 × # 攻击类型限定,主角攻击面专属——棋盘克制直伤走 _resolve_board_typed 不吃本加成) if attune_venom > 0 and (atk_type == "pierce" or atk_type == "magic") and cdelta > 0: cdelta += attune_venom - var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.crit_multiplier), cdelta) + var base_value := float(t.base_value) + var trade_atk := int(relic_flags.get("hit_trade_atk", 0)) + if trade_atk > 0: + base_value *= 1.0 + float(trade_atk) / 100.0 # 重锤增伤面(交换的正向侧) + # 蓄力流(迭代 12 T5):整备军备「每层下轮首段 +1」(层数×value 加算)+厚积薄发满层(≥3) + # spike 倍乘+强制必中(discount 100/crit 关闭掷骰面——方差买断的爆发出口) + var charge_layers := int(relic_flags.get("charge_layers", 0)) + if charge_layers > 0: + base_value += float(charge_layers * int(relic_flags.get("ap_carry_value", 0))) + var spike_mult := int(relic_flags.get("spike_mult", 0)) + var spike_ready := spike_mult > 0 and charge_layers >= 3 + if spike_ready: + base_value *= float(spike_mult) + var rolled: int = DamageMath.rolled_component(base_value, discount, crit, int(db.settlement.crit_multiplier), cdelta) var payload: Dictionary = {"target": String(target.display_name)} payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型) payload["roll"] = int(roll_read.roll) @@ -211,9 +247,53 @@ func _resolve_hero_attack(t: Dictionary) -> void: payload["crit"] = true var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense"), clamped_pool("enemy_defense")) var landed: int = maxi(int(db.settlement.damage_floor), pooled) + # 英雄侧 rider(迭代 12 T2 魔法流——enemy_skill rider 对称位补齐):后置必中附加,独立于掷点 + # 与乘区(奥术余烬「普攻命中后恒定结算」语义);档二(magic 4 件)rider 可暴击=当次 crit 时 + # rider 值乘暴击倍率。rider 不改变下限语义(landed 含 rider 后不再过 floor)。 + var hero_rider := int(relic_flags.get("rider_flat", 0)) + # 猎巢人(迭代 12 T3 标旗流——巢穴控制数转伤害光环):控制数 = 敌方 weaken(削弱)实例数 + # 〔八型批 side-wide 状态行——标中旗控制巢穴的既有承载;原设计「对其中敌伤害」简化为全体 + # 先手攻击附加值——台账披露待追认〕,每控制 1 巢 rider +Z。 + var nest_aura := int(relic_flags.get("nest_aura_value", 0)) + if nest_aura > 0: + for eff in effects.query_active("enemy"): + if String(eff.effect_id) == "weaken": + hero_rider += nest_aura + # 矿工的执拗(迭代 12 T4 普攻流——「排雷排得多好,打得就有多疼」):本轮安全开格 streak + # 〔编排器授予段实时写入 relic_flags.dig_streak;踩雷清零〕×value,上限 cap。 + var rdig := int(relic_flags.get("round_dig_value", 0)) + if rdig > 0: + hero_rider += mini(int(relic_flags.get("round_dig_cap", 5)), int(relic_flags.get("dig_streak", 0)) * rdig) + if hero_rider > 0: + if crit and bool(relic_flags.get("rider_crit", false)): + hero_rider = int(round(float(hero_rider) * float(db.settlement.crit_multiplier))) + landed += hero_rider + payload["hero_rider"] = hero_rider units.apply_damage(target, landed) payload["killed"] = not target.alive # 击杀标记在扣血后取(原语义不变) - emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), _attack_label(String(t.display), crit, discount), landed, payload, String(t.highlight)) + # 碎甲震击(迭代 12 T4 普攻流——命中重甲附加「护甲裂解」):复用 off_guard(enemy percent + # +25 受伤加深——零新状态行);apply_effect 域闭/重复授予由状态容器自行守卫。 + var crack := int(relic_flags.get("crack_value", 0)) + if crack > 0 and armor == "heavy": + effects.apply_effect("off_guard", {"source_class": "relic_armor_crack"}, -1) + payload["cracked"] = true + var label := _attack_label(String(t.display), crit, discount) + if hero_rider > 0: + label += "+奥术%d(必中)" % hero_rider + emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), label, landed, payload, String(t.highlight)) + # 贯穿射击(迭代 12 T4 穿刺流——击杀溢出弹射):击杀时对下一存活敌以穿刺类型弹射 + # 本体半额〔原设计「溢出伤害」追踪在单分量模型不可行——半额弹射 provisional 裁定披露待追认; + # 无次敌=弹射落空〕。B 模式入队段尾 flush 兑现;A 模式递归 resolve(目标存活守卫内建)。 + if bool(payload.get("killed", false)) and int(relic_flags.get("kill_overflow", 0)) > 0: + var next_target = units.default_target() + if next_target != null: + var of_t := open_ticket(DamageController.KIND_BOARD_TYPED, "relic_pierce_shot", + "贯穿射击", "board", maxi(int(db.settlement.damage_floor), int(landed / 2)), + DamageController.TARGET_FRONT_ALIVE, int(t.round_index), t.origin_cell, "pierce") + if policy == POLICY_SEGMENT: + _queue.append(of_t) + else: + _resolve(of_t) _outcomes[int(t.ticket_id)] = "redeemed" @@ -228,6 +308,13 @@ func _resolve_enemy_skill(t: Dictionary) -> void: var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.crit_multiplier), 0) var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output")) var total: int = maxi(int(db.settlement.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区 + # 铁壁整备(迭代 12 T5 蓄力流——每层下轮开场 S 点减伤;减后过下限保底) + var shield := int(relic_flags.get("shield_per_layer", 0)) * int(relic_flags.get("charge_layers", 0)) + # 稳健拓荒(迭代 12 T10——Fork posture B 侧 rule 键:受击减 1;两侧对称弱值=「不同强非强弱」) + if int(relic_flags.get("bulwark", 0)) > 0: + shield += 1 + if shield > 0: + total = maxi(int(db.settlement.damage_floor), total - shield) units.apply_damage(hero, total) var payload: Dictionary = {"target": "hero", "skill_id": String(t.context.get("skill_id", "")), "baseline": int(t.context.get("baseline", 0)), "roll": int(roll_read.roll), "ac": threshold, "tier": discount} @@ -250,7 +337,7 @@ func _resolve_board_typed(t: Dictionary) -> void: _ineffective_row(t, "无存活敌人") _outcomes[int(t.ticket_id)] = "zero" return - var delta: int = _counter_delta(String(t.attack_type), String(target.get("armor_type", "light"))) + var delta: int = _counter_delta(String(t.attack_type), String(target.get("armor_type", "light"))) + _relic_clamp_bonus(String(t.attack_type), String(target.get("armor_type", "light"))) var dealt: int = DamageMath.counter_component(int(t.base_value), delta, int(db.settlement.damage_floor)) units.apply_damage(target, dealt) emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → %s" % [String(t.display), String(target.display_name)], dealt, @@ -331,6 +418,32 @@ func _counter_delta(attack_type: String, armor: String) -> int: return int(row.get(armor, 0)) +# 克制点直调(迭代 12 T2 魔法流/T4 穿刺流 clamp_modifier 件——克制矩阵查表值后的流派件调整; +# 空旗=0 零扰动):魔网织者=magic vs magic(矩阵罚 −1 → 0/正);湛蓝誓约=magic vs heavy(+1 → +2); +# 猎手刺剑=pierce vs light(+1——对轻甲敌穿刺提升,对重甲无增益=正负双修)。hero_attack 与 +# board_typed 两消费面同享。 +func _relic_clamp_bonus(attack_type: String, armor: String) -> int: + var bonus := 0 + if attack_type == "magic": + if armor == "magic": + bonus += int(relic_flags.get("clamp_magic_magic", 0)) + elif armor == "heavy": + bonus += int(relic_flags.get("clamp_magic_heavy", 0)) + elif attack_type == "pierce": + if armor == "light": + bonus += int(relic_flags.get("clamp_pierce_light", 0)) + return bonus + + +# 折扣档改善(迭代 12 T4 猎弱本能——折扣表向全额方向移 value 档;查不到当前档 = 不动防御口径) +func _discount_shift_up(discount: int, shift: int) -> int: + var tbl: Array = db.settlement.roll_tier_discounts + var idx := tbl.find(discount) + if idx < 0: + return discount + return int(tbl[mini(tbl.size() - 1, idx + shift)]) + + # 淬毒刃纹 N(迭代 11 同调批 T5——战斗结算 begin_battle 注入;0 = 未装配零扰动) var attune_venom: int = 0 diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 17a9dfb4..1d164437 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -478,6 +478,8 @@ func _start_battle(node: Dictionary) -> bool: "hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(), "attunement": _attunement, "attune_charges": _attune_charges, + "relics": _relics, + "tree_rules": _tree_rules, "node_type": String(node.get("type", ""))}): push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1))) return false @@ -587,6 +589,24 @@ func set_attunement(ids: Array) -> void: _attunement = ids.duplicate() +# 流派件持有集注入(迭代 12 T1——AdventureRunner._start_current_level 唯一调用方;begin_battle +# enc.relics 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效 +# (attune_charges 共享账本同款);空集=零扰动) +var _relics: Array = [] + + +func set_relics(ids: Array) -> void: + _relics = ids + + +# 树 rule 键集注入(迭代 12 T10——begin_battle enc.tree_rules 携带;Fork 激活侧 effects) +var _tree_rules: Array = [] + + +func set_tree_rules(rules: Array) -> void: + _tree_rules = rules.duplicate() + + # 同调主动件充能账本注入(迭代 11 第①批 T6——共享 dict 引用直改;能力件骑库存面注入: # 差额注入防续玩双份——resume 路径 restore_inventory 已含断点内 attune 记录) func set_attune_charges(charges: Dictionary) -> void: @@ -751,6 +771,26 @@ func _shop_draw_shelf() -> void: _shop_shelf.append(tok) if _shop_shelf.size() >= 3: break + # 商店保底(迭代 12 T6——「本关变异交互件必刷」简化口径:货架零流派件时**挤掉最后一张普通卡** + # 换吞噬流件〔地形交互件广义映射——按关变异精细绑定归数值批,台账披露待追认〕;占位制货架恒 + # ≤3;池内无 devour 令牌=不保底合法态) + var has_relic := false + for tok in _shop_shelf: + if String((tok as Dictionary).get("kind", "")) == "relic": + has_relic = true + break + if not has_relic and not _shop_shelf.is_empty(): + for row in db.relic_rows(): + if String(row.get("archetype", "")) != "devour": + continue + var rid := String(row.get("relic_id", "")) + for t in _pool_deck: + var tok2: Dictionary = t as Dictionary + if String(tok2.get("kind", "")) == "relic" and String(tok2.get("relic_id", "")) == rid: + _shop_shelf[_shop_shelf.size() - 1] = {"kind": "relic", "tier": String(tok2.get("tier", "")), "relic_id": rid} + break + if String((_shop_shelf[_shop_shelf.size() - 1] as Dictionary).get("kind", "")) == "relic": + break # 换一张即止 # 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0) @@ -763,9 +803,14 @@ func pool_stock(kind: String) -> int: # 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向; -# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5) +# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外) +# 基价 provisional 15 固定锚(台账披露待追认——shop.tres 词表扩列归数值批)。 func pool_price(kind: String) -> int: var row: Dictionary = db.shop_pool_row(kind) + if row.is_empty(): + if kind == "relic": + return int(ceil(15.0 * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) * pow(db.shop_price_a(), float(_shop_visit_buys)))) + return 999999 var base := float(maxi(1, int(row.get("base_price", 1)))) var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) \ * pow(db.shop_price_a(), float(_shop_visit_buys)) @@ -780,11 +825,18 @@ func shop_shelf() -> Array: var kind := String((tok as Dictionary).get("kind", "")) var tier := String((tok as Dictionary).get("tier", "")) var row: Dictionary = db.shop_pool_row(kind) + var display := String(row.get("display_name", kind)) + var value := int(db.adventure.bonus_value(kind, tier)) + if row.is_empty() and kind == "relic": + var rrow: Dictionary = db.relic_row(String(tok.get("relic_id", ""))) + display = String(rrow.get("display_name", "流派件")) + value = 0 out.append({ "kind_id": kind, "tier": tier, - "display_name": String(row.get("display_name", kind)), - "value": int(db.adventure.bonus_value(kind, tier)), + "relic_id": String(tok.get("relic_id", "")), + "display_name": display, + "value": value, "price": pool_price(kind), "stock_left": pool_stock(kind), }) diff --git a/game/core/meta_progression.gd b/game/core/meta_progression.gd index 03ac6604..a89ade6e 100644 --- a/game/core/meta_progression.gd +++ b/game/core/meta_progression.gd @@ -142,16 +142,211 @@ func settle_adventure(summary: Dictionary) -> Dictionary: } -# ---- 跨局注入(冒险开始参数面——字段清单随柜台扩展,现 = 属性锻造三档)---- +# ---- 跨局注入(冒险开始参数面——字段清单随柜台扩展,现 = 属性锻造三档+树四线之三条)---- # 全零 = 白板合法态(无解锁不得改变任何局内规则);override 禁写表(表运行期只读)。 +# 迭代 12 T8:树四线 flat 并入(脊柱 stat+锻造脉+彩蛋——同一覆盖值通道;hit 线消费面= +# 命中折扣档归数值批,本批不入 override 三字段通道——披露)。 func hero_override() -> Dictionary: return { - "atk": forge_flat("atk"), - "max_hp": forge_flat("max_hp"), - "armor_class": forge_flat("armor_class"), + "atk": forge_flat("atk") + tree_stat_flat("atk"), + "max_hp": forge_flat("max_hp") + tree_stat_flat("max_hp"), + "armor_class": forge_flat("armor_class") + tree_stat_flat("armor_class"), } +# ---- 天赋树段(迭代 12 T8——R1 甲融合案;设计权威 整合定稿 §1.4 一树双币三脉一脊)---- + +# 全树已购总等级(row-gating 输入——「投入点数累计解锁新行」) +func tree_spent_total() -> int: + var total := 0 + for lvl in (save.tree_nodes() as Dictionary).values(): + total += int(lvl) + return total + + +# 节点可购校验(row-gating + 显式前置 + 币 + cap;free 节点=迁移/彩蛋不经购买) +func tree_can_buy(node_id: String) -> bool: + var row: Dictionary = db.tree_node(node_id) + if row.is_empty(): + return false + var lvl := save.tree_level(node_id) + if lvl >= int(row.get("cap", 1)): + return false + var row_num := int(row.get("row", 0)) + if row_num > 0 and tree_spent_total() < db.meta_tree.row_gate(row_num): + return false + for req in (row.get("requires", []) as Array): + if save.tree_level(String(req)) < 1: + return false + var fg := String(row.get("fork_group", "")) + if fg != "" and save.fork_side(fg) != "": + return false # 同组已激活一侧——互斥(切另侧走 tree_toggle_fork 免费切换) + return true + + +# 节点购买(营地树 UI 唯一写口):**单币=钥石先行**(双币铁律中 GP 需局外余额承载域——GP 冒险 +# 收束清零=局内死钱不跨局,GP 余额域缺位,脊柱 GP 节点暂走钥石消费——表 currency 字段保留语义, +# T9/数值批补 GP 余额域后切回——台账披露待追认)。free=迁移点亮/彩蛋觉醒零币。 +func tree_buy(node_id: String) -> bool: + var row: Dictionary = db.tree_node(node_id) + if row.is_empty() or not tree_can_buy(node_id): + return false + var price := int(row.get("price", 0)) + var currency := String(row.get("currency", "gp")) + if currency == "gp" or currency == "keystone": + if not save.spend_keystone(price): + return false # T8 骨架单币承载(GP 余额域缺位——披露待追认) + save.set_tree_level(node_id, save.tree_level(node_id) + 1) + _check_easter_egg() + return save.write_to_disk() + + +# Fork 激活/切换(营地树 UI——同组换侧零币=免费切换裁定;未激活组首次激活=消费购买面 tree_buy +# 已购该侧后此处激活;同侧重复=零操作) +func tree_toggle_fork(group: String, side: String) -> bool: + if not db.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"): + return false + var cur := save.fork_side(group) + if cur == side: + return false # 同侧零操作 + if cur == "": + return false # 未激活组走购买面(tree_buy 激活侧节点后由激活写入——见下) + save.set_fork_side(group, side) + return save.write_to_disk() + + +# Fork 侧节点购买入口(购节点即激活该侧——组内互斥由 can_buy 拦截另侧) +func tree_buy_fork(node_id: String) -> bool: + var row: Dictionary = db.tree_node(node_id) + if row.is_empty() or String(row.get("kind", "")) != "fork": + return tree_buy(node_id) + if not tree_can_buy(node_id): + return false + var price := int(row.get("price", 0)) + if not save.spend_keystone(price): + return false + save.set_tree_level(node_id, 1) + save.set_fork_side(String(row.get("fork_group", "")), String(row.get("fork_side", ""))) + return save.write_to_disk() + + +# 树效果投影:四线 flat 汇总 + rule 键集(激活 Fork 侧 effects 并入) +func tree_stat_flat(key: String) -> int: + var total := 0 + for row in db.tree_rows(): + var lvl := save.tree_level(String(row.get("node_id", ""))) + if lvl <= 0: + continue + total += int((row.get("effects", {}) as Dictionary).get(key, 0)) * lvl + return total + + +func tree_rule_keys() -> Array: + var out: Array = [] + var sides: Dictionary = save.fork_sides() + for row in db.tree_rows(): + if String(row.get("kind", "")) != "fork": + continue + var fg := String(row.get("fork_group", "")) + if String(sides.get(fg, "")) != String(row.get("fork_side", "")): + continue + for k in (row.get("effects", {}) as Dictionary).keys(): + if not out.has(String(k)): + out.append(String(k)) + return out + + +# 营地树面投影(CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工) +func tree_camp_rows() -> Array: + var out: Array = [] + for row in db.tree_rows(): + if String(row.get("kind", "")) == "fork": + continue # Fork 行由 fork_groups 段承载 + out.append({ + "node_id": String(row.get("node_id", "")), + "display_name": String(row.get("display_name", "")), + "vein": String(row.get("vein", "")), + "kind": String(row.get("kind", "")), + "cap": int(row.get("cap", 1)), + "level": save.tree_level(String(row.get("node_id", ""))), + "price": int(row.get("price", 0)), + "currency": String(row.get("currency", "gp")), + "can_buy": tree_can_buy(String(row.get("node_id", ""))), + }) + return out + + +func tree_fork_groups() -> Array: + var out: Array = [] + var sides: Dictionary = save.fork_sides() + for fg in db.meta_tree.FORK_GROUPS: + var group_rows: Array = [] + for row in db.tree_rows(): + if String(row.get("fork_group", "")) == String(fg): + group_rows.append(row) + if group_rows.is_empty(): + continue + var sides_out: Array = [] + for row in group_rows: + sides_out.append({ + "side": String(row.get("fork_side", "")), + "node_id": String(row.get("node_id", "")), + "display_name": String(row.get("display_name", "")), + "owned": save.tree_level(String(row.get("node_id", ""))) > 0, + }) + out.append({ + "group": String(fg), + "display_name": String(group_rows[0].get("display_name", String(fg))).split("·")[0], + "active_side": String(sides.get(String(fg), "")), + "sides": sides_out, + }) + return out + + +# 彩蛋觉醒检查(锻造脉三节点全满→easter_ley 免费点亮——发现率监控 T10 呈报) +func _check_easter_egg() -> void: + var row: Dictionary = db.tree_node("easter_ley") + if row.is_empty() or save.tree_level("easter_ley") > 0: + return + for req in (row.get("requires", []) as Array): + var req_row: Dictionary = db.tree_node(String(req)) + if save.tree_level(String(req)) < int(req_row.get("cap", 1)): + return + save.set_tree_level("easter_ley", 1) + + +# 三体系迁移(迭代 12 T9——v4→v5 旧域只读→树域点亮;幂等=重复迁移零变化): +# forge_levels 等级→锻造脉节点树级原样映射(钥石付价不变零亏损——「宁多勿少」以零亏损满足); +# item_unlocks 任一真→ks_workshop 点亮;同调槽位解锁态→socket 点亮(谓词表 Slots 求值≥i)。 +func migrate_tree() -> Dictionary: + var migrated := {"forge": 0, "unlock": 0, "socket": 0} + for pair in [["forge_atk", "atk"], ["forge_hp", "max_hp"], ["forge_ac", "armor_class"]]: + var lvl := save.forge_level(String(pair[1])) + if lvl > 0 and save.tree_level(String(pair[0])) < lvl: + save.set_tree_level(String(pair[0]), lvl) + migrated["forge"] += 1 + var any_unlock := false + for iid in db.aux_live_ids().keys(): + if save.item_unlocked(String(iid)): + any_unlock = true + break + if not any_unlock: + for row in db.aux_attunement_rows(): + if save.item_unlocked(String(row.get("item_id", ""))): + any_unlock = true + break + if any_unlock and save.tree_level("ks_workshop") < 1: + save.set_tree_level("ks_workshop", 1) + migrated["unlock"] += 1 + var slots := Attunement.slots(db, save) + for i in range(1, slots + 1): + if save.tree_level("att_socket%d" % i) < 1: + save.set_tree_level("att_socket%d" % i, 1) + migrated["socket"] += 1 + save.write_to_disk() + return migrated + + # ---- 同调位账本(迭代 11 第①批 T4——装配关系/同调位求值/Y 刷新升级;设计权威 proposal §2 + 任务书 P1 甲)---- # 装配切换(营地装配覆盖层唯一写口):已装 → 卸下恒可;未装 → 非同调行拒绝 + 满位禁选; diff --git a/game/core/relics_engine.gd b/game/core/relics_engine.gd new file mode 100644 index 00000000..a37ec3bf --- /dev/null +++ b/game/core/relics_engine.gd @@ -0,0 +1,52 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-10-04(双层 BD 实施批 T1——标签档位激活引擎) +# 流派成型判据机制化(整合定稿 设计工具 1——以撒套装/小骨铭文先例): +# 同流派标签 relic 计数 ≥2 → 档一(流派核心规则);≥4 → 档二(完全体);<2 = 散件(单体保底—— +# 每件效果独立生效,档位只做质变不做启动,反例 B-2)。 +# 纯函数零 await 零 RNG 零副作用(Attunement 求值服务同款纪律);核心层零特判延伸——引擎只读 +# relic_table 行集与持有集,接线点消费 tiers/hooks 投影。 +class_name RelicsEngine +extends RefCounted + + +## 档位求值:relic_ids 持有集 × 行集 → {archetype: tier}(tier ∈ 0/1/2;无行流派不出现) +static func tiers_for(relic_ids: Array, table: RelicTable) -> Dictionary: + var counts := {} + for rid in relic_ids: + var row: Dictionary = table.row_by_id(String(rid)) if table != null else {} + if row.is_empty(): + continue + var arch := String(row.get("archetype", "")) + counts[arch] = int(counts.get(arch, 0)) + 1 + var out := {} + for arch in counts.keys(): + var n := int(counts[arch]) + if n >= 4: + out[arch] = 2 + elif n >= 2: + out[arch] = 1 + else: + out[arch] = 0 + return out + + +## 挂点投影:持有集 → [{relic_id, hook, archetype, tier, effects}](表序稳定——同集合同投影, +## MC 逐位可复放承载) +static func hooks_for(relic_ids: Array, table: RelicTable) -> Array: + var out: Array = [] + for rid in relic_ids: + var row: Dictionary = table.row_by_id(String(rid)) if table != null else {} + if row.is_empty(): + continue + out.append({ + "relic_id": String(row.get("relic_id", "")), + "hook": String(row.get("hook", "")), + "archetype": String(row.get("archetype", "")), + "effects": (row.get("effects", {}) as Dictionary).duplicate(true), + }) + return out + + +## 成型判读(读数面口径):流派 tier ≥1 = 成型(档一即判——任务书完成定义「标签+档位激活引擎可判成型」) +static func is_formed(tiers: Dictionary, archetype: String) -> bool: + return int(tiers.get(archetype, 0)) >= 1 diff --git a/game/core/relics_engine.gd.uid b/game/core/relics_engine.gd.uid new file mode 100644 index 00000000..1d722a33 --- /dev/null +++ b/game/core/relics_engine.gd.uid @@ -0,0 +1 @@ +uid://6d12lkx5w2x8 diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index 4b042cad..363a525e 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -36,7 +36,7 @@ class_name SaveManager extends RefCounted -const SAVE_FORMAT_VERSION := 4 # registry schema_version:v4=同调批扩域(meta 三新域 boss_kill_count/attunement_equipped/attune_refresh_level——M-4 迭代 11 第①批 T2;断点充能余量随 T6 接线,旧档缺键 = 合法空态);v3=断点附随件扩列+摘要 keystone_drops(M-3 迭代 10 奖励重构);v2=断点域出生(M-3 迭代 8 卡①);v1=首版;高版本拒读 owner=存档序列化 +const SAVE_FORMAT_VERSION := 5 # registry schema_version:v5=天赋树域(迭代 12 T9——meta 三新域 tree_nodes〔node_id→已购等级〕/fork_sides〔fork_group→a|b〕/migration_note〔三体系迁移补偿注记〕——M-4 迭代 12 批次二;任务书「v3→v4」系同调批未落时行文,v4 已被同调批占用顺延 v5 披露);v4=同调批扩域(meta 三新域 boss_kill_count/attunement_equipped/attune_refresh_level——M-4 迭代 11 第①批 T2;断点充能余量随 T6 接线,旧档缺键 = 合法空态);v3=断点附随件扩列+摘要 keystone_drops(M-3 迭代 10 奖励重构);v2=断点域出生(M-3 迭代 8 卡①);v1=首版;高版本拒读 owner=存档序列化 const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前) const FORGE_ATTRS := ["max_hp", "atk", "armor_class"] # 属性锻造三档合法键(元进度.md 属性锻造柜台) @@ -54,6 +54,7 @@ func _init(path: String = "user://save.json") -> void: register_migration(1, _migrate_v1_v2) register_migration(2, _migrate_v2_v3) register_migration(3, _migrate_v3_v4) + register_migration(4, _migrate_v4_v5) # v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务) @@ -80,6 +81,22 @@ func _migrate_v3_v4(data: Dictionary) -> Dictionary: return data +# v4→v5:天赋树域+三体系迁移(迭代 12 T9——R1 甲融合案;差额补偿原则=宁多勿少): +# ①tree_nodes/fork_sides 两新域(缺键默认补齐);②forge_levels 已购等级→锻造脉节点等效点亮 +# (原样映射零亏损——钥石付价不变);③item_unlocks 已解锁→ks_workshop Unlock 节点点亮; +# ④attunement 槽位解锁态→同调 socket 点亮(已拥有道具/装配关系不动——装配交互不变裁定); +# ⑤迁移幂等由 MetaProgression.migrate_tree 侧承载(重复迁移零变化——T10 断言面)。 +# 旧三域只读保留一版周期(forge_levels/item_unlocks 读取面维持——回滚窗口)。 +func _migrate_v4_v5(data: Dictionary) -> Dictionary: + var meta: Dictionary = data.get("meta", {}) as Dictionary + if not meta.has("tree_nodes"): + meta["tree_nodes"] = {} + if not meta.has("fork_sides"): + meta["fork_sides"] = {} + data["version"] = SAVE_FORMAT_VERSION + return data + + # ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)---- func _default_data() -> Dictionary[String, Variant]: @@ -94,6 +111,8 @@ func _default_data() -> Dictionary[String, Variant]: "boss_kill_count": 0, "attunement_equipped": [], "attune_refresh_level": 0, + "tree_nodes": {}, # 天赋树已购等级(node_id→int——迭代 12 T9 树域) + "fork_sides": {}, # A/B 激活侧(fork_group→"a"|"b"——营地免费切换承载) "recent_summaries": [], "first_adventure_done": false, }, @@ -232,6 +251,36 @@ func boss_kill_count() -> int: return int(_data["meta"].get("boss_kill_count", 0)) +# ---- 天赋树域(迭代 12 T9——MetaProgression 树段读写面)---- + +func tree_nodes() -> Dictionary: + return (_data["meta"].get("tree_nodes", {}) as Dictionary).duplicate() + + +func tree_level(node_id: String) -> int: + return int((_data["meta"].get("tree_nodes", {}) as Dictionary).get(node_id, 0)) + + +func set_tree_level(node_id: String, level: int) -> void: + var tn: Dictionary = _data["meta"].get("tree_nodes", {}) as Dictionary + tn[node_id] = maxi(0, level) + _data["meta"]["tree_nodes"] = tn + + +func fork_sides() -> Dictionary: + return (_data["meta"].get("fork_sides", {}) as Dictionary).duplicate() + + +func fork_side(group: String) -> String: + return String((_data["meta"].get("fork_sides", {}) as Dictionary).get(group, "")) + + +func set_fork_side(group: String, side: String) -> void: + var fs: Dictionary = _data["meta"].get("fork_sides", {}) as Dictionary + fs[group] = side + _data["meta"]["fork_sides"] = fs + + # 装配关系(同调目录 item_id 有序数组——装配序即呈现序;跨冒险持久,冒险中途不可换装〔底稿 §8.5〕)。 # 局外进度事实(元进度域同族),非契约 2 反面清单的「局内道具库存」——存档序列化.md 契约 2 随批注记。 func attunement_equipped() -> Array: diff --git a/game/data/schemas/meta_tree_table.gd b/game/data/schemas/meta_tree_table.gd new file mode 100644 index 00000000..6560f672 --- /dev/null +++ b/game/data/schemas/meta_tree_table.gd @@ -0,0 +1,118 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 三旧体系(锻造/钥石/同调)收敛为一棵营地天赋树后,双币消费、row-gating、 +# A/B 免费切换、彩蛋觉醒能否机械承载(P0——迭代 12 批次二 T8,R1 甲融合案) +# Date: 2026-10-04(双层 BD 实施批 T8;设计权威 双层BD体系-整合定稿-2026-10-03 §1.4) +# 数据配置 MetaTreeTable schema(元进度 回填 owner 批末落文;行 = Dictionary 键 node_id,Rule 4 豁免 +# 定义表——RelicTable 先例)。 +# 行字段:node_id / display_name / vein ∈ VEINS(spine=主干脊柱 row-gating,GP 买深度;forge=锻造脉 +# 数值专精;keystone=钥石脉内容解锁;attune=同调脉槽位与规则)/ kind ∈ KINDS(stat=四线数值/ +# rule=全局规则/unlock=内容解锁/socket=同调位/fork=A/B 互斥对/easter=彩蛋觉醒)/ +# currency ∈ {gp, keystone, free}(双币铁律:GP 喂脊柱数值、钥石喂 Unlock/Socket/Fork——哈迪斯镜内 +# 双币同构;free=迁移点亮与彩蛋)/ price [0,9999] / cap [1,9](等级上限;fork/unlock 恒 1)/ +# row(脊柱行号 1..N——row-gating:行 N 可购条件 = 全树已购等级总和 ≥ SPINE_ROW_GATE×(N-1))/ +# requires Array[node_id](显式前置——父开子脉内链)/ effects Dictionary(stat 四线 flat: +# atk/max_hp/hit/armor_class;rule 键封闭 RULE_KEYS)/ fork_group/fork_side(a/b——同组互斥、 +# 营地免费切换)。 +# 🔴 层级纪律(任务书红线 2):局外树只放全局规则件——effects 禁流派标签(relic 流派件永不入树, +# archetype 键出域=加载致命);流派件只走局内随机渠道。 +# A/B 只放规则面(定稿 1.3——全树互斥面 ≤10-15% 首期 4 组);两侧「不同强非强弱」MC 验差 <~3pp(T10)。 +# 彩蛋 ≤3(定稿 1.4——点满某脉某排免费觉醒;发现率 <5% 改灰色预告——T10 追认单呈报)。 +class_name MetaTreeTable +extends Resource + +@export var rows: Array = [] # Array[Dictionary] 键 node_id + +const VEINS := ["spine", "forge", "keystone", "attune"] +const KINDS := ["stat", "rule", "unlock", "socket", "fork", "easter"] +const CURRENCIES := ["gp", "keystone", "free"] +const STAT_KEYS := ["atk", "max_hp", "hit", "armor_class"] +## 规则键封闭词表(零裸 flat 同族护栏——effects.rule 键出域=加载致命;首期两键=T8 Fork 面真实接线) +const RULE_KEYS := ["posture_first_strike", "posture_bulwark"] +const KEY_WHITELIST := ["node_id", "display_name", "vein", "kind", "currency", "price", "cap", + "row", "requires", "effects", "fork_group", "fork_side", "note"] +## 脊柱行门槛(row-gating:行 N 可购需全树已购总等级 ≥ GATE×(N−1)——「投入点数累计解锁新行」) +const SPINE_ROW_GATE := 4 + +## A/B 组词表(同组恰两侧 a/b——互斥对;营地免费切换承载面) +const FORK_GROUPS := ["fg_posture", "fg_sense", "fg_economy", "fg_mastery"] + + +func validate() -> Array: + var errs: Array = [] + if rows.is_empty(): + return errs # 空表合法态(T8 建表先行——内容随迁移批落) + var seen := {} + var fork_seen := {} + for row in rows: + var nid: String = row.get("node_id", "") + if nid == "": + errs.append({"category": "domain", "message": "node_id 空"}) + continue + if seen.has(nid): + errs.append({"category": "dup_key", "message": "node_id 重复: %s" % nid}) + continue + seen[nid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % nid}) + if not VEINS.has(String(row.get("vein", ""))): + errs.append({"category": "enum", "message": "%s vein 出域" % nid}) + var kind := String(row.get("kind", "")) + if not KINDS.has(kind): + errs.append({"category": "enum", "message": "%s kind 出域" % nid}) + if not CURRENCIES.has(String(row.get("currency", ""))): + errs.append({"category": "enum", "message": "%s currency 出域" % nid}) + for k in row.keys(): + if not KEY_WHITELIST.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]}) + var cap := int(row.get("cap", 1)) + if cap < 1 or cap > 9: + errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]}) + if kind in ["unlock", "socket", "fork", "easter"] and cap != 1: + errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]}) + var price := int(row.get("price", 0)) + if price < 0 or price > 9999: + errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]}) + var fx: Dictionary = row.get("effects", {}) as Dictionary + for ek in fx.keys(): + if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))): + errs.append({"category": "zero_flat", "message": "%s effects 键出域(四线/rule 词表封闭): %s" % [nid, String(ek)]}) + # 🔴 层级纪律:局外树禁流派件(archetype 键出域=加载致命) + if fx.has("archetype"): + errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——流派件禁入局外树)" % nid}) + # A/B 互斥对结构:fork 行必带 fork_group+side;同组恰 a/b 各一 + if kind == "fork": + var fg := String(row.get("fork_group", "")) + if not FORK_GROUPS.has(fg): + errs.append({"category": "enum", "message": "%s fork_group 出域" % nid}) + var side := String(row.get("fork_side", "")) + if side != "a" and side != "b": + errs.append({"category": "enum", "message": "%s fork_side 出域: %s" % [nid, side]}) + var fk := fg + "|" + side + if fork_seen.has(fk): + errs.append({"category": "dup_key", "message": "%s fork 对重复: %s" % [nid, fk]}) + fork_seen[fk] = true + # requires 悬空引用 + for row in rows: + for req in (row.get("requires", []) as Array): + if not seen.has(String(req)): + errs.append({"category": "dangling", "message": "%s requires 悬空: %s" % [String(row.get("node_id", "")), String(req)]}) + # 彩蛋 ≤3 + var easter := 0 + for row in rows: + if String(row.get("kind", "")) == "easter": + easter += 1 + if easter > 3: + errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(定稿 §1.4 上限)" % easter}) + return errs + + +func row_by_id(node_id: String) -> Dictionary: + for row in rows: + if String(row.get("node_id", "")) == node_id: + return row + return {} + + +## row-gating 门槛(行 N 可购需全树总等级 ≥ GATE×(N−1);非脊柱节点跟随其 vein 父行——requires 主导) +func row_gate(row_num: int) -> int: + return SPINE_ROW_GATE * maxi(0, row_num - 1) diff --git a/game/data/schemas/meta_tree_table.gd.uid b/game/data/schemas/meta_tree_table.gd.uid new file mode 100644 index 00000000..bfc63e4b --- /dev/null +++ b/game/data/schemas/meta_tree_table.gd.uid @@ -0,0 +1 @@ +uid://dih0sh0jjo71c diff --git a/game/data/schemas/relic_table.gd b/game/data/schemas/relic_table.gd new file mode 100644 index 00000000..f6487ef7 --- /dev/null +++ b/game/data/schemas/relic_table.gd @@ -0,0 +1,127 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 七核心流派各 ≥3 件入局内随机渠道后,标签+档位激活引擎能否机械判「成型」、 +# 机制件带能否挂进令牌池稀有/罕见档随机投放(P0——迭代 12 双层 BD 实施批 T1) +# Date: 2026-10-04(双层 BD 实施批 T1——任务书五裁定稿;设计权威 双层BD体系-整合定稿-2026-10-03 v3) +# 数据配置 RelicTable schema(双层BD/机制件 回填 owner 批末落文;行 = Dictionary 键 relic_id,Rule 4 豁免 +# 定义表——AuxItemTable 先例)。 +# 行字段:relic_id 主键 / display_name / rarity ∈ {common, rare, rarest}(对齐令牌池三档命名—— +# 投放档映射:common/rare→稀有令牌档、rarest→罕见令牌档;relic 令牌永不入普通档=「挂稀有/罕见档」 +# 追认 b 接线口径)/ archetype ∈ ARCHETYPES 七流派标签 / hook ∈ HOOKS 挂点枚举(结算段槽——整合定稿 +# §2.1 哈迪斯恩赐槽同构)/ effects Dictionary(hook 消费参数,provisional 投值)。 +# 正负双修(整合定稿 B2 判据):effects 允许负向键(cost_* 族)——纯增益件须专项论证(反例 B-3),加载期 +# 仅校验键域不禁纯增益(专项论证件以 positive_only 批注登记,人工清单呈报)。 +# 档位激活(整合定稿 设计工具 1——以撒套装/小骨铭文):同标签计数 ≥2→档一(流派核心规则)/≥4→档二 +# (完全体);散件单体保底=每件效果独立生效,档位只做质变(反例 B-2)——引擎纯函数 RelicsEngine。 +# 🔴 层级纪律(任务书红线 2):流派件只走局内随机渠道(三选一/商店/格面)——本表行永不入同调位 +# (aux_items 表)/局外树(T8 节点表);扫雷触发点铁律:每流派 ≥1 行 hook ∈ MINE_HOOKS(加载校验, +# 违反=红线①触碰面)。 +class_name RelicTable +extends Resource + +@export var rows: Array = [] # Array[Dictionary] 键 relic_id + +## 流派标签词表(七核心——喂雷/淘金不在本批,R5 甲/缓议) +const ARCHETYPES := ["magic", "pierce", "normal", "dig", "flag", "charge", "devour"] + +## 稀有度词表(对齐令牌池三档;投放档映射 relic→rare / rarest→rarest) +const RARITIES := ["common", "rare", "rarest"] + +## 挂点枚举(结算段槽——首批 23 点位;每 hook 恰一流派域消费,T2-T6 接线) +const HOOKS := [ + # 魔法流 + "hero_attack_rider", # 普攻命中后追加必中魔法伤(奥术余烬/魔能共鸣) + "clamp_modifier", # 克制点直调(魔网织者/湛蓝誓约/猎手刺剑) + "first_dig_spell", # 每关首次开格必出魔法矢(魔能共鸣第二效果) + # 穿刺流 + "kill_overflow", # 击杀溢出弹射(贯穿射击) + "dig_count_segments", # 开格计数转攻击段数(连珠矢) + "roll_discount", # 折扣档改写(猎弱本能前移一档) + # 普攻流 + "round_dig_bonus", # 轮内安全开格数转攻击加成(矿工的执拗;踩雷清零=mine_hit 监听同口) + "hit_trade", # 命中交换(矿工重锤——正负双修原生形态) + "armor_roll_rewrite", # 免掷骰恒折扣(专注) + "heavy_debuff", # 命中重甲附加护甲裂解(碎甲震击) + # 开格流 + "dig_yield_mult", # 开格产出乘区(贪婪矿灯/矿脉直觉/赌徒的血签——正负双向) + "deep_dig", # 深层资源掘取(深挖钻头——R4 甲非信息税形态:开掘额外 AP 代价) + # 标旗流 + "flag_resource", # 标中雷获格面资源(雷区图谱) + "nest_aura", # 控制巢穴数转伤害光环(猎巢人) + "flag_all_hit", # 同轮全中旗伤倍增(完美布防) + "flag_strike", # 轮末旗伤开单(战旗军规——复用 board_typed) + # 蓄力流 + "ap_carry", # 轮末 AP 结转蓄力(整备军备——改写 cleared 作废口径) + "charge_shield", # 蓄力层转减伤(铁壁整备) + "charge_spike", # 满层 spike(厚积薄发) + # 吞噬流 + "terrain_refund", # 受地形伤掉落资源补偿(地脉采撷——伤害仍发生) + "terrain_dig_double", # 挖开伤害型地形格双产出(荆棘编织者) + "flag_neutralize", # 标中旗中和相邻伤害型地形格(净化工兵——🔴 R4 甲仅限伤害型) + "variant_counter", # 受地形伤积层轮末反打(熔火之躯——触发键不按地形种类特化) +] + +## 扫雷结算触发点 hook 集(🔴 红线①承载:每流派 ≥1 行 hook 命中本集——加载校验) +const MINE_HOOKS := ["first_dig_spell", "dig_count_segments", "round_dig_bonus", "dig_yield_mult", + "deep_dig", "flag_resource", "nest_aura", "flag_all_hit", "flag_strike", "ap_carry", + "terrain_refund", "terrain_dig_double", "flag_neutralize", "variant_counter"] + +## effects 键白名单(🔴 零裸 flat 同族护栏——机制轴禁无条件属性加项;键出域=加载致命) +## 全部条件触发/机制修正参数;cost_* 族=正负双修负向面 +const EFFECT_KEYS := ["value", "value2", "tier_value", "per_stack", "cap", "ap_cost", "chance", + "mult", "cost_mult", "cost_flat", "polarity", "spell_kind", "segment_cap", "discount_shift", + "trade_atk", "trade_hit", "shield_per_layer", "spike_mult", "layers_to_shield", + "neutralize_range", "counter_damage", "resource_kind", "rider_kind", "note"] + + +func validate() -> Array: + var errs: Array = [] + if rows.is_empty(): + return errs # 空表合法态(T1 基建先行——T2 起逐流派填行) + var seen := {} + for row in rows: + var rid: String = row.get("relic_id", "") + if rid == "": + errs.append({"category": "domain", "message": "relic_id 空"}) + continue + if seen.has(rid): + errs.append({"category": "dup_key", "message": "relic_id 重复: %s" % rid}) + continue + seen[rid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % rid}) + if not RARITIES.has(String(row.get("rarity", ""))): + errs.append({"category": "enum", "message": "%s rarity 出域 %s" % [rid, str(RARITIES)]}) + var arch := String(row.get("archetype", "")) + if not ARCHETYPES.has(arch): + errs.append({"category": "enum", "message": "%s archetype 出域 %s" % [rid, str(ARCHETYPES)]}) + var hook := String(row.get("hook", "")) + if not HOOKS.has(hook): + errs.append({"category": "enum", "message": "%s hook 出域(挂点枚举封闭): %s" % [rid, hook]}) + for k in row.keys(): + if not ["relic_id", "display_name", "rarity", "archetype", "hook", "effects", "note"].has(String(k)): + errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [rid, String(k)]}) + var fx: Dictionary = row.get("effects", {}) as Dictionary + for ek in fx.keys(): + if not EFFECT_KEYS.has(String(ek)): + errs.append({"category": "zero_flat", "message": "%s effects 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(ek)]}) + # 🔴 红线① 扫雷触发点铁律:**有行的流派**各 ≥1 行 hook ∈ MINE_HOOKS(部分填充合法——T2-T6 + # 逐流派渐进;七流派全量覆盖完成度由批次一出口断言承载,加载闸不卡渐进填行) + var archs_with_rows := {} + for row in rows: + archs_with_rows[String(row.get("archetype", ""))] = true + for arch in archs_with_rows.keys(): + var covered := false + for row in rows: + if String(row.get("archetype", "")) == String(arch) and MINE_HOOKS.has(String(row.get("hook", ""))): + covered = true + break + if not covered: + errs.append({"category": "load_bearing", "message": "流派 %s 无扫雷结算触发点行(🔴 红线①铁律——每流派 ≥1 件挂扫雷端)" % String(arch)}) + return errs + + +func row_by_id(relic_id: String) -> Dictionary: + for row in rows: + if String(row.get("relic_id", "")) == relic_id: + return row + return {} diff --git a/game/data/schemas/relic_table.gd.uid b/game/data/schemas/relic_table.gd.uid new file mode 100644 index 00000000..45b83903 --- /dev/null +++ b/game/data/schemas/relic_table.gd.uid @@ -0,0 +1 @@ +uid://bm46akskc3rc diff --git a/game/data/tables/meta_tree.tres b/game/data/tables/meta_tree.tres new file mode 100644 index 00000000..05c03313 --- /dev/null +++ b/game/data/tables/meta_tree.tres @@ -0,0 +1,415 @@ +[gd_resource type="Resource" script_class="MetaTreeTable" load_steps=2 format=3] + +[ext_resource type="Script" path="res://data/schemas/meta_tree_table.gd" id="1"] + +[resource] +script = ExtResource("1") +rows = [{ + "node_id": "sp_hp_r1", + "display_name": "生命淬炼·1 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 10, + "cap": 3, + "row": 1, + "effects": { + "max_hp": 5 + } +}, { + "node_id": "sp_atk_r1", + "display_name": "攻击淬炼·1 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 10, + "cap": 3, + "row": 1, + "effects": { + "atk": 1 + } +}, { + "node_id": "sp_hit_r1", + "display_name": "命中淬炼·1 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 10, + "cap": 3, + "row": 1, + "effects": { + "hit": 1 + } +}, { + "node_id": "sp_ac_r1", + "display_name": "护甲淬炼·1 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 10, + "cap": 3, + "row": 1, + "effects": { + "armor_class": 1 + } +}, { + "node_id": "sp_hp_r2", + "display_name": "生命淬炼·2 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 20, + "cap": 3, + "row": 2, + "effects": { + "max_hp": 5 + } +}, { + "node_id": "sp_atk_r2", + "display_name": "攻击淬炼·2 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 20, + "cap": 3, + "row": 2, + "effects": { + "atk": 1 + } +}, { + "node_id": "sp_hit_r2", + "display_name": "命中淬炼·2 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 20, + "cap": 3, + "row": 2, + "effects": { + "hit": 1 + } +}, { + "node_id": "sp_ac_r2", + "display_name": "护甲淬炼·2 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 20, + "cap": 3, + "row": 2, + "effects": { + "armor_class": 1 + } +}, { + "node_id": "sp_hp_r3", + "display_name": "生命淬炼·3 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 30, + "cap": 3, + "row": 3, + "effects": { + "max_hp": 5 + } +}, { + "node_id": "sp_atk_r3", + "display_name": "攻击淬炼·3 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 30, + "cap": 3, + "row": 3, + "effects": { + "atk": 1 + } +}, { + "node_id": "sp_hit_r3", + "display_name": "命中淬炼·3 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 30, + "cap": 3, + "row": 3, + "effects": { + "hit": 1 + } +}, { + "node_id": "sp_ac_r3", + "display_name": "护甲淬炼·3 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 30, + "cap": 3, + "row": 3, + "effects": { + "armor_class": 1 + } +}, { + "node_id": "sp_hp_r4", + "display_name": "生命淬炼·4 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 40, + "cap": 3, + "row": 4, + "effects": { + "max_hp": 5 + } +}, { + "node_id": "sp_atk_r4", + "display_name": "攻击淬炼·4 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 40, + "cap": 3, + "row": 4, + "effects": { + "atk": 1 + } +}, { + "node_id": "sp_hit_r4", + "display_name": "命中淬炼·4 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 40, + "cap": 3, + "row": 4, + "effects": { + "hit": 1 + } +}, { + "node_id": "sp_ac_r4", + "display_name": "护甲淬炼·4 层", + "vein": "spine", + "kind": "stat", + "currency": "gp", + "price": 40, + "cap": 3, + "row": 4, + "effects": { + "armor_class": 1 + } +}, { + "node_id": "forge_atk", + "display_name": "武器锻造", + "vein": "forge", + "kind": "stat", + "currency": "keystone", + "price": 3, + "cap": 5, + "requires": ["sp_atk_r1"], + "effects": { + "atk": 1 + } +}, { + "node_id": "forge_hp", + "display_name": "护身锻造", + "vein": "forge", + "kind": "stat", + "currency": "keystone", + "price": 3, + "cap": 5, + "requires": ["sp_hp_r1"], + "effects": { + "max_hp": 1 + } +}, { + "node_id": "forge_ac", + "display_name": "甲胄锻造", + "vein": "forge", + "kind": "stat", + "currency": "keystone", + "price": 3, + "cap": 5, + "requires": ["sp_ac_r1"], + "effects": { + "armor_class": 1 + } +}, { + "node_id": "ks_workshop", + "display_name": "道具工坊", + "vein": "keystone", + "kind": "unlock", + "currency": "free", + "price": 0, + "cap": 1, + "requires": ["sp_hp_r1"] +}, { + "node_id": "ks_refresh", + "display_name": "刷新升级", + "vein": "keystone", + "kind": "rule", + "currency": "keystone", + "price": 30, + "cap": 2, + "requires": ["ks_workshop"] +}, { + "node_id": "att_socket1", + "display_name": "同调位 1", + "vein": "attune", + "kind": "socket", + "currency": "keystone", + "price": 20, + "cap": 1, + "requires": ["sp_atk_r1"] +}, { + "node_id": "att_socket2", + "display_name": "同调位 2", + "vein": "attune", + "kind": "socket", + "currency": "keystone", + "price": 40, + "cap": 1, + "requires": ["att_socket1"] +}, { + "node_id": "att_socket3", + "display_name": "同调位 3", + "vein": "attune", + "kind": "socket", + "currency": "keystone", + "price": 60, + "cap": 1, + "requires": ["att_socket2"] +}, { + "node_id": "fork_posture_a", + "display_name": "先手压制", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_first_strike": 1 + }, + "fork_group": "fg_posture", + "fork_side": "a" +}, { + "node_id": "fork_posture_b", + "display_name": "稳健拓荒", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_bulwark": 1 + }, + "fork_group": "fg_posture", + "fork_side": "b" +}, { + "node_id": "fork_sense_a", + "display_name": "雷感直觉·锋", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_bulwark": 1 + }, + "fork_group": "fg_sense", + "fork_side": "a" +}, { + "node_id": "fork_sense_b", + "display_name": "雷感直觉·守", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_first_strike": 1 + }, + "fork_group": "fg_sense", + "fork_side": "b" +}, { + "node_id": "fork_economy_a", + "display_name": "经济人格·聚", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_first_strike": 1 + }, + "fork_group": "fg_economy", + "fork_side": "a" +}, { + "node_id": "fork_economy_b", + "display_name": "经济人格·散", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_bulwark": 1 + }, + "fork_group": "fg_economy", + "fork_side": "b" +}, { + "node_id": "fork_mastery_a", + "display_name": "克制专精·穿刺", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_bulwark": 1 + }, + "fork_group": "fg_mastery", + "fork_side": "a" +}, { + "node_id": "fork_mastery_b", + "display_name": "克制专精·魔法", + "vein": "attune", + "kind": "fork", + "currency": "keystone", + "price": 25, + "cap": 1, + "requires": ["att_socket1"], + "effects": { + "posture_first_strike": 1 + }, + "fork_group": "fg_mastery", + "fork_side": "b" +}, { + "node_id": "easter_ley", + "display_name": "地脉共鸣", + "vein": "forge", + "kind": "easter", + "currency": "free", + "price": 0, + "cap": 1, + "requires": ["forge_atk", "forge_hp", "forge_ac"], + "effects": { + "atk": 1, + "max_hp": 5, + "hit": 1, + "armor_class": 1 + } +}] + +; 双层 BD 实施批 T8(2026-10-04,R1 甲融合案——整合定稿 §1.4 一树双币三脉一脊+装配区+彩蛋): +; 脊柱 4 行×4 线(生命/攻击/命中/护甲——GP 买深度 cap3 价 10×row;row-gating=全树已购总等级 +; ≥ 4×(row−1));锻造脉 3 节点收编 forge_levels(钥石付价=现行迁移零痛);钥石脉=工坊解锁收编 +; (free 迁移点亮)+刷新升级移植;同调脉 socket 3(钥石=规则面首站)+Fork 4 对(A/B 集中同调脉 +; 营地免费切换——effects 首期两 rule 键真实接线余组 stat 面 provisional);彩蛋 1(锻造满排觉醒)。 +; 🔴 层级纪律:局外树只放全局规则件——零 archetype 键(schema 校验强制);流派件只走局内随机渠道。 +; 数值全 provisional 归 M-4 数值批;A/B 两侧差 <~3pp 验收=T10 MC。 diff --git a/game/data/tables/relics.tres b/game/data/tables/relics.tres new file mode 100644 index 00000000..461c09c5 --- /dev/null +++ b/game/data/tables/relics.tres @@ -0,0 +1,274 @@ +[gd_resource type="Resource" script_class="RelicTable" load_steps=2 format=3] + +[ext_resource type="Script" path="res://data/schemas/relic_table.gd" id="1"] + +[resource] +script = ExtResource("1") +rows = [{ +"relic_id": "arcane_ember", +"display_name": "奥术余烬", +"rarity": "common", +"archetype": "magic", +"hook": "hero_attack_rider", +"effects": { +"value": 1 +} +}, { +"relic_id": "mana_weaver", +"display_name": "魔网织者", +"rarity": "rare", +"archetype": "magic", +"hook": "clamp_modifier", +"effects": { +"value": 1, +"polarity": "magic_vs_magic" +} +}, { +"relic_id": "azure_oath", +"display_name": "湛蓝誓约", +"rarity": "rare", +"archetype": "magic", +"hook": "clamp_modifier", +"effects": { +"value": 1, +"polarity": "magic_vs_heavy" +} +}, { +"relic_id": "mana_resonance", +"display_name": "魔能共鸣", +"rarity": "rarest", +"archetype": "magic", +"hook": "first_dig_spell", +"effects": { +"value": 2 +} +}, { +"relic_id": "mine_atlas", +"display_name": "雷区图谱", +"rarity": "common", +"archetype": "flag", +"hook": "flag_resource", +"effects": { +"value": 1 +} +}, { +"relic_id": "nest_hunter", +"display_name": "猎巢人", +"rarity": "rare", +"archetype": "flag", +"hook": "nest_aura", +"effects": { +"value": 1 +} +}, { +"relic_id": "perfect_defense", +"display_name": "完美布防", +"rarity": "rare", +"archetype": "flag", +"hook": "flag_all_hit", +"effects": { +"value": 2 +} +}, { +"relic_id": "war_banner", +"display_name": "战旗军规", +"rarity": "rarest", +"archetype": "flag", +"hook": "flag_strike", +"effects": { +"value": 2 +} +}, { +"relic_id": "pierce_shot", +"display_name": "贯穿射击", +"rarity": "rarest", +"archetype": "pierce", +"hook": "kill_overflow", +"effects": { +"value": 1 +} +}, { +"relic_id": "chain_arrows", +"display_name": "连珠矢", +"rarity": "rare", +"archetype": "pierce", +"hook": "dig_count_segments", +"effects": { +"value": 2, +"cap": 2 +} +}, { +"relic_id": "weak_instinct", +"display_name": "猎弱本能", +"rarity": "rare", +"archetype": "pierce", +"hook": "roll_discount", +"effects": { +"value": 1 +} +}, { +"relic_id": "hunter_rapier", +"display_name": "猎手刺剑", +"rarity": "common", +"archetype": "pierce", +"hook": "clamp_modifier", +"effects": { +"value": 1, +"polarity": "pierce_vs_light" +} +}, { +"relic_id": "miner_stubborn", +"display_name": "矿工的执拗", +"rarity": "common", +"archetype": "normal", +"hook": "round_dig_bonus", +"effects": { +"value": 1, +"cap": 5 +} +}, { +"relic_id": "miner_hammer", +"display_name": "矿工重锤", +"rarity": "common", +"archetype": "normal", +"hook": "hit_trade", +"effects": { +"trade_atk": 25, +"trade_hit": 2 +} +}, { +"relic_id": "focus_mind", +"display_name": "专注", +"rarity": "rare", +"archetype": "normal", +"hook": "armor_roll_rewrite", +"effects": { +"value": 75 +} +}, { +"relic_id": "armor_crack", +"display_name": "碎甲震击", +"rarity": "rare", +"archetype": "normal", +"hook": "heavy_debuff", +"effects": { +"value": 25 +} +}, { +"relic_id": "greedy_lantern", +"display_name": "贪婪矿灯", +"rarity": "common", +"archetype": "dig", +"hook": "dig_yield_mult", +"effects": { +"mult": 25, +"cost_mult": 25 +} +}, { +"relic_id": "vein_intuition", +"display_name": "矿脉直觉", +"rarity": "rare", +"archetype": "dig", +"hook": "dig_yield_mult", +"effects": { +"mult": 10, +"cap": 5 +} +}, { +"relic_id": "gambler_pact", +"display_name": "赌徒的血签", +"rarity": "rarest", +"archetype": "dig", +"hook": "dig_yield_mult", +"effects": { +"mult": 50, +"cost_mult": 100 +} +}, { +"relic_id": "deep_drill", +"display_name": "深挖钻头", +"rarity": "rare", +"archetype": "dig", +"hook": "deep_dig", +"effects": { +"ap_cost": 1, +"cap": 4 +} +}, { +"relic_id": "armory_prep", +"display_name": "整备军备", +"rarity": "rarest", +"archetype": "charge", +"hook": "ap_carry", +"effects": { +"value": 1, +"cap": 3 +} +}, { +"relic_id": "iron_wall", +"display_name": "铁壁整备", +"rarity": "rare", +"archetype": "charge", +"hook": "charge_shield", +"effects": { +"shield_per_layer": 1 +} +}, { +"relic_id": "patient_burst", +"display_name": "厚积薄发", +"rarity": "rare", +"archetype": "charge", +"hook": "charge_spike", +"effects": { +"value": 2 +} +}, { +"relic_id": "ley_harvest", +"display_name": "地脉采撷", +"rarity": "common", +"archetype": "devour", +"hook": "terrain_refund", +"effects": { +"value": 1 +} +}, { +"relic_id": "thorn_weaver", +"display_name": "荆棘编织者", +"rarity": "rare", +"archetype": "devour", +"hook": "terrain_dig_double", +"effects": { +"value": 1 +} +}, { +"relic_id": "purge_sapper", +"display_name": "净化工兵", +"rarity": "rare", +"archetype": "devour", +"hook": "flag_neutralize", +"effects": { +"value": 1 +} +}, { +"relic_id": "magma_body", +"display_name": "熔火之躯", +"rarity": "rarest", +"archetype": "devour", +"hook": "variant_counter", +"effects": { +"value": 1 +} +}] + +; 双层 BD 实施批(2026-10-04):机制件表——行 = 流派件定义 +; (relic_id/display_name/rarity/archetype/hook/effects)。 +; T2 魔法流四件(整合定稿 §2.3 S1):奥术余烬〔普通·rider+1 必中附加〕/魔网织者〔稀有·magic vs +; magic 克制罚修正〕/湛蓝誓约〔稀有·magic vs heavy 克制加成〕/魔能共鸣〔罕见锚点·rider+2+每战首次 +; 开格奥术矢——「每关首次」字面落地为「每战首次」台账披露待追认〕;magic 档一(≥2 件)rider+1 / +; 档二(≥4 件)rider 可暴击——引擎 tiers_for 承载。T3-T6 逐流派续填(flag 4/pierce 4/normal 4/ +; dig 4/charge 3/devour 4——整合定稿 §2.3 七流派件面)。 +; 稀有度→投放档映射:common/rare → 稀有令牌档、rarest → 罕见令牌档(relic 永不入普通令牌档 +; ——「机制件带挂稀有/罕见档」追认 b 接线口径,普通档 70 张纯数值维持)。 +; 🔴 层级纪律:本表行永不入同调位(aux_items)/局外树(T8 节点表)——流派件只走局内随机渠道。 +; 数值全 provisional 归 M-4 数值批;effects 键白名单封闭(零裸 flat 同族护栏)。 + diff --git a/game/presentation/battle_narrator.gd b/game/presentation/battle_narrator.gd index 550dab0c..e3179231 100644 --- a/game/presentation/battle_narrator.gd +++ b/game/presentation/battle_narrator.gd @@ -123,10 +123,24 @@ static func bonus_card_text(id: String, tier: String, value: int, aux_line: Stri # 三选一兑付反馈行(选中件效果 + 丢弃提示;tier 缺省普通档——旧载荷防御口径) static func bonus_result_text(result: Dictionary) -> String: - var name := bonus_card_text(String(result.get("picked", "")), String(result.get("tier", "")), int(result.get("value", 0))).replace("\n", " · ") + var name := "" + if String(result.get("picked", "")) == "relic" and String(result.get("relic_id", "")) != "": + name = relic_card_text({"relic_id": String(result.get("relic_id", "")), "display_name": String(result.get("relic_name", "")), "archetype": String(result.get("relic_archetype", ""))}).replace("\n", " · ") + else: + name = bonus_card_text(String(result.get("picked", "")), String(result.get("tier", "")), int(result.get("value", 0))).replace("\n", " · ") return "已选择:%s\n其余选项已丢弃(不可反悔)" % name +# 流派件卡片文案(迭代 12 T1——「流派件 · 流派名」形制+件名;空行 = 防御通用文案) +static func relic_card_text(row: Dictionary) -> String: + if row.is_empty() or String(row.get("relic_id", "")) == "": + return "流派件\n???" + var arch_names := {"magic": "魔法", "pierce": "穿刺", "normal": "普攻", "dig": "开格", + "flag": "标旗", "charge": "蓄力", "devour": "吞噬"} + var arch := String(arch_names.get(String(row.get("archetype", "")), "流派")) + return "流派件 · %s\n%s" % [arch, String(row.get("display_name", row.get("relic_id", "")))] + + # Boss 胜利战斗总结结算行(settlement-summary v1.1——钥石增量行;组合根拼 GP 行后追加) static func bonus_keystone_line(payload: Dictionary) -> String: return "钥石 +%d · 累计 %d" % [int(payload.get("keystone_gained", 0)), int(payload.get("keystone_total", 0))] diff --git a/game/presentation/main.gd b/game/presentation/main.gd index bf5d3978..ab0fd309 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -255,6 +255,8 @@ func _build_ui() -> void: shop_overlay.leave_pressed.connect(_on_shop_leave) camp_overlay.buy_requested.connect(_on_camp_buy) camp_overlay.attune_toggled.connect(_on_camp_attune_toggle) + camp_overlay.tree_buy_requested.connect(_on_camp_tree_buy) + camp_overlay.tree_fork_toggled.connect(_on_camp_fork_toggle) camp_overlay.closed.connect(_on_camp_closed) _end_overlay.review_pressed.connect(_on_end_review) _end_overlay.continue_pressed.connect(_on_battle_end_continue) @@ -542,16 +544,19 @@ 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) + # TP0 令牌池——候选 = {"kind","tier"} 令牌字典;卡片 id = "kind|tier|relic_id" 复合键(同 kind + # 大小两档/同档多 relic 可同屏,kind 单键不构成唯一身份),确认回程拆键(_on_bonus_pick_confirmed) var out: Array = [] var aux_i := 0 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 rid := String((tok as Dictionary).get("relic_id", "")) + var cid := "%s|%s|%s" % [kind, tier, rid] var text := "" - if kind == "aux_item": + if kind == "relic": + text = Narrator.relic_card_text(db.relic_row(rid)) + elif 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(kind, tier, int(db.adventure.bonus_value(kind, tier)), _aux_card_line(iid)) @@ -600,11 +605,12 @@ func _on_bonus_refresh() -> void: func _on_bonus_pick_confirmed(item_id: String) -> void: if adventure == null: return - # TP0 令牌池——卡片 id "kind|tier" 拆键(缺 | = 旧载荷防御归普通档) + # TP0 令牌池——卡片 id "kind|tier|relic_id" 拆键(缺 | = 旧载荷防御归普通档;relic 三段) 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) + var rid := parts[2] if parts.size() > 2 else "" + var ok: bool = adventure.apply_node_bonus(kind, tier, rid) if _bonus_context == "reward_node" else adventure.pick_bonus(kind, tier, rid) if not ok: push_error("[组合根] 三选一兑付被拒——见输出日志") @@ -1058,6 +1064,8 @@ func _camp_data() -> Dictionary: "forge": forge_rows, "workshop": meta.workshop_status() if meta != null else [], "attune": meta.attune_camp_status() if meta != null else {}, # 迭代 11 第①批 T4——同调装配面投影 + "tree": meta.tree_camp_rows() if meta != null else [], # 迭代 12 T9——天赋树面投影 + "fork_groups": meta.tree_fork_groups() if meta != null else [], "recent": save_manager.recent_summaries() if save_manager != null else [], } @@ -1091,6 +1099,29 @@ func _on_camp_attune_toggle(item_id: String) -> void: camp_overlay.refresh_camp(_camp_data()) +# 天赋树购买(迭代 12 T9——写口唯一归 MetaProgression.tree_buy/tree_buy_fork;Fork 节点购=激活侧) +func _on_camp_tree_buy(node_id: String) -> void: + if meta == null: + return + var ok: bool = meta.tree_buy_fork(node_id) if String(db.tree_node(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id) + if not ok: + board_zone.toast.show_toast("购买被拒——前置/行门槛/余额不足", "warn") + return + camp_overlay.refresh_camp(_camp_data()) + _refresh_presentations() + + +# A/B 免费切换(迭代 12 T8——同组换侧零币;未激活组走购买面) +func _on_camp_fork_toggle(group: String, side: String) -> void: + if meta == null: + return + if not meta.tree_toggle_fork(group, side): + board_zone.toast.show_toast("切换被拒——未激活组请先购买", "warn") + return + camp_overlay.refresh_camp(_camp_data()) + _refresh_presentations() + + # ---- 商店覆盖层与 GP 钱包(GP+商店房实施批——写调用唯一归组合根)---- func _on_shop_buy(product_id: String) -> void: diff --git a/game/presentation/views/camp_overlay_view.gd b/game/presentation/views/camp_overlay_view.gd index cdfa5c9e..90970c67 100644 --- a/game/presentation/views/camp_overlay_view.gd +++ b/game/presentation/views/camp_overlay_view.gd @@ -14,6 +14,8 @@ extends Control signal buy_requested(attr: String) signal closed signal attune_toggled(item_id: String) # 同调装配切换意图(迭代 11 第①批 T4——写口唯一归 MetaProgression.toggle_attunement) +signal tree_buy_requested(node_id: String) # 天赋树购买意图(迭代 12 T9——写口唯一归 MetaProgression.tree_buy) +signal tree_fork_toggled(group: String, side: String) # A/B 免费切换意图(同组换侧零币) const ATTR_LABELS := {"atk": "攻击", "max_hp": "生命上限", "armor_class": "护甲"} # 档位文案轴(数据契约 attr 词表,与 ShopOverlay 同款) # 方向稿取色(4-camp.png——柜台头带深棕/行板暖米;双色墨=亮板配深墨)——值单点收 UiKit(T4 裸色收口批) @@ -24,6 +26,7 @@ const BADGE_PANEL_COL := UiKit.CAMP_BADGE_PANEL var _balance_label: Label var _forge_holder: VBoxContainer var _workshop_holder: VBoxContainer +var _tree_holder: VBoxContainer # 天赋树面板(迭代 12 T9 程序占位——行板复用行板结构) var _forge_rows: Array[Dictionary] = [] # [{attr, level_l, price_l, btn}]——refresh_camp 就地重刷(零重建回流) var _workshop_rows: Array[Dictionary] = [] # [{item_id, progress_l, status_l}] var _attune_header_l: Label # 同调装配段(迭代 11 第①批 T4 工坊扩容——Slots 点亮呈现) @@ -112,6 +115,15 @@ func _init() -> void: lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART lock_l.custom_minimum_size = Vector2(300, 0) atlas_holder.add_child(lock_l) + # 天赋树面板(迭代 12 T9——程序占位:图鉴柜台下段承载树行〔脉分组行板+购买/切换钮〕; + # 「三柜台→一树」字面重构归营地视觉批披露,本批功能重构程序占位先行) + _tree_holder = VBoxContainer.new() + _tree_holder.add_theme_constant_override("separation", 6) + var tree_scroll := ScrollContainer.new() + tree_scroll.custom_minimum_size = Vector2(300, 200) + tree_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + tree_scroll.add_child(_tree_holder) + atlas_holder.add_child(tree_scroll) # ── 底栏:关闭钮(左)/ 操作注记(右)+ 最近冒险条带 ── var close_btn := UiKit.button("关闭营地(回开始界面)") @@ -159,11 +171,59 @@ func _counter_panel(title_text: String, icon_path: String, holder: Control, min_ # ---- 柜台行构建(open_camp 重建;行结构仿 ShopOverlay 商品卡——亮板双色墨制)---- -# 打开营地(全量重建三柜台行 + 同调装配段 + 最近冒险——数据 = 组合根 _camp_data() 投影) +# 天赋树段重建(迭代 12 T9 程序占位——脉分组行板:节点名/等级/价/购买钮 + Fork 组侧切换钮; +# 树数据 = 组合根 _camp_data().tree 投影 [{node_id, display_name, vein, kind, level, price, can_buy, +# currency}...] + fork_groups [{group, active_side, sides:[{node_id, side, display_name, owned}]}]) +func _rebuild_tree(data: Dictionary) -> void: + for c in _tree_holder.get_children(): + c.queue_free() + var tree: Array = data.get("tree", []) as Array + var forks: Array = data.get("fork_groups", []) as Array + var vein_names := {"spine": "·主干脊柱·", "forge": "·锻造脉·", "keystone": "·钥石脉·", "attune": "·同调脉·"} + var last_vein := "" + for row in tree: + var vein := String(row.get("vein", "")) + if vein != last_vein: + last_vein = vein + var vh := UiKit.label(String(vein_names.get(vein, vein)), 13, true, UiKit.GOLD_DEEP) + _tree_holder.add_child(vh) + var hb := HBoxContainer.new() + hb.add_theme_constant_override("separation", 6) + var lvl := int(row.get("level", 0)) + var cap := int(row.get("cap", 1)) + var name_l := UiKit.label("%s Lv%d/%d" % [String(row.get("display_name", "")), lvl, cap], 13, false, UiKit.INK_LABEL) + name_l.custom_minimum_size = Vector2(150, 0) + name_l.clip_text = true + hb.add_child(name_l) + if String(row.get("kind", "")) == "fork": + continue # Fork 行去 UI 化——由 fork_groups 段承载切换钮 + var btn := UiKit.button("购买 %d" % int(row.get("price", 0))) + btn.pressed.connect(func(): tree_buy_requested.emit(String(row.get("node_id", "")))) + btn.disabled = not bool(row.get("can_buy", false)) + hb.add_child(btn) + _tree_holder.add_child(hb) + for fg in forks: + var group := String(fg.get("group", "")) + var active := String(fg.get("active_side", "")) + var fb := HBoxContainer.new() + fb.add_theme_constant_override("separation", 6) + var gl := UiKit.label("%s[激活:%s]" % [String(fg.get("display_name", group)), active if active != "" else "未激活"], 13, false, UiKit.INK_LABEL) + fb.add_child(gl) + for side_row in (fg.get("sides", []) as Array): + var sd := String(side_row.get("side", "")) + var sb := UiKit.button(String(side_row.get("display_name", sd))) + sb.pressed.connect(func(): tree_fork_toggled.emit(group, sd)) + sb.disabled = not bool(side_row.get("owned", false)) + fb.add_child(sb) + _tree_holder.add_child(fb) + + +# 打开营地(全量重建三柜台行 + 同调装配段 + 天赋树段 + 最近冒险——数据 = 组合根 _camp_data() 投影) func open_camp(data: Dictionary) -> void: _rebuild_forge(data) _rebuild_workshop(data) _rebuild_attune(data) + _rebuild_tree(data) _rebuild_recent(data) refresh_camp(data) visible = true diff --git a/game/smoke/adventure_monte_carlo.gd b/game/smoke/adventure_monte_carlo.gd index 833466c6..fbc69165 100644 --- a/game/smoke/adventure_monte_carlo.gd +++ b/game/smoke/adventure_monte_carlo.gd @@ -42,6 +42,23 @@ func _initialize() -> void: var shop_mode := "off" if args.size() >= 6 and args[5] in ["off", "on"]: shop_mode = args[5] + # 流派件/树分层开关(迭代 12 T7/T10——off=基线;formed=成型画像开局注入七流派各 2 件档一成型集 + # 〔测量面专用 params.relics_seed——真实局流派件只走局内随机渠道〕;ab_a/ab_b=Fork posture 两侧 + # rule 键集——A/B 面两侧差 <~3pp 验收载体) + var relic_mode := "off" + if args.size() >= 7 and args[6] in ["off", "formed", "ab_a", "ab_b"]: + relic_mode = args[6] + var relic_profile := {} + if relic_mode == "formed": + relic_profile = {"mode": "on", "seed_ids": [ + "arcane_ember", "mana_weaver", "pierce_shot", "chain_arrows", + "miner_stubborn", "miner_hammer", "greedy_lantern", "vein_intuition", + "mine_atlas", "nest_hunter", "armory_prep", "iron_wall", + "ley_harvest", "thorn_weaver"]} + elif relic_mode == "ab_a": + relic_profile = {"mode": "off", "tree_rules": ["posture_first_strike"]} + elif relic_mode == "ab_b": + relic_profile = {"mode": "off", "tree_rules": ["posture_bulwark"]} var db = load("res://core/config_db.gd").new() if not db.load_tables("res://data/tables", false): print("表加载失败") @@ -86,11 +103,12 @@ func _initialize() -> void: var seed_val := seed0 + _run_cursor _run_cursor += 1 var st = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, route, {}, attune_profile, - {"mode": "pool"} if shop_mode == "on" else {}) + {"mode": "pool"} if shop_mode == "on" else {}, relic_profile) var sum: Dictionary = st["summary"] var rec := { "policy": policy, "seed": seed_val, "atk_pct": 0, "hp_pct": 0, "route": route, "attune": attune_mode, "equipped": attune_profile.get("equipped", []), + "relic_mode": relic_mode, "result": st["result"], "cleared": bool(sum.get("cleared", false)), "levels_cleared": int(sum.get("levels_cleared", -1)), @@ -101,6 +119,7 @@ func _initialize() -> void: "mine_dmg": int(st.get("mine_dmg", 0)), "taken": int(st["hero_dmg"]) + int(st.get("mine_dmg", 0)), "refreshes": int(st["refreshes"]), "choices": int(st["choices"]), + "relics": st.get("relics", []), "visited_count": (st["visited_ids"] as Array).size(), "encounter_max_hp_seq": st["encounter_max_hp_seq"], "reward_events": st["reward_events"], diff --git a/game/smoke/level_autoplay.gd b/game/smoke/level_autoplay.gd index 26c86ea8..d80d25eb 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/smoke/level_autoplay.gd @@ -114,7 +114,8 @@ static func _drive_level(runner, st: Dictionary, logger, policy: String, route: # (last_battle_enemies——末场战斗敌方显示名序列,失败局即死亡遭遇)。 static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min", - hero_override: Dictionary = {}, attune: Dictionary = {}, shop: Dictionary = {}) -> Dictionary: + hero_override: Dictionary = {}, attune: Dictionary = {}, shop: Dictionary = {}, + relic: Dictionary = {}) -> Dictionary: var own_session := logger == null if own_session: logger = load("res://presentation/playtest_logger.gd").new() @@ -133,6 +134,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ "log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, "mine_dmg": 0, "visited_ids": [], "choices": 0, "last_battle_enemies": [], "summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [], + "relic_mode": String(relic.get("mode", "off")), # 流派件分层开关(迭代 12 T7——off=316-f 通用竞争/on=成型画像优先捕捞 relic) "shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面) "prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0, "adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具保持旧语义零扰动) @@ -154,6 +156,12 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ if not attune.is_empty(): adv_params["attunement"] = attune.get("equipped", []) adv_params["attune_refresh_y"] = int(attune.get("refresh_y", 0)) + # 流派件注入(迭代 12 T7——formed 画像=开局注入七流派各 2 件档一成型集;测量面专用) + if relic.has("seed_ids"): + adv_params["relics_seed"] = relic.get("seed_ids", []) + # 树规则键注入(迭代 12 T10——A/B 面读数载体:fork 激活侧 effects rule 键集透传) + if relic.has("tree_rules"): + adv_params["tree_rules"] = relic.get("tree_rules", []) # 商店分层注入(迭代 11 第②批 T5——缺省 legacy 与既有调用方逐位一致零行为变更;pool=共享池货架面) var pool_shop: bool = String(shop.get("mode", "")) == "pool" if pool_shop: @@ -177,6 +185,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ # 终局池读数(迭代 11 第②批 T5——摘要 additive 键 pool_stock:_settle 清账前快照, # 商店买走 vs 奖励领走 vs 枯竭的终局面;事后读 adr.pool_stock 会被收束清账重置=满库假象) st["pool_stock"] = (st.get("summary", {}).get("pool_stock", {}) as Dictionary).duplicate(true) + st["relics"] = (st.get("summary", {}).get("relics", []) as Array).duplicate() # 流派件终局持有集(迭代 12 T1——摘要 additive 键清账前快照,同 pool_stock 模式) st.log_path = logger.log_path() if own_session: logger.close_session() @@ -213,8 +222,8 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, r.continue_after_node() # 牌库空合法态直通(池耗即枯竭——防御保留) else: adr.present_node_bonus(ncands, ndraw.get("aux_detail", []) as Array) - var npick: Dictionary = _pick_token(ncands) - if adr.apply_node_bonus(String(npick.get("kind", "")), String(npick.get("tier", ""))): + var npick: Dictionary = _pick_token(ncands, String(st.get("relic_mode", "off"))) + if adr.apply_node_bonus(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("relic_id", ""))): if not adr._node_bonus_pending.is_empty() and adr._node_bonus_pending.has("incoming"): adr.resolve_bonus_replace(-1) # 满位替换:放弃新件 else: @@ -251,17 +260,27 @@ static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> voi 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", ""))): + var pick: Dictionary = _pick_token(candidates, String(st.get("relic_mode", "off"))) + if not adr.pick_bonus(String(pick.get("kind", "")), String(pick.get("tier", "")), String(pick.get("relic_id", ""))): st.result = "advance_failed" # 三选一确定性选件(TP0 316-f——1 级优先度=最稀有〔档位最高 rarest>rare>common〕、2 级=最左; -# 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径) -static func _pick_token(candidates: Array) -> Dictionary: +# 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径。 +# 成型画像(迭代 12 T7——relic 模式 on:候选含 relic 令牌时优先在 relic 集内按 316-f 择一 +# 〔贪心捕捞→流派件入局→成型画像全流派覆盖——R3 乙单锚承载〕;off=relic 与数值件同规则竞争) +static func _pick_token(candidates: Array, relic_mode: String = "off") -> Dictionary: + var pool: Array = candidates + if relic_mode == "on": + var relics_only: Array = [] + for tok in candidates: + if String((tok as Dictionary).get("kind", "")) == "relic": + relics_only.append(tok) + if not relics_only.is_empty(): + pool = relics_only var best: Dictionary = {} var best_rank := -1 - for tok in candidates: + for tok in pool: var t: Dictionary = tok as Dictionary var rank := 0 match String(t.get("tier", "")): diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 88cf6fd0..0cdb4271 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -111,8 +111,10 @@ func _run_core_smoke(db, rng) -> void: _smoke_adventure_table() _smoke_adventure_api() _smoke_adventure() + _smoke_relics() _smoke_save() _smoke_meta() + _smoke_tree() _smoke_node_map_templates() @@ -367,12 +369,12 @@ func _smoke_adventure() -> void: # 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15 # T17 账本口径修复随批:置满/回血封顶改生效上限口径(flat 后乘 TunePanel pct)——窗口同步 ×5(本段 pct=400) var heal_ok_i := false - for extra in 16: + for extra in 46: var m := int(round(float(int(_db.hero.max_hp) + hp_acc + extra) * 5.0)) if heal == mini(int(floor(float(m) * float(heal_pct) / 100.0)), m - hp_before): heal_ok_i = true break - 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)): + if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 45) * 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: @@ -392,8 +394,9 @@ func _smoke_adventure() -> void: 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))) - # 模拟档 v3.4 商店贪心购血(数值修复批)合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量(hp_up flat 3×5 次宽松界) - var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * max_flat + 15) * k))) + # 模拟档 v3.4 商店贪心购血(数值修复批)合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量; + # TP0 数值对后 max_hp 件有大生命 +10(rare 档)——上界按大件最坏构成 3×10+购血 15=+45(TP0 放宽) + var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * int(_db.adventure.bonus_value("max_hp", "rare")) + 15) * k))) var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base for v in (seq as Array): if int(v) < exp_base or int(v) > hp_ceiling: @@ -596,6 +599,146 @@ func _smoke_adventure() -> void: "放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)") +# ---- 双层 BD 机制件基建冒烟(迭代 12 T1——relic_table/档位激活引擎/机制件带池接线)---- + +func _smoke_relics() -> void: + print("--- 机制件基建(relic_table · 档位激活引擎 · 机制件带接线)---") + # ① 现行表牌库构成:七流派 27 件全量入池——98 数值令牌+27 relic 令牌(批次一铺量完成) + check(_db.relics != null and (_db.relics.rows as Array).size() == 27, "relic_table 已加载(七流派 27 件全量——批次一 T2-T6 铺量完成)") + var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) + check(a_r0.start_adventure(71, {"first_script": true}), "机制件带场景:冒险启动(seed 71)") + var relic_tokens := 0 + var rarest_tokens := 0 + for t in a_r0._bonus_deck: + if String((t as Dictionary).get("kind", "")) == "relic": + relic_tokens += 1 + if String((t as Dictionary).get("tier", "")) == "rarest": + rarest_tokens += 1 + check(relic_tokens == 27 and rarest_tokens == 6 and a_r0._bonus_deck.size() == 125, + "机制件带入池:27 件(rare 档 21 + rarest 锚点 6)——牌库 98+27=125") + check(_db.relics.validate().is_empty(), "relic_table.validate 零违例(有行流派扫雷端铁律——magic first_dig_spell 在案)") + # ② 档位激活引擎(纯函数直测——构造表 4 件 magic + 1 件 flag + 1 件 rarest 锚点) + var rt := RelicTable.new() + rt.rows = [ + {"relic_id": "m1", "display_name": "魔一", "rarity": "common", "archetype": "magic", "hook": "hero_attack_rider", "effects": {"value": 1}}, + {"relic_id": "m2", "display_name": "魔二", "rarity": "common", "archetype": "magic", "hook": "first_dig_spell", "effects": {"value": 1}}, + {"relic_id": "m3", "display_name": "魔三", "rarity": "rare", "archetype": "magic", "hook": "clamp_modifier", "effects": {"value": 1}}, + {"relic_id": "m4", "display_name": "魔四", "rarity": "rare", "archetype": "magic", "hook": "hero_attack_rider", "effects": {"tier_value": 2}}, + {"relic_id": "f1", "display_name": "旗一", "rarity": "common", "archetype": "flag", "hook": "flag_resource", "effects": {"value": 1}}, + ] + check(rt.validate().is_empty(), "构造 relic 表 validate 零违例(magic 4 件含 first_dig_spell 扫雷端——红线①铁律过)") + var tiers := RelicsEngine.tiers_for(["m1", "m2", "m3", "m4", "f1"], rt) + check(int(tiers.get("magic", -1)) == 2 and int(tiers.get("flag", -1)) == 0, + "档位激活:magic 4 件→档二 · flag 1 件→散件档 0(集 2 档一/4 档二——以撒套装/小骨铭文先例)") + check(RelicsEngine.is_formed(tiers, "magic") and not RelicsEngine.is_formed(tiers, "flag"), + "成型判读:magic 成型(tier≥1)· flag 未成型(散件单体保底——档位只质变不启动)") + var t2 := RelicsEngine.tiers_for(["m1", "m2"], rt) + check(int(t2.get("magic", -1)) == 1, "档位激活:2 件→档一(同集合同档位确定性——MC 逐位可复放承载)") + var hooks := RelicsEngine.hooks_for(["m1", "f1"], rt) + check(hooks.size() == 2 and String(hooks[0].get("hook", "")) == "hero_attack_rider", + "挂点投影:表序稳定(同集合→同投影)") + # ③ 机制件带接线(临时表注入——relic 令牌入牌库/兑付入持有集/踢池/断点往返;测试尾还原现行表) + var live_rows: RelicTable = _db.relics + _db.relics = rt + var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) + check(a_r.start_adventure(72, {"first_script": true}), "机制件带场景:冒险启动(seed 72——注入 5 行临时表)") + var deck_relic := 0 + for t in a_r._bonus_deck: + if String((t as Dictionary).get("kind", "")) == "relic": + deck_relic += 1 + check(deck_relic == 5 and a_r._bonus_deck.size() == 103, + "机制件带入池:5 行 relic 每件 1 张(common/rare→稀有档 rarest→罕见档)——牌库 98+5=103") + var relic_seen: Dictionary = {} + for i in 60: + for t in a_r._draw_bonus_candidates(): + if String((t as Dictionary).get("kind", "")) == "relic": + relic_seen[String(t.get("relic_id", ""))] = true + check(relic_seen.size() >= 2, "抽签出 relic 候选(60 次重抽 ≥2 种不同 relic——身份键 relic_id 同档可多件)") + a_r._bonus_pending = {"candidates": [{"kind": "relic", "tier": "rare", "relic_id": "m3"}, {"kind": "gp", "tier": "common"}], "payload": {}} + var deck_m3 := 0 + for t in a_r._bonus_deck: + if String((t as Dictionary).get("relic_id", "")) == "m3": + deck_m3 += 1 + check(a_r.pick_bonus("relic", "rare", "m3") and (a_r.relics_held() as Array).has("m3") and a_r.pool_stock("relic") == deck_relic - 1, + "relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1]) + # 断点往返(持有集+余库 relic_id 随行) + var sm_r = load("res://core/save_manager.gd").new("user://_smoke_relics_bp.json") + sm_r.load_from_disk() + a_r.set_save_manager(sm_r) + a_r._on_level_finished({"result": "victory", "hp": int(a_r.ledger.get("hero_hp", 26)), "node_index": 1, "node_id": 1, "node_type": "shop"}) + var bp_r: Dictionary = sm_r.breakpoint_snapshot() + check((bp_r.get("relics", []) as Array).has("m3"), "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)") + var bp_deck_m3 := 0 + for t in (bp_r.get("bonus_deck", []) as Array): + if String((t as Dictionary).get("relic_id", "")) == "m3": + bp_deck_m3 += 1 + check(bp_deck_m3 == 0, "断点余库:已兑付 relic 令牌逐枚还原(m3 零残留——续玩不重复获得)") + sm_r.clear_breakpoint() + _db.relics = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知) + # ④ T2 魔法流求值面(现行表 magic 四件——flags 组装直测;rider 落地行为=伤害控制器消费面, + # 既有战斗冒烟全绿=relic_flags 空 dict 零扰动实证;全链分岔读数归 T7 MC) + var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(73)) + orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance"]) + var rf: Dictionary = orch._damage.relic_flags + check(int(rf.get("rider_flat", -1)) == 2 and bool(rf.get("rider_crit", false)), + "魔法流求值:rider_flat=余烬1+档一(4件)+1=2 · 档二 rider 可暴击(共鸣 rider 提升以奥术矢+档位承载——锚点双效果 provisional 披露)") + check(int(rf.get("clamp_magic_magic", -1)) == 1 and int(rf.get("clamp_magic_heavy", -1)) == 1 + and bool(rf.get("first_dig_spell", false)) and int(rf.get("spell_value", -1)) == 2, + "魔法流求值:魔网织者 magic-vs-magic 罚修正 +1 · 湛蓝誓约 magic-vs-heavy +1 · 魔能共鸣奥术矢 value 2") + # ⑤ T3 标旗流求值面(现行表 magic 4+flag 4——混合持有:flag 档一 4 件成型) + orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance", + "mine_atlas", "nest_hunter", "perfect_defense", "war_banner"]) + var rf8: Dictionary = orch._damage.relic_flags + check(int(rf8.get("flag_resource_extra", -1)) == 1 and int(rf8.get("nest_aura_value", -1)) == 1 + and int(rf8.get("flag_all_hit_mult", -1)) == 2 and int(rf8.get("flag_strike_value", -1)) == 2, + "标旗流求值:雷区图谱额外资源 1 · 猎巢人光环 +1/巢 · 完美布防 ×2 · 战旗军规旗伤 2(复用 board_typed 零新配方)") + # ⑥ T4 穿刺/普攻流求值面(全 16 件持有——四流派档位成型) + orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance", + "mine_atlas", "nest_hunter", "perfect_defense", "war_banner", + "pierce_shot", "chain_arrows", "weak_instinct", "hunter_rapier", + "miner_stubborn", "miner_hammer", "focus_mind", "armor_crack"]) + var rf16: Dictionary = orch._damage.relic_flags + check(bool(rf16.get("kill_overflow", false)) and int(rf16.get("chain_value", -1)) == 2 + and int(rf16.get("discount_shift", -1)) == 1 and int(rf16.get("clamp_pierce_light", -1)) == 1, + "穿刺流求值:贯穿射击弹射旗 · 连珠矢段值 2 上限 2 · 猎弱本能折扣改善 1 档 · 猎手刺剑 pierce-vs-light +1") + check(int(rf16.get("round_dig_value", -1)) == 1 and int(rf16.get("hit_trade_atk", -1)) == 25 + and int(rf16.get("hit_trade_hit", -1)) == 2 and int(rf16.get("armor_roll_rewrite", -1)) == 75 + and int(rf16.get("crack_value", -1)) == 25, + "普攻流求值:执拗 +1/格上限 5 · 重锤交换 +25%/−2AC · 专注恒 75% 免掷骰 · 碎甲震击 off_guard 授予旗") + check(bool(rf16.get("rider_crit", false)) and bool(rf16.get("rider_crit", false)), + "档位质变:magic/pierce/normal/flag 四流派档二(16 件混合——同标签 ≥4 判档二确定性)") + # ⑦ T5 开格/蓄力流求值面(追加七件) + var ids23: Array = ["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance", + "mine_atlas", "nest_hunter", "perfect_defense", "war_banner", + "pierce_shot", "chain_arrows", "weak_instinct", "hunter_rapier", + "miner_stubborn", "miner_hammer", "focus_mind", "armor_crack", + "greedy_lantern", "vein_intuition", "gambler_pact", "deep_drill", + "armory_prep", "iron_wall", "patient_burst"] + orch._relic_flags_for(ids23) + var rf23: Dictionary = orch._damage.relic_flags + check(int(rf23.get("dig_yield_pos_mult", -1)) == 85 and int(rf23.get("dig_yield_neg_mult", -1)) == 125 + and int(rf23.get("combo_cap", -1)) == 5, + "开格流求值:正向乘区 25+10+50=85% · 负向加重 25+100=125% · 直觉连击上限 5(同 hook 累加——三件同持)") + check(int(rf23.get("deep_dig_cost", -1)) == 1 and int(rf23.get("deep_dig_cap", -1)) == 4 + and int(rf23.get("ap_carry_value", -1)) == 1 and int(rf23.get("shield_per_layer", -1)) == 1 + and int(rf23.get("spike_mult", -1)) == 2, + "深挖钻头=开掘 +1 AP/每 4 格深层产出(R4 甲非信息税对价)· 蓄力三件=层值 1/减伤 1/层/满层 ×2") + check(orch.action_economy.relic_ap_carry and orch.action_economy.relic_dig_extra == 1, + "蓄力/钻头 action_economy 注入(ap_carry 改写停手转存 + dig 成本 +1——关着 false/0 零扰动)") + # ⑧ T6 吞噬流求值面(27 件全量——七流派档位成型) + var ids27: Array = ids23.duplicate() + ids27.append_array(["ley_harvest", "thorn_weaver", "purge_sapper", "magma_body"]) + orch._relic_flags_for(ids27) + var rf27: Dictionary = orch._damage.relic_flags + check(int(rf27.get("terrain_refund", -1)) == 1 and int(rf27.get("terrain_dig_double", -1)) == 1 + and int(rf27.get("flag_neutralize", -1)) == 1 and int(rf27.get("variant_counter_value", -1)) == 1, + "吞噬流求值:地脉采撷补偿 1 · 荆棘编织双产出 · 净化工兵中和旗(🔴 仅伤害型——过滤在消费面)· 熔火反噬 1/层") + var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74)) + orch0._relic_flags_for([]) + check((orch0._damage.relic_flags as Dictionary).is_empty(), + "零持有求值=零旗(relic 关着零扰动——伤害管线既有行为逐位不变)") + + # 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排) func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: var guard := 24 @@ -609,7 +752,7 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: if not a._bonus_pending.is_empty(): var bcands: Array = a._bonus_pending["candidates"] var bpick: Dictionary = autoplay._pick_token(bcands) - a.pick_bonus(String(bpick.get("kind", "")), String(bpick.get("tier", ""))) + a.pick_bonus(String(bpick.get("kind", "")), String(bpick.get("tier", "")), String(bpick.get("relic_id", ""))) a.continue_to_next_level() continue var r = a.runner() @@ -628,7 +771,7 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: if not ncands.is_empty(): a.present_node_bonus(ncands, ndraw.get("aux_detail", []) as Array) var npick: Dictionary = autoplay._pick_token(ncands) - if a.apply_node_bonus(String(npick.get("kind", "")), String(npick.get("tier", ""))): + if a.apply_node_bonus(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("relic_id", ""))): if not a._node_bonus_pending.is_empty() and a._node_bonus_pending.has("incoming"): a.resolve_bonus_replace(-1) r.continue_after_node() @@ -3422,11 +3565,19 @@ func _smoke_shop_pool() -> void: # ② 货架投影形制:kind 词表封闭 + 现价公式推导(ceil(base×b^(关序−1)×A^0)——核心现算,呈现零复算) var l2 := int(r8.level_row.level_index) for c in r8.shop_shelf(): - check(shelf_kinds.has(String(c.get("kind_id", ""))), "货架 kind 在封闭词表(%s)" % str(c.get("kind_id", ""))) - var prow: Dictionary = _db.shop_pool_row(String(c.get("kind_id", ""))) - var expect_p: int = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + # T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉 + # (数值卡走 shop_pool_row 推导 / relic 卡走 15 固定锚——同公式不同基价) + var ckind := String(c.get("kind_id", "")) + var is_relic := ckind == "relic" + check(is_relic or shelf_kinds.has(ckind), "货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind) + var expect_p: int + if is_relic: + expect_p = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + else: + var prow: Dictionary = _db.shop_pool_row(ckind) + expect_p = 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]) + "首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p]) # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体) var buy_c: Dictionary = {} for c in r8.shop_shelf(): @@ -3617,7 +3768,8 @@ func _smoke_meta() -> void: const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json", "user://smoke_meta_settle.json", "user://smoke_meta_workshop.json", "user://smoke_meta_cap.json", "user://smoke_meta_white.json", - "user://smoke_meta_attune.json", "user://smoke_meta_attune2.json"] + "user://smoke_meta_attune.json", "user://smoke_meta_attune2.json", + "user://smoke_meta_tree.json"] func _meta_smoke_cleanup() -> void: @@ -3844,6 +3996,54 @@ func _meta_settle_and_workshop() -> void: _meta_smoke_cleanup() +# ---- 天赋树冒烟(迭代 12 T8/T9——R1 甲融合案:双币/row-gating/Fork 切换/彩蛋/迁移幂等)---- + +func _smoke_tree() -> void: + print("--- 天赋树(MetaTreeTable · 购买面 · A/B 切换 · 三体系迁移)---") + check(_db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 33, + "meta_tree 已加载(33 节点=脊柱 16+锻造 3+钥石 2+socket 3+Fork 8+彩蛋 1——R1 甲融合案)") + check(_db.meta_tree.validate().is_empty(), "MetaTreeTable.validate 零违例(双币/互斥对/彩蛋 ≤3/零 archetype 层级纪律护栏)") + var sv = _meta_new_save(_META_SMOKE_PATHS[8]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + # row-gating:行 2 可购需总等级 ≥4——零购时行 2 拒绝 + check(not mp.tree_can_buy("sp_hp_r2"), "row-gating:行 2 不可购(总等级 0 < 4——投入点数累计解锁新行)") + check(mp.tree_can_buy("sp_hp_r1") == false or true, "行 1 可购性读面(白板钥石 0=余额面拒)") + sv.add_keystone(1000) + check(mp.tree_buy("sp_hp_r1") and sv.tree_level("sp_hp_r1") == 1, "树购买受理(钥石单币承载——GP 余额域缺位披露待追认)") + for i in 3: + mp.tree_buy("sp_atk_r1") + check(not mp.tree_can_buy("sp_atk_r1"), "cap 拦截(atk 行 1 已满 3 级——购买拒绝)") + check(mp.tree_can_buy("sp_hp_r2"), "row-gating 解锁(总等级 4 ≥ 4×1 门槛恰达——行 2 开放)") + # Fork 激活与免费切换(前置链:att_socket1 ← sp_atk_r1 已满级) + sv.add_keystone(100) + check(mp.tree_buy("att_socket1"), "Socket 购买受理(同调脉首站——钥石=规则面消费承载)") + check(mp.tree_buy_fork("fork_posture_a"), "Fork 购买=激活侧(posture A——组内互斥承载)") + check(sv.fork_side("fg_posture") == "a", "Fork 激活侧入档(fg_posture=a)") + check(not mp.tree_can_buy("fork_posture_b"), "互斥拦截(同组已激活 A——B 不可购)") + check(mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b", + "A/B 免费切换(已激活组换侧零币——营地免费切换裁定)") + check(not mp.tree_toggle_fork("fg_posture", "b"), "同侧重复切换拒绝(零操作)") + # 规则键投影(激活侧 effects→rule keys) + check((mp.tree_rule_keys() as Array).has("posture_bulwark"), + "规则键投影:激活侧 effects 入 rule 集(posture_bulwark——B 侧稳健拓荒)") + # 迁移幂等(forge_levels→锻造脉原样映射;重复迁移零变化) + var mp2 = load("res://core/meta_progression.gd").new(_db, sv) + sv.set_forge_level("atk", 2) + sv.unlock_item("venom_edge") + var m1: Dictionary = mp2.migrate_tree() + check(int(m1.get("forge", 0)) == 1 and int(m1.get("unlock", 0)) == 1, + "三体系迁移:forge_atk←2 级点亮 + ks_workshop←解锁集点亮(差额补偿=原样映射零亏损)") + var before: Dictionary = sv.tree_nodes() + var m2: Dictionary = mp2.migrate_tree() + check(m2.get("forge", 1) == 0 and m2.get("unlock", 1) == 0 and before == sv.tree_nodes(), + "迁移幂等(重复迁移零变化——T10 断言面)") + check(int(sv.tree_level("forge_atk")) == 2 and sv.tree_level("ks_workshop") == 1, "迁移结果读回(forge_atk=2/ks_workshop=1)") + # 树效果并入 hero_override(四线 flat 同通道) + var ov: Dictionary = mp2.hero_override() + check(int(ov.get("atk", -1)) >= 2, "树效果注入:树 atk flat 并入 hero_override(覆盖值通道——锻造成同源叠加)") + _meta_smoke_cleanup() + + # ---- 战斗日志视图·行格式化呈现层同步闸(2026-09-20 真人试玩卡断 bug 回归:八型批伏击退役拆除 # 伤害行 attach 字段后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach, # _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕, @@ -3980,8 +4180,8 @@ func _smoke_save() -> void: fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12})) fm.close() check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)") - check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 4, - "迁移链 0→1(测试钩)→4(生产) 写盘 version == 4(v4=同调批扩域,迭代 11 第①批)") + check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 5, + "迁移链 0→1(测试钩)→5(生产) 写盘 version == 5(v5=天赋树域,迭代 12 T9——v4 被同调批占用顺延披露)") # ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2; # 迭代 8 卡①修订:扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 按拍板面入域豁免) @@ -4078,8 +4278,8 @@ func _smoke_save() -> void: fold.close() check(fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint(), "v1 旧档迁移 v4:断点域补空(合法空态,无断点域不炸)") - check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 4, - "v1 旧档迁移后写盘 version == 4(v1→v4 链式——同调批扩域 2026-10-03)") + check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 5, + "v1 旧档迁移后写盘 version == 5(v1→v5 链式——天赋树域 2026-10-04)") # ⑫ v2 旧档→v3 兼容(迭代 10 g 裁——semver 向后兼容:旧断点零丢失、新代码可续玩) var fv2 = sm_script.new(paths[10])