From b206120b8e115370e16c8e87b6b68c3613f8c3f6 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Mon, 19 Oct 2026 19:14:20 +0800 Subject: [PATCH] =?UTF-8?q?docs(research):=20=E6=95=88=E6=9E=9C=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E6=97=A0=E7=8A=B6=E6=80=81=E5=8C=96=E6=8F=90=E6=A1=88?= =?UTF-8?q?=20v1=E2=80=94=E2=80=94=E8=AE=A1=E6=95=B0=E5=99=A8=E5=9B=9B?= =?UTF-8?q?=E6=A1=B6=E5=A4=96=E7=BD=AE=C2=B7=E6=90=AC=E8=BF=90=E9=93=BE?= =?UTF-8?q?=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:EffectEngine 持运行时账本 _counters(effect_engine.gd:87)被判违反 ECS「系统无状态」原则。目标形态=引擎退化为纯计算引擎,计数器按 round/battle/level/adventure 四桶外置、由各生命周期宿主持有并随宿主生灭, 事件触发时经 ctx 传入;counters_export/seed 搬运链整段删除。 本文件为提案草案(非需求卡片),经 2026-10-19 三轮 code-level 复核: §2 现状实查(行号全部实核:effect_engine.gd 五行 +3 偏移系迭代 31 卡② forgive_seal_miss 扩签插入三行;硬编码清除名单在 adventure_runner.gd:840-842; 断点写键 :1035;现役 distinct counter_id 共 16 枚非 13;写口 6 处非 5, 漏 grant_segment.gd:327 即卡②保险记账唯一通路;读口 5 处非 2) §3 目标形态:四桶宿主(LevelRunner/BattleOrchestrator×2/RunLedger)+ id 前缀归属(round_/lvl_/adv_/无前缀=meta_非法)零运行时机制 §7 N1–N6 全部由既有约束推得定值(非裁决单),采集问题按 sim-data 配套 纪律⑦「零数值改动=零 MC」定不发起 §7.8 run_ledger.gd:42 判空早退缺陷全解:自锁(全新开局连玩则 adventure 域 四枚整局不落账,唯一解锁=断点回填 :206-208);git log -S 实证该守卫由 6fd7d782 迭代 27 T3 逐字搬入,旧形态下合理(判共享引用有效性),收口后 判据与被防风险脱钩 §4.1 验收测试清单 13 条(机制正向验收为主力) §4.2 载体可达性核验:仅 C2/E1E2 闭合,余半闭合或未闭合 §4.3 未闭合项 U1–U6 §4.4 实现设计缺口 A1–A8(A1 ctx 结构/A2 桶接口/A3 树合成行漏前缀校验为阻塞级) §4.5 需求澄清未对齐 B1–B6 状态:设计未闭合,尚不可写需求卡片。 已知行为变更(修后):twin_digs 真跨战累积;tree_sense 锋/守由「每战限 2 次」修回「每关限 2 次」=显著削弱(一关 4–5 战,理论上限比 4–5:1),系回到 设计意图(effect_engine.gd:140 注释),不构成数值改动。 反哺点(本批为文档产物,不落 design/):①architecture.md 缺「系统无状态」 原则条目(ECS 依据目前仅在 2026-10-19 会话裁定中);②06-待办清单:627 「计数器寿命域未统一」解法方向被本提案推翻;③entities.yaml counter_id 前缀 规则;④tree_sense 削弱登记 07 目检;⑤roadmap 当前位 stale(仍写迭代 31 待开工,卡②已合入)。 范围:代码+文档,不含美术。零待裁点(A1/A2/A3/A6/A7 由本轮自行补齐)。 --- .../提案-效果引擎无状态化-2026-10-19.md | 541 ++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 design/research/提案-效果引擎无状态化-2026-10-19.md diff --git a/design/research/提案-效果引擎无状态化-2026-10-19.md b/design/research/提案-效果引擎无状态化-2026-10-19.md new file mode 100644 index 00000000..3edb9800 --- /dev/null +++ b/design/research/提案-效果引擎无状态化-2026-10-19.md @@ -0,0 +1,541 @@ +# 提案:效果引擎无状态化(计数器外置·分桶) + +- **日期**:2026-10-19 +- **性质**:架构重构提案(restructure,非 refactor)——现役设计被判为错误形态,不是打磨 +- **上游**:`design/gdd/06-待办清单.md:627`「计数器寿命域未统一」条目(2026-10-18 登记);本提案推翻该条目的解法方向,见 §8 +- **状态**:**草案·设计未闭合**——§7 设计已定值、§7.7 采集定不发起,但 §4.2–§4.5 核验出 3 项阻塞级实现设计缺口(A1 ctx 结构/A2 桶接口/A3 树合成行漏校验)+ 5 项次级缺口 + 6 项需求澄清未对齐,**尚不可写卡** +- **范围**:代码 + 文档,**不含美术** +- **关联已定项**:迭代 31 卡①代码术语刷新批 / 卡②凭证件裁削批(完美保险按现役 `domain=battle` 落地)——本提案若先落地,卡②的专长行需同步改形 + +--- + +## 1. 目标与完成定义 + +**目标**:把 `EffectEngine` 从「解释器 + 自带状态账本」改为「**纯计算引擎(零运行时状态)**」;所有效果侧的值改由**各生命周期的宿主对象**持有,事件触发时按参数传入,引擎只做判定。 + +**完成定义**(全部满足才算完): + +1. `EffectEngine` 实例内**无任何运行时可变数据**(仅剩编译产物 `_triggers` 与只读依赖 `db`); +2. 计数器按 round / battle / level / adventure 四桶外置,各桶由宿主对象持有,桶随宿主生灭; +3. `counters_export` / `counters_seed` 两条搬运链**整段删除**,跨战持久不再靠副本同步; +4. `adventure_runner.gd:840-842` 硬编码清除名单删除,改为「关卡桶整体清空」; +5. 计数器归属机器可读且加载期可校验(写错即加载致命); +6. **同种子复放对拍**与改前一致(确定性零漂移)。对拍=**同种子重放已有模拟锚、零局新采集**(`sim-data/README.md` 配套纪律③:对拍断言 = 0 新跑,出口闸复验用锚内子集 ≤20)。🔴 **2026-10-19 复核:本条与「缺陷自动消解」互斥**——分桶会消解 `run_ledger.gd:42` 判空早退(自锁,实际是**整局**不落账,非仅首战),adventure 域四枚从「每战清零」变「跨战持久」,是行为变更 ⇒ 对拍中相关项必然出现差异。最终口径取决于 §7.7 待裁项 B。 + +> **用语订正(2026-10-19)**:草案前几版对读者直用「MC」缩写而未展开,违反 doc-conventions 第 4 节用语红线。**「MC」=蒙特卡洛(Monte Carlo)**,在本项目特指「跑大量随机冒险的模拟试玩」,入口 `res://smoke/adventure_monte_carlo.gd`(全冒险期望),产物缓存层=`sim-data/`(该目录 README 首行即「MC 缓存层」)。虽系项目既有简称(`AGENTS.md:34/43/62` 全篇在用),但**首次出现必须展开**,本草案后续一律写「全冒险模拟(蒙特卡洛)」。 + +--- + +## 2. 现状(代码级事实) + +### 2.1 引擎持有的可变成员 + +`game/core/effect_engine.gd:86-91` 共五个(🔴 草案初版行号整体 +3 偏移——因迭代 31 卡② `forgive_seal_miss` 扩签在 `:42-44` 插入三行注释;2026-10-19 复核订正): + +| 成员 | 行 | 性质 | 处置 | +|---|---|---|---| +| `_triggers` | :86 | 编译产物(装载期定死) | **保留**(=程序,非状态) | +| `_counters` | :87 | **运行时账本**(counter_id→int,混装四档寿命) | **搬走** | +| `_reset_on_round_start` | :88 | 编译产物(装载期收集) | 随分桶删除 | +| `_adventure_counter_ids` | :89 | 编译产物(装载期收集) | 随分桶删除 | +| `db` | :91 | 只读依赖 | 保留 | + +### 2.2 现役引擎接口与调用点 + +| 接口 | 行 | 调用点 | +|---|---|---| +| `rebuild()` | :101 | `battle_orchestrator.gd:392`、`smoke_all.gd:1612` | +| `dispatch()` | :211 | `battle_orchestrator.gd:93`、`action_economy.gd:105/168/195`、`damage_controller.gd:78`、`reward_economy.gd:65` | +| `counter()` | :227 | `when: fact counter`(:328)、`gte_ref`/`lt_ref`(:318/:320)、`battle_orchestrator.gd:641`、`damage_controller.gd:345`、`grant_segment.gd:103/205` | +| `counter_add()` | :231 | `action_economy.gd:110/113/173`、`damage_controller.gd:351/464`、**`grant_segment.gd:327`** | +| `counter_spend()` | :240 | `battle_orchestrator.gd:717` | +| `counters_export()` / `counters_seed()` | :249 / :258 | `battle_orchestrator.gd:1127` / `:393` | +| `validate_trigger_row()` | :354(静态纯函数) | `feat_table.gd:44`、`treasure_item_table.gd:104` | + +🔴 **草案初版遗漏两处**:①第 6 个写口 `grant_segment.gd:327`(`_apply_counter_hit`——含卡② `insurance_used` 记账,见 §6-3);②三个 `counter()` 读口 `damage_controller.gd:345` / `grant_segment.gd:103` / `grant_segment.gd:205`。 + +### 2.3 宿主链与寿命的物理来源 + +- `level_runner.gd:452` `_start_battle` → `BattleOrchestrator.new` → `battle_orchestrator.gd:274` `_fx = EffectEngine.new(db_ref)`。 +- ⇒ **域内账本的物理寿命恒等于一场战斗**;所谓「跨战持久」= 战末 `counters_export` 拷进 `run_ledger.adventure_counters`、战首 `counters_seed` 拷回,**两份副本手工搬运**。 +- 搬运链的既有缺陷:`run_ledger.gd:42` `if adventure_counters.is_empty(): return` ⇒ 空账时导出被整批丢弃;因账本只有断点回填 `:206-208` 才能变非空,**形成自锁:全新开局连玩则 adventure 域四枚整局不落账**(自迭代 20 T6 即有,无整局级测试覆盖)。**本提案落地后该缺陷随宿主消失(不再有副本)**,代价=行为变更,详见 §7.8 / 待裁项 B。 +- 🔴 草案初版把「按关重置」清零点写成 `adventure_runner.gd:876-878`,实为 **`_reset_tree_limited_counters()` :840-842**(硬编码两键 `tree_sense_sharp_used` / `tree_sense_guard_used`),调用点 **`_start_current_level` :847**;新冒险置空在 **:255**(非 :258);断点写键在 **:1035**(非 :1072,该文件仅 1059 行)。 + +### 2.4 效果侧还有一批「记忆」住在编排器 + +`battle_orchestrator.gd:74` `_fx_ac_round`(轮始清零 :101)、`:76` `_fx_ac_battle`(战始清零 :371)、`:78` `_fx_new_resource_types`(战始清零 :372)——同属效果状态,是否一并归桶见 N6。 + +### 2.5 无状态化的可行性(已核) + +- `game/core/**` **零 `static var`**(无静态可变状态); +- 调度全程同步串行(`effect_engine.gd:3` 命中序=装载序),无并发,传参安全; +- `validate_trigger_row` 已是静态纯函数,形态合规。 + +--- + +## 3. 目标形态 + +### 3.1 四只桶(宿主决定寿命,不需要寿命字段) + +| 桶 | 宿主(候选,见 N1) | 清空方式 | 现役对应 | +|---|---|---|---| +| `round` | 战斗域对象 | 轮始 `= {}` | `_reset_on_round_start` 名单(4 枚:`round_blast_spent` / `instinct_atk` / `round_mines` / `round_digs`) | +| `battle` | `BattleOrchestrator`(每战 new) | 随宿主销毁 | `rebuild` 的 `_counters = {}`(8 枚:`geo_types` / `helm_layers` / `skip_pts` / `echo_seals` / `smith_seals` / `fuse_pts` / `stop_swap` / `insurance_used`) | +| `level` | 关卡域对象 | 进关 `= {}` | `adventure_runner.gd:840-842` 硬编码名单 | +| `adventure` | `run_ledger.adventure_counters`(沿用) | 新冒险置空(`adventure_runner.gd:255`) | `domain=adventure`(4 枚:`twin_digs` / `pain_layers` / `tree_sense_sharp_used` / `tree_sense_guard_used`) | + +🔴 **枚数订正**:现役 distinct counter_id **共 16 枚**(4+8+4),非草案初版的「13 枚」。13 系卡② 落地前的旧数(未含 `insurance_used`),且与草案自身分桶子项相加(4+8+4=16)自相矛盾。 + +**寿命语义不再需要任何字段**:对象活着值就活着,对象重建值自然归零。 + +### 3.2 引擎接口改动 + +| 现在 | 之后 | +|---|---| +| `dispatch(event, payload, facts)` | 增 ctx 参数(携带本战可读写账本引用集) | +| `counter()` | 改从 ctx 读(纯查询,不存) | +| `counter_add()` / `counter_spend()` | **删除**——写由发射点落到传入账本 | +| `counters_export()` / `counters_seed()` | **删除** | +| `has_effect_on()` / `validate_trigger_row()` | 保留(纯函数) | +| — | 新增纯函数 `bucket_of(counter_id)`(按前缀判桶) | + +写口迁移(**共 6 处,非 5 处**):`action_economy.gd:110/113/173`、`damage_controller.gd:351/464`、**`grant_segment.gd:327`** 由 `_fx.counter_add(...)` 改为对 ctx 账本写入;`battle_orchestrator.gd:717` 的 `counter_spend` 同理。 + +🔴 `grant_segment.gd:327` 是**卡② 完美保险的记账唯一通路**(`:315` `_apply_counter_hit(h)` 落 `insurance_used`),漏掉它=保险「每战限次一次」失效(恒 0 → 每次标错都豁免)。 + +### 3.3 归属表达(零运行时机制) + +**id 前缀即归属,加载期强校验、写错即致命**: + +| 前缀 | 桶 | +|---|---| +| `round_` | 回合桶 | +| `lvl_` | 关卡桶 | +| `adv_` | 冒险桶 | +| 无前缀 | 战斗桶(缺省) | +| `meta_` | **非法**(引擎不接存档级——存档级归 `SaveManager` meta 域) | + +现役 13 枚中 `round_blast_spent` / `round_mines` / `round_digs` 已符合;`tree_sense_*` 需改前缀或登记 `tree_`→level(改名代价见 §6)。 + +--- + +## 4. 改动清单(粗估 ≈3–5.5d) + +| T | 内容 | 估时 | +|---|---|---| +| T0 | worktree/分支;冒烟·单元·探针·三闸基线记档 | 0.25d | +| T1 | 桶承载对象新建 + 四宿主接线 | 0.5–1d | +| T2 | 引擎删状态 + `dispatch` 增 ctx + `bucket_of` | 0.5–1d | +| T3 | 施加点改写(action_economy / damage_controller / orchestrator) | 0.5–1d | +| T4 | 三处清零点就位(轮始 / 进关 / 战斗 new) | 0.25–0.5d | +| T5 | 前缀校验进 `validate_trigger_row` | 0.25d | +| T6 | 数据行与树合成行 counter_id 调整 | 0.25d | +| T7 | **机制正向验收断言新增** + 旧测试迁移 + 变异验证 + 同种子复放对拍(详见 §4.1) | 1–1.5d | +| T8 | 文档回写(architecture / 06 / README / entities.yaml) | 0.25d | + +### 4.1 验收测试清单(机制正向验收 —— 主力) + +> 🔴 **本节是草案初版的漏项(2026-10-19 补)**。原草案只有「测试迁移面」(§5:哪些旧断言要改)与「同种子复放对拍」,**把对拍当成了验收主力——这是错的**。 +> +> 对拍只能证「除已知差异外没改坏别的」(负向/兜底),**不能证新机制按设计工作**。机制改动的正向验收必须由下列断言承担。依据:`test-conventions.md` §1「存在必须证明可达且正确」/§3「缺陷修复必须沉淀回归断言」/§5 落码规范/§6 变异验证。 + +| # | 验收点 | 执行载体 | 断言词汇 | 怎么测(注入粒度) | +|---|---|---|---|---| +| **A1** | **adventure 桶跨战持久**(=`run_ledger.gd:42` 缺陷消解的**回归断言**,修前恒 0) | 冒烟 `smoke_all.gd` 冒险段 | 状态/计数器断言 | 新冒险开局 → 第 1 战末置 `adv_twin_digs=1` → 进入第 2 战首读该键 ==1 | +| **A2** | adventure 桶新冒险置空 | 冒烟 | 状态/计数器断言 | `start_adventure` 后桶 size==0 | +| **A3** | adventure 桶随断点往返 | 单测 `feat_vouchers_test.gd`(承 V16,键名随前缀改) | 快照比对 | 写断点 → 回读 → 桶内容逐位相等 | +| **B1** | level 桶**同关跨战保持、进关清零** | 冒烟 | 状态/计数器断言 | 同关第 2 战读 `lvl_tree_sense_*` 保持;进下一关后归零 | +| **B2** | **tree_sense 每关限 2 次**(削弱行为的正面确认) | 冒烟 | 状态/计数器断言 | 同关第 1 战用满 2 次 → 第 2 战 `lt 2` 不命中 → 无减伤/无返还(修前第 2 战仍有额度) | +| **C1** | battle 桶随宿主销毁 | 冒烟 | 状态/计数器断言 | 第 1 战置 `insurance_used=1`;第 2 战读 ==0 | +| **C2** | 完美保险「每战限次一次」仍成立(卡② V25 迁移保绿) | 单测 `feat_vouchers_test.gd:132+` | 状态/计数器断言 | 沿用 V25 注入口径,改走新桶 | +| **D1** | round 桶轮始清空 | 单测/冒烟 | 状态/计数器断言 | 轮始后 `round_*` 全 0 | +| **E1** | counter_id 无前缀或带 `meta_` 前缀 → **加载致命** | 单测(新增) | fail-fast | 合成一行喂 `validate_trigger_row` → 命中止/assert | +| **E2** | 同 counter_id 跨前缀重复 → 致命 | 单测(新增) | fail-fast | 同上 | +| **F1** | 旧件零残留 | 静态扫描面 | 快照比对 | 全仓 `counters_export` / `counters_seed` / `_reset_tree_limited_counters` 零命中 | +| **F2** | **旧断言同批摘除**(test-conventions §5.5 换代同生死) | — | — | `effect_words_t1_test.gd:86-94`、`smoke_all.gd:6083-6090`、`:6131-6134` 随旧件同批删除——旧件断言残留=假绿来源 | +| **G1** | 同种子复放对拍(**兜底,非主力**) | `adventure_monte_carlo` 同种子重放 | 重放 | 除已记档的已知差异外逐位一致;零局新采集(配套纪律③) | + +**变异验证必做项**(test-conventions §6——无变异红记录只写「已实现,拦截能力未证」): + +| 断言 | 变异注入点 | 期望 | +|---|---|---| +| A1 | 把 adventure 桶写入改回「判空早退」(复刻 `run_ledger.gd:42`) | A1 必须红 | +| B2 | 摘掉进关清零点(T4 产物) | B2 必须红 | +| E1 | 注释掉 `validate_trigger_row` 内的前缀校验分支 | E1 必须红 | + +> 三步闭环:注入 → 只跑覆盖该断言的最窄一道闸 → `git checkout -- <文件>` 精确还原并复跑回绿 → 逐条进实现报告变异验证记录表。 + +**真人目检登记**:tree_sense 锋/守由「每战限 2 次」变「每关限 2 次」是玩家可感知削弱 ⇒ 按「只登记不阻塞」登记进 `07-体验验证清单`,不构成收口前置。 + +### 4.2 载体可达性核验(2026-10-19 实查,逐条对 §4.1) + +> test-conventions §5:「写卡期查不出执行载体的条目=不可测,当场呈报,禁拖到实现期」。下表是逐条实查结果。 + +| 验收点 | 载体 | 核验结论 | 证据(file:line) | +|---|---|---|---| +| **C2** 保险限次一次 | 单测 `feat_vouchers_test.gd` | ✅ **已闭合**——V25 现成「第 1 战用满 → 第 2 战重新豁免」双编排器骨架;`orch2.begin_battle({"battle_index":2,…})` + `orch2._grant_seg._judge_seal(...)` 即真实 `seal_missed` | `:143-186` | +| **E1/E2** 前缀致命 | 新增负面套件 | ✅ **已闭合**——现役 fail-fast 测试模式:`config_db_fatal_oob_test.gd`(独立进程,`expect:1` + `negative:true`),manifest 协议在役 | `tests/run_all.gd:37`/`:48` | +| **F1** 旧件零残留 | `static_scan.sh` 新增 S 段 | 🟡 **半闭合**——S1–S10 分检制在位;本批新增 S 段承载,按纪律「闸件增减只改该脚本+`run_all_gates.sh` 登记」,卡里须写明改哪两个文件 | `static_scan.sh:38-82` | +| **A1/A2/A3** adventure 桶 | 冒烟/单测新段 | 🟡 **半闭合**——RunLedger 经 `enc["ledger"]` 下传(`battle_orchestrator.gd:375`),冒烟可自建共享账喂两个 orch;`AdventureRunner.run_ledger` 公开字段可达(`smoke_all.gd:6131` 先例)。**卡在 U2** | `battle_orchestrator.gd:375` | +| **C1** battle 桶 | 单测/冒烟 | 🟡 **半闭合**——编排器可 new + begin_battle(`smoke_all.gd:2680-2687` 先例);桶须为公开字段或 API,测试才读写得了 | `smoke_all.gd:2680-2687` | +| **B1/B2** level 桶 | 冒烟新段或探针 | 🟡 **半闭合**——LevelRunner 真实驱动有先例(`board_shot_probe.gd:5`);但现役 level 桶**寄居在 `run_ledger.adventure_counters` 字段内**(`adventure_runner.gd:840-842` erase 的就是该字段),搬去 LevelRunner 后须新定公开接口。**卡在 U3** | `adventure_runner.gd:840-842` | +| **D1** round 桶 | 冒烟 | 🔴 **未闭合**——`round_start` 的真实发射入口未定位 | — | +| **G1** 对拍已知差异 | `analyze.py` 字段 | 🔴 **未闭合**——RUNSUM 哪些字段会变未列 | — | + +### 4.3 写卡前未闭合项(禁拖到实现期) + +| # | 未闭合项 | 具体卡点 | +|---|---|---| +| **U1** | **A1 注入口径写错了** | 原稿写「置 `adv_twin_digs=1`」**不可行**——新形态引擎无 `counter_add` 公开写口(T2 删除),置数必须走**真实事件**(如触发一次 `dig_paid`)。这条必须重写 | +| **U2** | `_battle_over` 能否孤立调用 | 战末入口在 `battle_orchestrator.gd:1111`(私有)。直接调它会连带 despawn/映射退役/奖励结算。要确认能否被测试单独调用,或另找更窄的战末钩子 | +| **U3** | level 桶公开接口 | LevelRunner 持桶后,测试读写走什么(公开字段/getter/方法)——不定这条,B1/B2 就不可测 | +| **U4** | `round_start` 真实发射入口 | 未定位;定位不到,D1 只能走裸 `dispatch`(弱于真实路径) | +| **U5** | G1 已知差异字段清单 | 需列出 RUNSUM 中预期变化的具体字段,否则对拍红了无法区分「预期」与「意外」 | +| **U6** | B2 的 `mine_hit` 构造路径 | `seal_missed` 有 V25 的 `_judge_seal` 先例;`mine_hit` 的等价构造路径未定位 | + +**结论**:13 条验收点中 **2 条已闭合**(C2/E1E2)、**4 条半闭合**(A1A2A3/C1/B1B2/F1)、**2 条未闭合**(D1/G1),另有 6 项卡点(U1–U6)必须先解。**§4.1 现阶段不可直接落码成卡的验收清单。** + +### 4.4 实现设计缺口(2026-10-19 第三轮盘点——写卡前必须补) + +| # | 缺口 | 具体未定 | 阻塞等级 | +|---|---|---|---| +| **A1** | **`ctx` 结构未定义**(最大缺口) | `dispatch` 增的 ctx 里装什么?四桶引用怎么命名/定型?**谁构造 ctx、多久构造一次**?——若 ctx 每轮重建,round 桶天然是新的 `{}`(清零点免费),但 level/adventure 引用要每轮重挂;若 ctx 每战构造一次,round 桶的清零点要显式做。两种走法决定了 T4「三处清零点」到底是三处还是两处 | 🔴 阻塞 | +| **A2** | 桶容器类型与测试接口 | 四桶是否统一为 `Dictionary[String, int]`?level/adventure 是宿主字段、ctx 只带引用——那测试凭什么读写(公开字段/getter/方法)未定(同 U3) | 🔴 阻塞 | +| **A3** | **树合成行走不到生产侧前缀校验** | `feats.tres`/`treasure_items.tres` 的行经 `validate_trigger_row`(`feat_table.gd:44`/`treasure_item_table.gd:104`);但**树合成行**(`effect_engine.gd:142-158`)走 `_push_trigger`,该函数只 `assert(EVENTS.has(on))` 校事件域,**对 counter_id 前缀零校验**。`tree_rules_v11_test.gd:60` 是测试在代校,不是生产在校 ⇒ 前缀校验必须同时落 `_push_trigger`,否则树规则写错前缀不致命,与「加载期强校验」的完成定义 5 直接冲突 | 🔴 阻塞 | +| **A4** | `_battle_over` 孤立调用(同 U2) | 战末入口私有(`orchestrator.gd:1111`),直接调连带 despawn/映射退役/奖励结算 | 🟡 | +| **A5** | `round_start` 真实发射入口(同 U4) | 未定位 ⇒ D1 可能只能走裸 `dispatch`,弱于真实路径 | 🟡 | +| **A6** | `counter_spend` 施加面漏列 | §3.2 只列了写口 `orchestrator.gd:717`;**漏了读施加面 `action_economy.gd:106`**(按 hits 的 `kind == "counter_spend"` 分支走加收对价)——ctx 改动后这条路径怎么走未写 | 🟡 | +| **A7** | counter_id 改名传播面未清点 | 已知:`feats.tres` 数据行、`effect_engine.gd` 树合成行硬编码、`smoke_all.gd` 断言字符串、`tests/` 断言字符串。**未查:呈现层/HUD 是否显示 counter_id**。T6 估时 0.25d 可能不够 | 🟡 | +| **A8** | 保险双账一致性(N6 遗留) | `_perfect_forgiven`(编排器自有账,N6 定「留编排器」)与 `insurance_used`(battle 桶,`grant_segment.gd:327` 写)是两份。V25 对两者都断言;`when counter lt 1` 读的是桶内那份 ⇒ 需厘清 `_perfect_forgiven` 是镜像还是另有语义,否则同一次标错可能被两处各记一次 | 🟡 | + +### 4.5 需求澄清未对齐(不影响本批动工,但需登记或后续裁) + +| # | 事项 | 现状 | 处置 | +|---|---|---|---| +| **B1** | **立项依据无文档载体** | 「系统无状态」在 `architecture.md`/`architecture.yaml`/`entities.yaml` **零命中**;本批是代码批、禁写 `design/` ⇒ 卡片的立项依据只能引用「用户 2026-10-19 会话裁定」,不是文档级依据 | 登记 06(来源=会话提及,设计面=文档轨)→ 后续文档批补 architecture.md 条目;本批报告里作反哺点 | +| **B2** | **06:627 条目解法方向被推翻** | 「计数器寿命域未统一」条目的解法(寿命域统一口径)已被本提案推翻,但 06 是待办唯一登记处,**条目不改,后人看 06 仍会按旧方向想** | 同上,登记 06 改写/作废;本批只作反哺点 | +| **B3** | 天赋树锋/守是否重新定价 | 本批把它从「每战 2 次」修回「每关 2 次」=削弱(§7.7) | 已定不改数值;「是否重新定价」=独立议题,不进本批 | +| **B4** | 旧档断点旧键失效 | `tree_sense_*`→`lvl_*` 后旧档内旧键成死键 | 已按 2026-10-18 先例定不迁移;**但卡的验收应加一条「旧档加载不崩」**(现在没有) | +| **B5** | `roadmap.md` 当前位 stale | 仍写「迭代 31 待开工」,卡②已合入 | 已呈报,不进本批 | +| **B6** | `_fx.counter("dig_combo")` 死读数 | `orchestrator.gd:641`,无任何数据行声明该 id ⇒ combo 加成恒 0 死路 | 已呈报。**建议本批顺手删**(迁到 ctx 后仍恒 0,删它零风险),需你一句确认 | + +--- + +## 5. 影响面 + +**代码**:`effect_engine.gd`、`battle_orchestrator.gd`、`action_economy.gd`、`damage_controller.gd`、`reward_economy.gd`(`set_fx` 面)、**`settlement/grant_segment.gd`**(草案初版漏——6 个用点:`:103` 读 / `:205` 读 / `:227` `has_effect_on` / `:311-317` `forgive_seal_miss` 消费 / `:327` 写)、`run_ledger.gd`、`adventure_runner.gd`、`level_runner.gd`、`feats.tres`。 + +**测试**(草案初版漏项已补): + +| 面 | 位点 | +|---|---| +| `effect_words_t1_test.gd` | `:74` `counter_add("dbl_digs")`/`:86-87` `counter_add` + `counters_export`/`:93-94` `counters_seed` + `counter("pain_layers")`/`:105/:108/:110/:113` `counter_add`/`counter_spend`/`counter`/`:121/:123` `reset_at` 面 | +| `tree_rules_v11_test.gd` | `:29-39` 合成行内嵌 `domain=adventure` 字面量(前缀改名须同步) | +| `feat_vouchers_test.gd` | **`:114`/`:119`/`:128`**(V16 断点往返 `pain_layers`/`twin_digs`;初版写 `:92-103` 系错行)+ `:132+` 卡② V25 完美保险验收组 | +| `settle_segments_test.gd` | `:32-33` `_apply_counter_hit` + `_fx.counter("dig_combo")` | +| `treasure_effects_test.gd` | `:34/:45/:53-56` 裸 `dispatch` 面(无 ctx 参数则签名不兼容) | +| `smoke_all.gd` | `:6083/:6085/:6088` `counter_add`+`counters_export`+`counters_seed`/`:6131-6134` 直呼 `_reset_tree_limited_counters`(删函数须同步)/**另 8 处 `fx.counter_add` 直调 `:1427/:1436/:1445/:1466/:1478/:1525/:1566/:1583`(初版全漏)**/`:1410` 断点 `adventure_counters` 键断言/`:1612` `_fx.rebuild`/`:6096/:6124/:6148/:6157` enc 携带 `adventure_counters` 空账 | +| `run_all.gd` | 套件表(增改套件须登记) | + +**存档/断点**:`adventure` 桶已在断点(写键 `adventure_runner.gd:1035`,续玩回填 `:206-208`);`level` 桶是否进断点见 N3。仅改归属不改 id → 旧档零影响;**若采纳 N2 前缀改名(`tree_sense_*` → `lvl_*`)则旧档断点内旧键失效**(Alpha 阶段可接受,有先例:代码术语刷新批 2026-10-18 用户裁不迁移)。 + +--- + +## 6. 风险与代价 + +1. **确定性风险**:读写时序若与改前不一致,同种子复放会漂移 → T7 必须做同种子复放对拍(`adventure_monte_carlo.gd`,零局新采集)。 +2. **旧档**:`counter_id` 若改名(如 `tree_sense_*`),断点里旧键失效。Alpha 阶段可接受,有先例(代码术语刷新批 2026-10-18 用户裁:不迁移)。 +3. **与迭代 31 卡②的时序(🔴 2026-10-19 复核:前提已翻转)**:草案初版写「卡②**将落**完美保险、建议本批排在卡②之后」——但 **卡② 已合入 master**(`3e076e22` merge 迭代 31 卡② 凭证件裁削批),完美保险**已按「引擎持状态 + `domain=battle`」形态在役**:`effect_engine.gd:42-44` `forgive_seal_miss` 已扩签、`feats.tres:875-877` `insurance_used` 行已落(feats.tres 首例 `domain=battle`)、`battle_orchestrator.gd:267/472/1299` `_perfect_forgiven` 已落地、`grant_segment.gd:311-317` 消费点已接线、`feat_vouchers_test.gd:132+` V25 验收组已在册。 + ⇒ **本批不再是「排在卡②之后」,而是必须把已落地的保险链路一并迁移**:`insurance_used` 归 battle 桶(无前缀缺省)、`grant_segment.gd:327` 写口改 ctx、`_perfect_forgiven`(编排器自有账)是否并入 battle 桶见 N6。若不迁移即删 `counter_add`,保险**每战限次一次**的闸门失效(变每场无限豁免),且 V25 验收组红。 +4. **`_triggers` 仍留在引擎内**——本提案的判断是「编译产物=程序,不算状态」;若此前提被否,则需另立 `Program` 对象,工作量 +0.5d。 + +--- + +## 7. N1–N6 定值(2026-10-19 复核后改定:由既有约束推得唯一解) + +> 🔴 **本节已重写**。上一版把 N1–N6 当裁决单上交,复核后判定:**六项全部可由已生效的用户裁定 + 项目硬纪律推出唯一解,不存在灰色**。真正的待裁只剩 A/B 两项,见 §7.7。候选与淘汰依据留痕见 §11(不再作为裁决请求)。 + +| # | 项 | 定值 | 推导依据(约束 → 结论) | +|---|---|---|---| +| **N1** | round/battle 桶宿主 | **挂 `BattleOrchestrator`,不新建类**;round 桶清零挂 `_emit_round_start()`(:100 现役挂点) | ECS「数据由宿主持有、随宿主生灭」:battle 桶宿主=每战 `new` 的编排器(:274)⇒ 寿命天然吻合。round 桶粒度不匹配(轮<战),替代方案=新建轮级承载类,但①熵增闸要求新类同批修订 Project Layout+白名单;②现役 `_fx_ac_round`(:74 声明 / :101 清零)已是同一模式=既有先例 ⇒ 取最小改动,不加类 | +| **N1** | level 桶宿主 | **`LevelRunner`**(:845 每关 `new`) | 唯一满足「宿主寿命=语义寿命」的选项:每关 new ⇒ 天然按关清零,`:840-842` 硬编码名单自动消失。挂 `run_ledger` 则宿主=冒险级(名实不符)且随断点走(物理排除 N3「不进」)。代价=新增一条 `enc` 下传路径,照抄现役 `enc["ledger"]` 模式即可,属工程细节非裁决项 | +| **N2** | 归属表达 | **id 前缀 + 加载期强校验**(`round_`/`lvl_`/`adv_`/无前缀=battle,`meta_` 非法) | **用户 2026-10-19 已裁**:「不该给字符串查全局包补映射表,那是把反模式当前提;状态对象按参数传入即可,剩下只需 id 命名空间前缀 + 加载期强校验,零运行时机制」。另两候选:乙=§8 已作废的 `domain` 扩词表;丙=第二事实源,违反数值/数据单一事实源 | +| **N3** | level 桶进断点吗 | **不进** | ①宿主=`LevelRunner`,不跨断点存在;②断点只写选路停点(`adventure_runner.gd:992` 注释「战斗中不存」),level 桶在 :847 进关清零 ⇒ 写入时刻**恒为空**,「进」是空操作;③进=新增存档键+`SAVE_FORMAT_VERSION` 7→8+迁移体,零收益。遗留「退菜单续玩可刷新每关限次」=既有行为(非本批引入),按真人检测口径登记 06,不阻塞 | +| **N4** | 内容语义 | **`pain_layers` 归 battle;`twin_digs` 保 adventure;`smith_seals` 剔除** | 补查 GDD 后可定:`奇物.md:58` 定稿「疼痛记忆 on 踩雷:攒 1 层;**下一次攻击**附加层值伤害,用后清零」,且 v3.16 版本行已记「『下场战斗』系笔误,2026-10-12 追认⑥改定」⇒ **设计意图=不跨战** → 归 battle。`twin_digs`:`奇物.md:38` 只写「每第 2 次付费挖格返 1 AP」,未写范围 ⇒ **保现役(adventure)**,本批是 restructure 不做语义变更。`smith_seals` 未声明 `domain`(默认 battle,随战清零)⇒ 不属名实不符 | +| **N5** | 引擎是否保留 `counter()` | **保留,改读 ctx**(引擎不自持) | 用户 ECS 依据「状态对象按参数传入」=引擎经 ctx **只读**,不算自持状态。引擎内部 `_fact_value` 的 `fact counter`(:328)必须读(现役 4 枚条件面全走此路),外置不可行——除非每次发射 payload 预带整桶快照,成本更高。`gte_ref`/`lt_ref` 现役零数据行使用,不构成约束 | +| **N6** | 归桶范围 | **只归计数器**;`_fx_ac_round`/`_fx_ac_battle`/`_fx_new_resource_types`/`_fx_bonus_delta`/`_perfect_forgiven` **留编排器** | ECS 无状态约束的是**系统**(`EffectEngine`),编排器是宿主/世界,宿主持组件合法;且这些是**无名累积器**(非 `counter_id`),与 N2 前缀机制不兼容,强行归桶须另立键名机制(违反「零运行时机制」)。§1 完成定义 1–6 只约束引擎 ⇒ 只归计数器即全部满足 | + +### 7.7 定值:本批不发起全冒险模拟采集(2026-10-19 收回待裁项 B) + +> 排期归属(编入 roadmap 迭代号 / 与营地五卡谁先)**不属于本阶段议题**——需求卡片尚未生成,先定需求本身,排期在卡成之后。 + +**问:为什么要做全冒险模拟?** 项目里它的正当用途只有两个(`sim-data/README.md` 档位表写的):①数值调研/分析;②数值修改后的回归验证。**两者都是数值场景**。本批**一个数值表都不改** ⇒ 两个用途都不成立。 + +| 档 | 用途 | 局数上限 | 单次耗时 | +|---|---|---|---| +| 一档·调研 | 调研/数值分析 | 100+ 常用 | 16–65 分钟 | +| 二档·验证 | 数值修改后回归验证 | ≤40 | ~3.2 分钟 | +| 三档·小调 | 小调整后快检 | ≤20 | ~1.6 分钟 | +| 四档·微量 | 微调后确认没大问题 | ≤5 | <1 分钟 | + +> 🔴 **档名订正**:前几版写成「MC **一档**」——档名用错,一档是调研档;且一档明确「只能由用户发起,任务书/计划书/agent 均无权发起」。 + +**我把它挂上去的真实动机**是「想确认行为变更不是灾难级」。但这个需求有更便宜且纪律明定的手段:**同种子复放对拍**(配套纪律③——对拍断言 = **0 局新采集**,同种子重放已有锚,出口闸复验用锚内子集 ≤20)。对拍的作用是**排除意外**(除已知几处外不得有其他差异),不是量化。 + +**定值**:不发起任何档位采集。依据=配套纪律⑦「零数值改动 = 零 MC」+档位表四档触发面均为数值场景。验收=同种子复放对拍,adventure 桶相关差异列为**已知行为差异并记档**,不声称零漂移。§1 完成定义 6 同步改为「除已记档的已知差异外,逐位一致」。 + +🔴 **必须呈报的事实(削弱幅度)**:`tree_sense_sharp_used` / `tree_sense_guard_used` 修后是**显著削弱**,不是变强。 + +- 一关战斗场数:L1 单链 5 场(4 普通+Boss,`level.tres:8`);L2/L3 分支图走一条路径约 4 场(`:9`/`:10`)。 +- 现役(缺陷态):每战限 2 次 ⇒ 一关理论上限 **8–10 次**;修后:每关限 2 次 ⇒ **2 次**。 +- 理论上限比约 **4–5 : 1**;实际幅度受触发频率(踩中危险格/标错次数)限制,小于该比例,但**方向确定为削弱**。 +- 这是**回到设计意图**(`effect_engine.gd:140` 注释明写「每关限次」),不是平衡调整 ⇒ 不构成数值改动,不触发采集纪律。 +- 若你认为修完后天赋树锋/守需要重新定价,那是**独立议题**,不在本批。 + +### 7.8 B 项所指缺陷全解(2026-10-19 code-level 实查) + +**缺陷本体**:`game/core/run_ledger.gd:40-45` + +```gdscript +func counters_merge_export(exported: Dictionary) -> void: + if adventure_counters.is_empty(): # ← :42 判空早退闸 + return + for cid in exported.keys(): + adventure_counters[cid] = int(exported[cid]) +``` + +| 维度 | 内容 | +|---|---| +| **所属领域** | 核心域 `game/core/`(非呈现层、非数据层) | +| **所属系统/类** | `RunLedger`——共享四本账承载类(迭代 27 T3 从「四账散在 encounter 字典」收口为单类,审计 P1-4) | +| **所属链路** | EffectEngine adventure 域计数器「**战末导出 → 冒险账本 → 战首回灌**」搬运链(迭代 20 T6 建立) | +| **设计作用** | 让 `domain=adventure` 的计数器跨战斗持久:战末 `orchestrator.gd:1127` 导出 → 覆写 `run_ledger.adventure_counters`;战首 `:393` `counters_seed` 回灌引擎 | +| **实际作用** | 恒等空操作(见下「自锁」) | + +**自锁机制(不是「首战丢一次」,是整局不落账)**: + +1. 新冒险开局 `adventure_runner.gd:255` → `run_ledger.adventure_counters = {}`(空); +2. 战末导出 → `:42` 判空 → 整批 `return`,四枚值全丢; +3. 账本**仍为空** → 下一战末继续丢 → ……循环不变; +4. 唯一解锁路径=断点回填 `:206-208`(`bp_counters is Dictionary` 才赋非空)。 + +⇒ 同一份存档,**读档前每战清零、读档后跨战累积**,行为不一致且难复现。 + +**受影响四枚(`_adventure_counter_ids` 名单)**: + +| counter_id | 来源 | 语义 | 修前(全新开局连玩) | 修后 | +|---|---|---|---|---| +| `twin_digs` | 双管镐 `feats.tres:29-31` | 每第 2 次付费开格返 1 AP | 跨战进度丢失 → 实为「每战第 2 次」 | 真跨战累积 | +| `pain_layers` | 疼痛记忆 `feats.tres:374-377` | 触陷阱攒层,下次攻击附加,用后清零 | 踩陷阱后未攻击即战末 → 层数丢失 | 层数带入下一战 | +| `tree_sense_sharp_used` | 天赋树 sense_sharp(引擎合成 `:142-148`) | 踩中危险格伤害减半,**每关限 2 次** | 一关多战(`battle_index` 关卡域 1 基序)⇒ 实为**每战限 2 次** | 真每关限 2 次(配合 `:840-842` 按关 erase) | +| `tree_sense_guard_used` | 天赋树 sense_guard(引擎合成 `:152-158`) | 标错返还 AP,**每关限 2 次** | 同上,实为每战限 2 次 | 同上 | + +**时序定性(是「时序问题」吗?——部分成立,但性质是两步叠加,非单点漏做)**: + +五个时点上的调用**全都在**(开局置空 `:255`、进关 erase `:840-842`、战首 seed `:393`、战斗累积 `:231`、战末 export `:1127`),不存在「整个步骤被漏写」。出问题的是两处: + +| 时点 | 性质 | 说明 | +|---|---|---| +| **T0 开局** | **缺初始化**(该做没做) | 应播种四枚零值账(账本初始非空),实际只给 `{}` 空字典 ⇒ 为 T3 守卫的误判提供了触发条件 | +| **T3 战末** | **守卫误判**(做了但被跳过) | 应无条件覆写四枚,实际被 `is_empty()` 早退闸整批丢弃 ⇒ **病根** | + +两者**任一修好即可解开自锁**:只修 T0(播种零账)→ 账本非空 → T3 守卫放行;只修 T3(删守卫)→ 无条件覆写 → 即使 T0 空也通。T1/T4 的偏差是这两处的**后果**,不是独立缺陷。 + +⇒ 本提案走的是「修 T3」这条路,且连同整条搬运链一起删(无副本即无守卫可附),T0 的初始化缺口随之失去意义。 + +**守卫来源考据(`git log -S "adventure_counters.is_empty()"` → `6fd7d782` 迭代 27 T3 收口时逐字搬入)**: + +收口**前**的等价实现(`6fd7d782^:game/core/battle_orchestrator.gd:402-403` / `:1157`): + +```gdscript +# 战首:从 enc 取共享引用;enc 没带(调试/单场战斗)⇒ 退化成临时字典 +_fx_adventure_counters_ref = counters_ref if counters_ref is Dictionary else {} +# 战末 +if _fx != null and not _fx_adventure_counters_ref.is_empty(): # ← 守卫原型 + for cid in exported.keys(): + _fx_adventure_counters_ref[cid] = int(exported[cid]) +``` + +**旧形态下这个守卫是合理的**:它判的不是「有没有数据」,而是**「这个引用是不是真的指向共享账本」**——`is_empty()` 只是当时成立的**代理判据**。enc 没带 `adventure_counters` 时引用退化为临时 `{}`,写进去没人看得到,所以跳过。 + +**收口后判据与被防风险脱钩**:账本变成 `RunLedger` 的**成员字段**(`run_ledger.gd:12`),永远存在、永远有效,不再有「引用无效」这回事;此时 `is_empty()` 只剩「还没写入过任何值」的含义。**判据没改,但它要防的东西已经不存在了** ⇒ 这就是「误判」的准确含义——不是写错了条件,是**迁移时把一个已失效的代理判据原样保留**。 + +**修法对比(伪代码)**: + +```gdscript +# 甲 · 删守卫(最小改动,2 行)——推荐 +func counters_merge_export(exported): + for cid in exported.keys(): + adventure_counters[cid] = int(exported[cid]) + +# 乙 · 改判据(判"本次有没有东西可导",而非"目标账本空不空") +func counters_merge_export(exported): + if exported.is_empty(): # 注:counters_export 对名单全量输出,恒非空 ⇒ 本分支恒不触发 + return # ⇒ 乙 实际等价于甲,但保留了一个恒真的死条件 + for cid in exported.keys(): + adventure_counters[cid] = int(exported[cid]) + +# 丙 · 补 T0 初始化(不改守卫,改上游) +# adventure_runner.gd:255 由 {} 改为播种四枚 0 ⇒ 账本恒非空 ⇒ 守卫恒放行 +# 可解自锁,但保留了一个语义已错的条件,后人仍会踩 ⇒ 不推荐 + +# 丁 · 本提案:整个函数连同搬运链删除 +# adventure 桶直接住 RunLedger,引擎不再持有副本 ⇒ 无"导出"动作 ⇒ 无守卫可附 +``` + +**结构性根因(为什么无状态化后这类问题整体消失)**: + +先澄清一个误解——**旧形态不是「写不进去」**。GDScript 的 `Dictionary` 是引用类型,写外部字典毫无技术障碍;旧守卫防的不是可写性,而是**有效性**:写进一个无人可见的临时副本等于白写。 + +真正的结构性根因是「**引擎持内部账本 + 战末一次性搬运**」这个两段式结构本身,它同时制造了三类风险: + +| 风险 | 来源 | +|---|---| +| 引用可能无效(临时 `{}`) | 内部账本需要「导出到某处」,而"某处"可能不存在 | +| 需要额外时点(战末/战首) | 搬运只能在战斗边界发生 ⇒ 时点错了就丢数据 | +| 副本与真身的一致性 | 两份数据 ⇒ 谁先谁后、谁覆写谁 | + +无状态化后这三类风险的**结构源头被拆掉**:桶住在外部宿主上,`dispatch` 时**经 ctx 就地写入外部桶**(状态按参数传入,N2 已裁)⇒ 「写外部」从「战末一次性搬运」降级为「每次事件的常规动作」,**不再有特殊时点、不再有副本、不再有守卫可附**。 + +> 换言之:本提案不是「修好了这个守卫」,而是**让「导出」这个动作本身不再存在**——守卫连同它要防的风险一起失去依附对象。 + +> 四者都能解开自锁;甲/丁 是同一结果的两条路径(甲最小、丁是本提案目标形态)。 + +**与提案的关系**:提案删除整条搬运链(`counters_export` / `counters_seed` / `counters_merge_export`),adventure 桶直接住 `RunLedger`(唯一真身,无副本)。**早退闸随宿主一起消失——不是「修 bug」,是无处可附**。副作用=行为变更(上表右列)。 + +**B 三选一**: + +- **B1 不发起采集(纪律⑦ 默认)**:验收=同种子复放对拍(零局新跑,锚内子集 ≤20),adventure 桶相关差异列为**已知行为差异并记档**,不声称零漂移。 +- **B2 破例发起一次二档采集**(需你明确发起,~3.2 分钟):把「跨战持久」后的 AP 经济/通关率位移量化,确认不是灾难级破坏。 +- **B3 保留 `:42` 行为、另立缺陷批**:对拍零漂移,但等于为对齐而复刻一个 bug,**不推荐**。 + +> 若 B 选 B1/B2,§1 完成定义 6 须同步改写(现文「与改前一致」与缺陷消解直接冲突)。 + +--- + +### 7.9 本轮自行定值(2026-10-19,可否决——均属约束可推,不占用裁决位) + +| # | 定值 | 推导依据 | +|---|---|---| +| 1 | **立项判据=用户 2026-10-19 给出的 ECS 架构依据**(系统无状态/数据随宿主生灭/状态按参数传入),不依赖 architecture.md 现有条文 | ECS 依据即已生效的用户裁定,判据充分。`architecture.md`/`architecture.yaml`/`entities.yaml` 检索「无状态」「纯计算」零命中属**文档欠账**,处理方式=登记 06(来源=会话提及,设计面=文档轨)另立文档批补条目,**不阻塞本批** | +| 2 | 分桶后数据行 `domain` 与 `reset_at` 两个字段**删除** | 前缀是归属的唯一声明;保留即**双事实源**(红线)。`reset_at` 由「round 桶轮始整体清空」取代,`domain` 由 `adv_`/`lvl_` 前缀取代 | +| 3 | 容器键名 `run_ledger.adventure_counters` **保留不改** | 改名会破坏旧档断点键;最小改动+单一事实源(桶的容器名不是归属声明) | +| 4 | counter_id 改名**不迁移旧档** | 仅 `tree_sense_*`→`lvl_*`、`twin_digs`→`adv_twin_digs`、`instinct_atk`→`round_instinct_atk` 三处会废掉旧档断点内旧键;Alpha 阶段可接受,**有先例**(2026-10-18 代码术语刷新批用户裁不迁移) | +| 5 | 引擎两个编译产物名单 `_reset_on_round_start` / `_adventure_counter_ids` **一并删除** | 归属改由前缀在加载期强校验,名单即第二事实源 | + +> 以上五条均为「约束给得出唯一解」,故不进裁决单;若与你的判断不符,直接指出即可推翻。 + +--- + +## 8. 已作废的旧方向(防重走) + +2026-10-19 前半场提出的「**寿命域统一口径**」方案(五档/四档/两档枚举、`domain` 键扩词表、`reset_at` 退役、D1–D9 裁决单)**整体作废**。作废理由:该方案的隐含前提是「引擎持有状态、需要一个字段说明它活多久」;改为分桶后,**寿命由宿主对象天然决定,不需要寿命字段**。 + +原议题中仍然成立的部分只剩两条:①`feats.tres` 名实不符(`pain_layers` / `twin_digs`;**`smith_seals` 已于 2026-10-19 复核剔除**——它未声明 `domain`,默认 battle 随战清零,不属名实不符)→ 已在 §7 N4 定值(`pain_layers` 归 battle/`twin_digs` 保 adventure,依据 `奇物.md:58` 与 v3.16 追认⑥);②`run_ledger.gd:42` 空账早退缺陷 → 本提案落地后自动消解,无需单独修(但消解=行为变更,触发 §7.7 待裁项 B)。 + +--- + +## 9. 不在范围内 + +- `charge_reset`(秘宝充能周期,`treasure_item_table.gd:46`)——已自带枚举与校验,另属一族,不动; +- 凭证件 runner 侧裸标量(`_pending_rap` / `_time_sand_queue`)——一次性结算,非效果引擎状态; +- `SaveManager` meta 域(生涯计数 / 钥石 / 树节点 / 锻造路线)——存档级,本提案明确不并入引擎; +- 断点快照结构除 N3 外不变。 + +--- + +## 10. 复核订正记录(2026-10-19 code-level 复核,逐条实查) + +### 10.1 行号订正汇总 + +| 草案初版写法 | 实际 | 说明 | +|---|---|---| +| `effect_engine.gd` :83/84/85/86/88 | :86/87/88/89/91 | 整体 +3——迭代 31 卡② 在 `:42-44` 插入 `forgive_seal_miss` 三行注释 | +| `effect_engine.gd` rebuild :98 / dispatch :208 / counter :224 / counter_add :228 / counter_spend :237 / export :246 / seed :255 / validate :351 | :101 / :211 / :227 / :231 / :240 / :249 / :258 / :354 | 同上 +3 | +| `adventure_runner.gd:876-878` 硬编码清除名单 | **:840-842**(`_reset_tree_limited_counters`),调用点 :847 | 差 36 行 | +| `adventure_runner.gd:258` 新冒险置空 | :255 | — | +| `adventure_runner.gd:1072` 断点写键 | **:1035**(该文件仅 1059 行,:1072 越界);续玩回填 :206-208 | — | +| `feat_vouchers_test.gd:92-103` | :114 / :119 / :128 | V16 断点往返在 :114 起 | +| 计数器「13 枚」 | **16 枚**(round 4 + battle 8 + adventure 4) | 13 系卡② 前旧数;且与草案自身分桶相加 4+8+4=16 矛盾 | + +### 10.2 遗漏面订正 + +1. **第 6 个写口** `settlement/grant_segment.gd:327`(`_apply_counter_hit`)——卡② `insurance_used` 记账唯一通路,漏=保险限次失效。 +2. **3 个读口** `damage_controller.gd:345`、`grant_segment.gd:103`(`value_counter` 层值:安全帽 `helm_layers`/疼痛记忆 `pain_layers`/陷阱本能 `instinct_atk`/弃图兑换 `stop_swap`)、`grant_segment.gd:205`(`per` 反打层值)。 +3. **测试迁移面漏 8+3+2 处**:`smoke_all.gd` 另 8 处 `fx.counter_add` 直调(:1427/1436/1445/1466/1478/1525/1566/1583)、`settle_segments_test.gd:32-33`、`treasure_effects_test.gd:34/45/53-56` 裸 `dispatch`。T7 估时(0.5–1d)应上修。 +4. **`smoke_all.gd:1612`** `orch0._fx.rebuild([], [], [])`——`rebuild` 签名若改须同步。 + +### 10.3 判据出处存疑(须用户确认,本轮不代裁) + +§1 的立项判据「`_counters` 被判为违反『系统无状态』原则」——**`design/dev/architecture/architecture.md`、`design/registry/architecture.yaml`、`design/registry/entities.yaml` 三处均无「无状态」/「纯计算」原则条目**(2026-10-19 全仓检索零命中)。architecture.yaml:34-35 对效果引擎的登记只写「发布订阅解释器/四词表封闭/遍历序=声明序保种子复放」,**未含无状态约束**。 +⇒ 该判据目前**无权威文档出处**。若本批以此立项,需先定:①在 architecture.md 补一条「系统无状态」原则(文轨动作,另立批);②或改以「消除双副本搬运 / 消除硬编码清除名单」为立项理由(这两条是实查成立的事实,无需新原则)。**本轮只呈报,不代裁。** + +### 10.4 顺带发现(不在本批范围,仅呈报) + +1. **死读数**:`battle_orchestrator.gd:641` `_fx.counter("dig_combo")`——全仓无任何数据行声明 `counter_id="dig_combo"`,**该读数恒 0**,`_feat_yield_adjust` 的 `combo_cap` 分支实为死路。属既有缺陷,与本批正交;本批迁移后仍恒 0,不影响对拍。 +2. **06 上游条目行号亦已漂移**:`06-待办清单.md:629` 内引 `adventure_runner.gd:873-877`、`effect_engine.gd:32`、`:119-121` 三处均非现役行号(实为 :840-842、:31、:124-125)。本批落地回写 06 时须一并订正。 +3. **roadmap 当前位 stale**:`roadmap.md:8` 仍写「迭代 31 待开工」,但卡② 已合入 master(`3e076e22`)。属并行会话反哺迟到,非本批范围。 + +--- + +## 11. N1–N6 候选与淘汰依据(决策留痕,非裁决请求) + +> 🔴 2026-10-19 复核后本节**降级为留痕**:六项已由既有约束收敛到定值(见 §7),下表保留「当初并列了什么、各自为什么不选」供后人复核。仅 §7.7 的 A/B 两项仍需用户裁。 + +### N1 · 桶的宿主 + +**战斗侧(round + battle 桶)** + +| 候选 | 内容 | 不选它的理由 | +|---|---|---| +| **甲** 挂 `BattleOrchestrator`(两 Dictionary 成员,battle 桶随 `new` 天然空) | 最小改动、零新类 | 编排器已 1299 行、已持 9 个 `_fx_*` 运行态;再加两桶=状态只是从引擎搬到编排器,**「无状态化」对系统层未发生**。round/battle 寿命差异靠人工清零点保证(现役 `_fx_ac_round` 正是此形态,即缺陷原形) | +| **乙** 新建 `BattleCounters` 类(持 round/battle 两桶 + `reset_round()`) | 寿命由类型表达;两桶清零各一方法;可独立单测 | +1 新类;ctx 传参多一层;`dispatch` 签名改动面略大 | +| **丙** 单一 `EffectCounters` 承载全部四桶 | ctx 只需一个参数 | 把冒险级 `adventure` 桶与战斗级桶塞进同一对象,寿命又退回「靠人工清零」,与本提案立意相消 | + +**关卡侧(level 桶)** + +| 候选 | 内容 | 不选它的理由 | +|---|---|---| +| **甲** 挂 `run_ledger`(与 `adventure_counters` 同处) | 断点零新增键(复用同字典);`_reset_tree_limited_counters` 改成「清 level 前缀键」 | RunLedger 名义=冒险级共享四账,装关卡桶=名实不符;且它随快照走 ⇒ **N3「不进断点」选项被物理排除**(选甲即强制选「进」) | +| **乙** 挂 `LevelRunner`(每关 new,随宿主生灭=天然按关清零) | 寿命天然正确;硬编码名单自动消失 | 需新增一条 `enc` 下传路径(现役只下传 `enc["ledger"]`);N3 若选「进断点」须新增存档键 | + +### N2 · 归属表达 + +| 候选 | 内容 | 不选它的理由 | +|---|---|---| +| **甲** id 前缀(`round_`/`lvl_`/`adv_`/无前缀=battle,`meta_` 非法) | 零运行时机制;加载期 `validate_trigger_row` 强校验 | 前缀=命名约束,改名要动 `effect_engine.gd:143-158` 合成行 + `tree_rules_v11_test.gd:29-39` 字面量,且**旧档断点内旧键失效**;`bucket_of()` 是字符串约定,无类型系统兜底 | +| **乙** 数据行显式键(`domain` 扩为 round/battle/level/adventure 四值) | 与现有 `domain` 键同面,改动直观 | **正是 2026-10-19 前半场已作废的方向**——保留 `domain` 键=保留「引擎需要知道寿命」的心智模型,分桶的意义被抵消(§8) | +| **丙** 集中注册表(新常量表 counter_id → 桶) | 单点可查 | **第二事实源**,与 `feats.tres` 行内声明易双向漂移(ADR 漂移教训同类红线);每加一枚计数器须改两处 | + +### N3 · level 桶进断点吗 + +| 候选 | 内容 | 不选它的理由 | +|---|---|---| +| **甲 进** | 新增存档键 + `SAVE_FORMAT_VERSION` 7→8 | 需写迁移体(v6→v7 是「空迁移=缺键 `_normalize` 补齐」先例,成本可控)。**但**——见下方「前提核查」 | +| **乙 不进**(现值) | 零存档改动 | 续玩时每关限次归零 ⇒ 玩家可「退菜单续玩」刷新限次,与 2026-10-13 修掉的 `attune_refresh_y`「用完退菜单再续玩满血复活」**同类可利用漏洞**(属既有缺陷,非本批引入) | + +🔴 **前提核查(影响 N3 实质收益,须先确认)**:现役断点只写在**选路停点**(`adventure_runner.gd:992` 注释明确「选路停点=唯一可恢复停点形态;战斗中不存」),而 level 桶在 `_start_current_level`(:847)进关时清零。⇒ 断点写入时刻 level 桶**恒为空**,「进断点」很可能是**空操作**。若将来支持战斗中存点才真正有意义。**请裁:是否按「当前恒空」判定 N3 收益≈0、直接选乙。** + +### N4 · 内容语义(名实不符两枚,已剔除 `smith_seals`) + +| 计数器 | 候选 | 不选它的理由 | +|---|---|---| +| `twin_digs`(现役声明 adventure) | ①真 adventure(跨战累积,保现役)②归 battle(每第 2 次按战重算,每战首次付费开格不返) | ①=承认现役,但 `run_ledger.gd:42` 空账早退使**首战导出全丢**=首战攒的次数丢失(现役真 bug);②=强度实质变化(每战少返一次),属数值改动,须另走数值裁 | +| `pain_layers`(现役声明 adventure,但 `:407` 行 `set:0` 用后清零) | ①归 battle(与「用后清零」自洽,跨战持久本就不成立)②保留 adventure 但删 `set:0` 行(变真跨战累积层)③归 level | ①最省事且语义自洽,但**改变了「上一战残留层可带进下一战」这一现役行为**(要确认是否为设计意图);②=明确的内容加强,属数值裁;③两不靠,无现役依据 | + +### N5 · 引擎是否保留 `counter()` 只读 + +| 候选 | 内容 | 不选它的理由 | +|---|---|---| +| **甲 保留**(纯查询,读 ctx) | `_fact_value` 的 `fact counter`(:328)在 `dispatch` 内部必须能读到——现役 4 枚条件面(`twin_digs`/`pain_layers`/`insurance_used`/`tree_sense_*`)全走此路;外部 4 读口也走 ctx | 「引擎接触账本对象」——严格说是持**只读引用**,是否算「状态」取决于 §1 完成定义 1 的严格度 | +| **乙 连读也外置** | 外部 4 读口直读桶,不经引擎 | **引擎内部仍必须读**:`when` 条件的 `fact counter` 判定在 `_cond_ok` 内发生,无法外置到调用点,除非改成「payload 预携带全部计数器现值」——那等于每次发射都要快照整桶,成本与复杂度都更高。⇒ 乙实质只能做到「外部调用点不再经引擎」,引擎内部读不可消除 | + +补充事实:`gte_ref` / `lt_ref` 两算子现役**零数据行使用**(仅登记在 `OPS` 词表),不构成 N5 约束。 + +### N6 · 归桶范围 + +| 候选 | 内容 | 不选它的理由 | +|---|---|---| +| **甲 全归**(`_fx_ac_round`→round 桶;`_fx_ac_battle`/`_fx_bonus_delta`/`_fx_new_resource_types`/`_perfect_forgiven`→battle 桶) | 效果态彻底外置,无状态化名副其实 | ①这些是**无名 int/Dictionary 累积器**,不是 `counter_id`,进桶后与 N2 前缀机制不兼容,须另立桶内键名;②`_fx_bonus_delta` 有「战末汇入 ledger 再清零」的跨战语义,归 battle 桶后该步骤仍需保留;③`_perfect_forgiven`(卡②,契约 2 禁改写 `_perfect_hit_false`)并入桶=把刚验收的保险链路再动一次 | +| **乙 只归计数器**(不动编排器 5 个效果态) | 改动面最小;§1 完成定义 1–6 条**全部仍可满足**(完成定义只约束引擎) | 「无状态化」只对引擎成立,编排器仍持 6 个效果运行态,提案立意打折 | +| **丙 分两步**(本批乙,编排器效果态另立一批) | 本批可达完成定义且风险最低;遗留项明确登记 06 | 需多一次立项;两批之间编排器仍持态(可接受) |