# 玩家属性系统（Player Attributes）设计

> **日期**：2026-07-31
> **Epic**：E3 经济与 build · 子计划 ①（缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`）
> **优先级**：P0 · 工作量 M
> **目标**：建立数据驱动的玩家属性框架（含独立的加成公式模块），并接线三条既有死数据；为子计划 ②「货架 C（属性购买）」提供接入点。

---

## 0. 范围界定（重要）

**路线图对本子计划的描述是错的，本 spec 以权威文档为准。**

路线图写「定义 4/5 缺失 player stats（如暴击/急速/范围/吸血/减伤）」—— 这些属性名是拟稿时的推测，不存在于任何权威文档。实际的权威属性表是 `docs/design/numerical_design.md` §1.1，内容完全不同（11 个属性：`hp_max` / `mana_max` / `move_speed` / `cast_delay_mod` / `recharge_speed_mod` / `luck` / `cpu_limit` / `attunement_×4`）。

11 个属性 + 加成层 + 数据文件 + 设计器页，对单个 spec 过大。**本期只做「属性框架 + 修复既有死线」**，即那些「消费方已存在、只是没接上」的属性。明确排除：

- `attunement_fire/ice/lightning/poison`（4 个元素精通）—— 新增玩法维度，需接入元素伤害管线，独立立项。
- `luck` —— 依赖暴击链，而暴击链目前是死的（见 §1.3），独立立项。
- `recharge_speed_mod` —— 代码中**不存在**充能系统（全项目 grep `recharge` 零命中），属新增机制而非断线，独立立项。
- `mana_max` 迁入框架 —— 见 §2.2 的理由。

## 1. 背景与现状（代码复核 2026-07-31）

### 1.1 三条死数据

- **`cpu_limit` 双份死数据**：`PlayerStats.cpu_limit`（`player_stats.gd:21`，进存档）与 `CoreDefinition.cpu_limit`（`core_definition.gd:16` ← `cores.json`，逐法杖 3/5/6/8，设计器可改）**两者都不被读取**。`spell_evaluator.gd:332` 写死 `var max_ops: int = MAX_OPS_PER_CPU * 5`。权威文档 §1.1 自己已标注此 bug（「代码硬编码 MAX_OPS = 40×5 = 200，从不读 cpu_limit」）。后果：六个法杖各自配置过的运算力完全无效，法杖间少了一个区分维度，且违反「纯数据驱动」。
- **`move_speed` 不是属性**：`player_manager.gd:7` 的 `const MOVE_SPEED: float = 200.0`，硬编码常量。
- **`cast_delay_mod` 不存在**：`player_manager.gd:88` 直接取 `core.cast_interval`，无任何全局修正系数。

### 1.2 孤儿字段

`PlayerStats.armor`（:22）与 `PlayerStats.resistance`（:23）**零消费方**（全项目 grep `PlayerStats.armor` / `PlayerStats.resistance` 无命中），且**不在权威属性表 §1.1 内**。`take_damage`（:65）只乘难度系数，两者从未参与伤害计算。

它们不是「断线」而是**实现先于设计的残留**：从来没有被设计过。

### 1.3 暴击链是死的（影响 `luck` 的排除决定）

`CastStats.crit_chance`（`cast_stats.gd:13`）只在 CIRCUIT 分支快照中被读写，从未被掷判；`bullet_manager.gd:81` 调 `DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0)` 把 `is_crit` **硬编码为 `false`**。故权威 §1.1 中「`luck` 影响暴击率」这条设计，落地前必须先打通暴击链 —— 属独立工作。

### 1.4 可复用的既有模式

`PlayerStats.mana_leech` 是本项目已验证的「多来源属性重算」模式：`combat_manager._rebuild_wand` 在换杖/换牌时按 `core.base_mana_leech + Σ(deck node meta.mana_leech)` 重算，读取端是裸字段。本设计的加成层复刻此模式。

## 2. 设计

### 2.1 加成模型 —— 公式而非叠加

**核心约束（用户 2026-07-31 指定）**：任何加成都必须有明确的合成公式，不做简单叠加。这与权威 §0 的核心公式 `构建强度 = (基础数值 × 修正系数) ^ 逻辑复杂度` 一致 —— 修正是**乘算系数**。

一条加成是 `{attr_id: String, mode: String, value: float, source: String}`，`mode` 仅两种：`flat`（平坦加减）、`pct`（百分比）。

每个属性在 `attributes.json` 声明自己的 `combine`：

| `combine` | 公式 | 用于 |
| :-- | :-- | :-- |
| `hybrid`（默认） | `(base + Σflat) × Π(1 + pct)` | `move_speed`、`hp_max` |
| `inverse` | `(base + Σflat) × Π(1 − pct)`，**下限**钳制到 `hard` | `cast_delay_mod`（越低越快） |
| `add_int` | `base + Σflat`，取整；**拒绝 `pct`** | `cpu_limit`（离散指令预算） |

**乘算用连乘 `Π` 而非线性求和 `Σ`，这是刻意的**：三个「+20% 移速」在连乘下是 ×1.728，线性求和则是 ×1.6。连乘永远逼近而不会突然爆炸，也避免「+50% × 2 = +100%」这类线性叠加导致的后期失控。

`inverse` 的 `pct` 取 `(1 − pct)`：「施法延迟 −20%」得 ×0.8，符合直觉；连乘保证永远碰不到 0，再由 `hard` 兜底。

`add_int` **拒绝** `pct` 并 `push_error`：对离散指令预算做乘算无意义（「1.5 个 CPU」不存在），静默取整会掩盖配置错误。

### 2.2 本期纳入的属性

| 属性 | base | soft | hard | combine | 处理 |
| :-- | --: | --: | --: | :-- | :-- |
| `cpu_limit` | **0** | 20 | 50 | `add_int` | 修死数据；生效 MAX_OPS 用 `玩家 + 法杖` |
| `move_speed` | **200** | 600 | 800 | `hybrid` | 由 `const` 转为属性 |
| `cast_delay_mod` | 1.0 | 0.1 | **0.05** | `inverse` | 新增，在**开火时**乘到 `core.cast_interval`；`hard` 原定 0.01，2026-07-31 据实测上调至 0.05，见 §2.2b |
| `hp_max` | 100 | 2000 | — | `hybrid` | 已可用，迁入框架 |

**`cpu_limit` 玩家基准取 0**（权威表写 5）：因生效值 = 玩家 + 法杖，而法杖已提供 3–8。取 0 使小木法杖仍为 5 → MAX_OPS 200，**与当前行为完全一致，零平衡改动**；取 5 会让所有法杖的执行预算翻倍。语义上玩家属性是「全局加成」（权威 §1.1 开头：「这些属性是全局的，会修正所有法杖的输出」），基准为 0 正是纯加成语义。

**法杖的 `cpu_limit` 作为一条加成来源接入，而非在调用点相加**（自审修正）：
```gdscript
# combat_manager._rebuild_wand（与既有 mana_leech 重算同处、同时机）
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core")
```
理由是钳制位置。若在 `spell_evaluator` 调用点写 `PlayerStats.cpu_limit + core.cpu_limit`，则 `hard`(50) 只钳制到玩家那一份，法杖的 3–8 加在钳制**之后**，总值可达 58 —— 硬上限失效。走加成来源则 `hard` 天然作用于生效总值，`spell_evaluator` 只需读 `PlayerStats.cpu_limit` 一个数。

这还有两个附带好处：① `_modifiers` 本期**不再是空的**，加成系统从第一天起就有真实消费者，而非「有机制无消费者」；② 验收第 11 条（`remove_modifiers_from`）得以对真实来源而非合成数据验证。换杖时机与 `mana_leech` 完全一致，无需新增钩子。

**`move_speed` 基准取 200（权威表写 300），并订正权威表**：权威的 300 **从未被任何代码读取过**，是纸面孤值；而 200 自 S0 沿用至今，20 波内容、Boss 弹幕密度、无敌帧 0.5 s 均按此值调校。此处既成事实的信息量大于纸面设计 —— 改成 300 等于用未经验证的数字推翻一整轮已调校的内容。

> **与归航那次的对照（重要，避免误用「以权威为准」）**：`numerical_design.md` 的 Mana 列与**每一个**已实现法术吻合，证明该列是活的、被遵守的，故归航定价越权是真错误。而 `move_speed` 300 从未被读取，是同一文档中未被实践检验的部分。「以权威为准」不是无条件的 —— 要看那条权威有没有被实践检验过。

**`mana_max` 本期不迁入**：权威 §1.1 写明「法杖也有自己的上限，取 Min 值」，而现状是核心值直接覆盖玩家值。那个 Min 语义需要独立的设计判断（取 Min 后玩家升级蓝上限在低上限法杖下完全无效，这是否是设计意图？），不应混进属性框架这一期顺手改。本期保持原样，记录为已知遗留。

### 2.2b `cast_delay_mod` 的饱和点 —— 实测数据与 `hard` 上调（2026-07-31 追记）

> 本节记录的是**运行时实测**，不是推导。数据来自验收期（Task 5 Step 7b②）向 `get_tree().root`
> 挂载的临时 `_physics_process` 扫描节点：16 个相位 × 180 物理帧，逐相位切换法杖与
> `cast_delay_mod`，记录**相邻两次施法的帧距分布**。

#### 现象

`player_manager._handle_auto_cast` 用 `if` 而非 `while`：

```gdscript
_cast_timer -= delta
if _cast_timer <= 0.0:
    _cast_timer = _cast_interval * PlayerStats.cast_delay_mod   # 赋值，非 -= 或 +=
    SpellEvaluator.execute_compiled(...)
```

因此**每物理帧至多施法一次**，60 Hz 物理步长下硬顶 60 次/秒。且 `_cast_timer` 是**赋值**而非累积扣减，
亚帧余量每周期被丢弃 —— 实际射速按**整帧量化**。

#### 实测表

每个相位的帧距分布都是**单值**（如「1 帧×179」「2 帧×89」），这本身就是量化的直接证据。

| 法杖 | `cast_delay_mod` | 生效间隔 T | 实测帧距 | 实际速率 |
| :-- | --: | --: | --: | --: |
| `wand_basic`（基准 0.5 s） | 1.0 | 0.5 s | 31 帧 | 1.94 /s |
| | 0.5 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1（soft）** | 0.05 s | 3 帧 | **20 /s** |
| | 0.07 | 0.035 s | 3 帧 | 20 /s |
| | 0.06 | 0.03 s | 2 帧 | 30 /s |
| | **0.05（现 hard）** | 0.025 s | 2 帧 | **30 /s** |
| | 0.04 | 0.02 s | 2 帧 | 30 /s |
| | 0.034 | 0.017 s | 2 帧 | 30 /s |
| | **0.033** | 0.0165 s | 1 帧 | **60 /s ← 饱和点** |
| | 0.02 | 0.01 s | 1 帧 | 60 /s |
| | 0.01（原 hard） | 0.005 s | 1 帧 | 60 /s |
| `wand_fast`（基准 0.25 s） | 1.0 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1（soft）** | 0.025 s | 2 帧 | **30 /s** |
| | 0.07 | 0.0175 s | 2 帧 | 30 /s |
| | 0.0667 | 0.016675 s | 2 帧 | 30 /s |
| | **0.05（现 hard）** | 0.0125 s | 1 帧 | **60 /s ← 已在饱和区内（饱和点是 0.0667，见下）** |
| | 0.01（原 hard） | 0.0025 s | 1 帧 | 60 /s |

#### 四条结论

1. **饱和点 = `(1/60) / 法杖基准间隔`**。
   | 法杖 | 基准间隔 | 饱和点 | 来源 |
   | :-- | --: | --: | :-- |
   | `wand_basic` | 0.5 s | ≈ **0.0333** | 实测（0.034 仍 2 帧、0.033 已 1 帧）|
   | `wand_fast` | 0.25 s | ≈ **0.0667**（= 1/15）| 实测夹在 (0.05, 0.0667] 内：0.0667 仍 2 帧、0.05 已 1 帧 |
   | `circuit_fork` | 0.6 s | ≈ 0.0278 | **公式推导，未实测** |

   > **关于 `wand_fast @ 0.0667` 读到 2 帧**：数据没错，是采样点的问题。真实边界是 `1/15 = 0.06666…`，
   > 我取的 `0.0667` 比它高 `3.3e-5`（T 上高 `8.3e-6 s`），故落在 2 帧那侧。再叠加结论 3 的浮点余量效应
   > —— T 恰为整帧倍数时反而多花一帧 —— **实际的 1 帧上界略低于 0.0667**。故 `wand_fast` 的饱和点
   > 应读作「0.0667 稍下方」，本文一律以公式值 0.0667 记，误差方向是保守的（真实无效区间只会**更大**一点）。
2. **soft→hard 之间的可达档位是台阶而非曲线**：`wand_basic` 只有 20 / 30 / 60 次每秒三档，
   `wand_fast` 只有 30 / 60 两档。中间值买不到。
3. **周期恒为 `ceil(T × 60)` 帧，且 T 恰为整帧倍数时会多花一帧**（`T = 0.5` 得 **31** 帧而非 30，
   `T = 0.25` 得 **16** 帧而非 15）：`_cast_timer` 是赋值，`0.5 - 30 × delta` 的浮点余量为正，
   要到第 31 帧才跨过 0。故**实际射速恒 ≤ 名义值，永不超发** —— 这是好事，但意味着名义
   「2 次/秒」实际是 1.94 次/秒。
4. 综上，原 `hard: 0.01` 在两把主力杖上都**深深落在饱和区内**：`wand_basic` 白费 3.3 倍区间、
   `wand_fast` 白费 6.7 倍。玩家在货架 C 花钱把 `cast_delay_mod` 从 0.033 买到 0.01，**射速一点没变**。

#### 决定（用户 2026-07-31）

`hard: 0.01 → 0.05`。

**关键前提：饱和点 = `(1/60) / 法杖基准间隔`，随杖而异，因此不存在对所有杖都最优的单一 `hard`。**
`hard` 是**全局**的一个数，而每把杖的饱和点各不相同（0.0278 / 0.0333 / 0.0667），任何取值都只能覆盖一部分：

| `hard` 取值 | `circuit_fork`(0.0278) | `wand_basic`(0.0333) | `wand_fast`(0.0667) |
| :-- | :-- | :-- | :-- |
| 0.01（原值） | 无效区间 0.0278→0.01 | 无效区间 0.0333→0.01 | 无效区间 0.0667→0.01（6.7 倍）|
| **0.05（已采用）** | ✅ 无无效区间 | ✅ 无无效区间 | ⚠️ 仍剩 `[0.05, 0.0667)` |
| 0.0667 | ✅ | ✅ | ✅ 无无效区间 | 

—— 但 `0.0667` 会让 `wand_basic`（饱和 0.0333）与 `circuit_fork`（0.0278）**够不到自己的饱和点**，
即它们的最高射速被硬上限提前截断，**制造出新的"不可达区间"**（想更快但买不到）。

**故 0.05 是折中**：让**多数杖**（`wand_basic` / `circuit_fork`，以及所有基准间隔 ≥ 0.333 s 的杖）
无无效区间，只在最快的 `wand_fast` 上留 `[0.05, 0.0667)` 这一小段。相对原 `0.01`
（两把主力杖都深陷饱和区）是大幅改进，但**不是"两把主力杖都不再有无效区间"** ——
`wand_fast` 那段仍在，只是从 6.7 倍缩到 1.33 倍。

> **📌 订正记录（2026-07-31）**：本节初稿曾写「`wand_fast` 恰好在 0.05 饱和 / 两把主力杖都无无效区间」，
> **算错了** —— 按同一节自己给出的公式 `(1/60)/0.25 = 0.0667`，不是 0.05。实测数据表（§2.2b 上方）
> 记的 0.0667 一直是对的，错的只是从那句口头转述派生出来的几处标注。已连同
> `numerical_design.md` §1.1、roadmap E3-①、plan Step 7b② 一并订正。
> **教训与本期其余三条同源**：结论若与自己上一句的公式矛盾，是最容易被略过的一类错 ——
> 因为读者会假定作者算过。

**明确不做**：不在代码里加下限钳制。下限已由 `attributes.json` + `AttributeFormula` 的
`maxf(hard, ...)` 强制；在 `player_manager` 再加一个会形成**重复权威**，设计师调 `hard` 时立刻脱同步，
违反 CLAUDE.md「纯数据驱动，代码不含硬编码副本或回退」。

#### 改动后的边界复测（`hard = 0.05` 落地后，同一套扫描）

| 法杖 | 请求 `mod` | 生效 `mod` | 生效间隔 T | 实测帧距 | 实际速率 | 判读 |
| :-- | --: | --: | --: | --: | --: | :-- |
| `wand_fast` | 0.05 | 0.05 | 0.0125 s | **1 帧×179** | 60 /s | ⚠️ 已达 60 /s，但饱和点是 0.0667 —— `[0.05, 0.0667)` 这段买了没用 |
| `wand_fast` | 0.01 | **0.05** | 0.0125 s | 1 帧×179 | 60 /s | ✅ `maxf(hard, …)` 生效，越界请求被钳回 |
| `wand_basic` | 0.05 | 0.05 | 0.025 s | **2 帧×89** | 30 /s | ✅ |
| `wand_basic` | 0.01 | **0.05** | 0.025 s | 2 帧×89 | 30 /s | ✅ 钳制生效 |
| `wand_basic` | 0.1（soft） | 0.1 | 0.05 s | 3 帧×59 | 20 /s | ✅ 对照，soft 段未受影响 |
| `circuit_fork` | 0.05 | 0.05 | 0.03 s | **2 帧×89** | 30 /s | ✅ 见下方遗留 |

> **遗留（实测）**：`circuit_fork`（0.6 s）在新 `hard` 0.05 处实测为 **2 帧/次（30 /s）** ——
> 它的饱和点 0.0278（公式推导）低于 0.05，故**没有**无效区间，但也吃不满 60 /s；`wand_basic`
> 同理（0.0333 < 0.05，30 /s）。反过来 `wand_fast`（0.0667 > 0.05）能吃满 60 /s，代价是
> `[0.05, 0.0667)` 那段无效区间。
>
> **这正是"单一全局 `hard` 撞上逐杖饱和点"的必然取舍**：`hard` 偏小则慢杖有无效区间，偏大则
> 快杖被截断。0.05 选择了**让无效区间只留在一把杖上**，因为无效区间是"花了钱没效果"（欺骗），
> 而吃不满硬顶只是"上限没那么高"（诚实）。若日后要彻底消除，正确做法是让 `hard` 逐杖化
> （即把它变成 `cores.json` 的一个字段），而不是继续挪这个全局数 —— 那属于新的设计立项。

### 2.3 公式模块 —— `AttributeFormula`

**所有加成公式集中在一个专门模块**，与状态持有分离。

`scripts/domain/attribute_formula.gd`：
```gdscript
## AttributeFormula — 属性合成公式的唯一实现处
## 纯静态函数，无状态、不依赖 PlayerStats / EventBus / 场景树
class_name AttributeFormula
extends RefCounted

enum Combine { HYBRID, INVERSE, ADD_INT }

## 唯一的公式入口：base + 加成列表 + 属性定义 → 生效值
static func compute(base: float, mods: Array, attr_def: Dictionary) -> float

## combine 字符串（JSON）→ 枚举；未知值 push_error 并回退 HYBRID
static func combine_from_string(s: String) -> Combine
```

设计要点：

1. **纯函数、零依赖** —— 因此**不需要启动游戏就能测**。公式正确性（连乘 vs 线性、`inverse` 方向、`add_int` 拒绝 `pct`、`hard` 两个方向的钳制）全部是 `execute_editor_script` 里的静态函数断言，比在运行中的战斗场景构造玩家状态更快、更确定，且改公式时能立刻回归。
2. **公式的唯一实现处** —— 改公式只需读这一个文件，不必翻还管着 HP/金币/经验/魔力/无敌帧的 `PlayerStats`。
3. `class_name` 注册的 `RefCounted` 值对象，与 `cast_stats.gd` 同类，放同一层级风格的 `scripts/domain/`。

> **实现注意**：`class_name` 脚本会触发 `validate_script` 的既有假阴性（报 `hides a global script class`）—— 工具问题非代码问题。验证用既有绕法：`GDScript.new()` + 从磁盘读 `source_code` + `reload()`。

### 2.4 `PlayerStats` 结构

`PlayerStats` 瘦回单一职责 —— **只负责加载数据、持有加成列表、把公式结果写进类型化字段**，一个公式都不含。

```gdscript
# 生效值：静态类型裸字段，读取端零开销（move_speed 每物理帧被 player_manager 读）
var move_speed:     float = 200.0
var cast_delay_mod: float = 1.0
var cpu_limit:      int   = 0

var _attr_def:  Dictionary = {}          # attributes.json 全量定义，只读
var _modifiers: Array[Dictionary] = []   # 加成来源；本期恒空

func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void
func remove_modifiers_from(source: String) -> void
func _recompute_attrs() -> void          # 逐属性调 AttributeFormula.compute，写回裸字段
```

`compute` 统一返回 `float`；`add_int` 属性在写回时由 `PlayerStats` 做 `int()` 转换（`cpu_limit = int(AttributeFormula.compute(...))`）。公式模块不感知目标字段的静态类型，保持纯粹。

**读取端永远是裸字段访问** —— 零开销、静态类型，符合 CLAUDE.md 的热路径纪律（`move_speed` 每物理帧被读，字典查找不可接受）。写入端集中在 `_recompute_attrs()`。

`remove_modifiers_from` 本期就加：子计划 ④「出售退款」必然需要按来源撤销加成，届时不加就要改签名或加并行结构。

> **本期已有的真实加成来源**：法杖的 `cpu_limit` 以 `source = "core"` 接入（§2.2），故 `_modifiers` 从第一天起就非空，加成层不是「有机制无消费者」。
>
> **本期确实无消费方的部分**：`soft` 字段（钳制只用 `hard`）。保留理由是它属于权威表已写死的设计数据，不存反而丢失信息，且子计划 ②「货架 C」要靠它区分「常规来源可达」与「需特殊来源突破」。`pct` 模式本期也无来源使用（`"core"` 用的是 `flat`），但公式与其单元断言一并落地 —— 否则货架 C 落地时才第一次验证乘算，风险后置。

### 2.5 三处接线

| 位置 | 改动 |
| :-- | :-- |
| `spell_evaluator.gd:332` | `MAX_OPS_PER_CPU * 5` → `MAX_OPS_PER_CPU * PlayerStats.cpu_limit`（法杖份额已由 §2.2 的 `"core"` 加成来源计入） |
| `combat_manager._rebuild_wand` | 换杖/换牌时 `remove_modifiers_from("core")` + `add_modifier("cpu_limit", "flat", core.cpu_limit, "core")`，与既有 `mana_leech` 重算同处 |
| `player_manager.gd:7,45` | 删 `const MOVE_SPEED`，改读 `PlayerStats.move_speed` |
| `player_manager.gd:88` | `_cast_interval = core.cast_interval` → `× PlayerStats.cast_delay_mod` |

### 2.6 删除孤儿字段

删 `PlayerStats.armor`（:22）与 `PlayerStats.resistance`（:23）。

理由：零消费方、不在权威属性表内 —— 留着会误导（下一个人看到 `var resistance: float = 0.0  # 0.0~1.0` 会以为玩家抗性已设计好只差接线）。

> **真要做玩家侧抗性/护甲时怎么立项**：它是**货架 C 属性词条**的好候选（子计划 ②）—— 届时有明确消费场景（花钱买减伤），连同权威 §1.1 表一起补充定义，而非像现在这样悬空。敌人侧已有 armor（`enemy_manager`）与 per-element resistance（E1-① 已上线），玩家侧做成镜像是自洽的，但那是**扩充权威属性表**的设计决策，需走正常立项流程。

### 2.7 数据与设计器

`data/attributes.json`：
```json
{
  "cpu_limit":      { "display_name": "运算力 cpu_limit",     "base": 0.0,   "soft": 20.0,   "hard": 50.0,  "combine": "add_int" },
  "move_speed":     { "display_name": "移动速度 move_speed",   "base": 200.0, "soft": 600.0,  "hard": 800.0, "combine": "hybrid"  },
  "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod","base": 1.0,   "soft": 0.1,    "hard": 0.01,  "combine": "inverse" },
  "hp_max":         { "display_name": "最大生命 hp_max",       "base": 100.0, "soft": 2000.0, "hard": 0.0,   "combine": "hybrid"  }
}
```
`hard: 0.0` 表示无硬上限（`hybrid` 下 0 视为不钳制；`inverse` 的 `hard` 是下限，不适用此约定 —— 见 §2.1）。

设计器新增「属性」页（`addons/game_designer/attribute_tab.gd`）：每属性一行，`base`/`soft`/`hard` 用 `UI.spin`、`combine` 用 `UI.opt`（存 key 不存显示串），标签「中文 English」双语序，经 `designer_ui.gd` 工厂创建。按 CLAUDE.md「新增分页 3 步」注册。

## 3. 影响文件

| 文件 | 改动 |
| :-- | :-- |
| `scripts/domain/attribute_formula.gd` | **新增** —— 公式模块 |
| `scripts/autoloads/player_stats.gd` | 属性字段 + 加成列表 + `_recompute_attrs`；删 `armor`/`resistance` |
| `scripts/domain/spell_system/spell_evaluator.gd` | `:332` MAX_OPS 接线 |
| `scripts/domain/player_manager.gd` | 删 `const MOVE_SPEED`；移速与施法间隔接线 |
| `data/attributes.json` | **新增** |
| `addons/game_designer/attribute_tab.gd` | **新增** 设计器页 |
| `addons/game_designer/designer_panel.gd` | 注册新分页 |
| `docs/design/numerical_design.md` | §1.1 `move_speed` 基准 300 → 200 并注明理由 |

## 4. 验收标准

**公式类（纯函数单元断言，无需启动游戏）**：

1. `hybrid` 连乘：三条 `+20% pct` → ×1.728（**非** ×1.6，证明是连乘而非线性求和）。
2. `hybrid` 混合：`base 200 + flat 50` 再 `×(1+0.2)` → 300。
3. `inverse` 方向：`base 1.0` + 三条 `−20% pct` → 0.512；再多条也不小于 `hard`（0.01）。
4. `add_int` 拒绝 `pct`：传入 `pct` 加成时 `push_error` 且该条被忽略，`flat` 仍正确累加。
5. `hard` 两个方向：`hybrid` 上限钳制、`inverse` 下限钳制；`hard: 0.0` 在 `hybrid` 下表示不钳制。
6. 边界：空加成列表 → 返回 base；未知 `combine` 字符串 → `push_error` 并回退 `hybrid`。

**接线类（运行时实测）**：

7. `cores.json` 的 `cpu_limit` 生效：矩阵板(8) → MAX_OPS 320、高速法杖(3) → 120（改前恒为 200）。
8. **现有平衡零改动**：小木法杖(5) 仍为 MAX_OPS 200。
9. **换杖时加成来源正确轮换**：小木(5) → 矩阵板(8) 后 `PlayerStats.cpu_limit` 为 8 而非 13（证明 `remove_modifiers_from("core")` 先于 `add_modifier` 生效，旧法杖份额未累积）。
10. `move_speed` 数据驱动：改 `attributes.json` 后玩家实际移速跟随变化。
11. `cast_delay_mod = 0.5` → 实际 `_cast_interval` 减半。
12. `remove_modifiers_from(source)` 精确撤销该来源的全部加成且重算正确 —— 混入一条他源加成，撤销 `"core"` 后他源仍在。

**清理与规范**：

12. `armor` / `resistance` 全项目零残留（含存档 `to_dict`/`from_dict`）。
13. 设计器「属性」页对真实 `data/attributes.json` 回读往返一致；`combine` 下拉存 key 不存显示串。
14. 解析通过（`class_name` 脚本用 `GDScript.reload()` 绕法验证）；纯数据驱动，文件缺失时 `push_error` 明确报错不静默回退。

## 5. 非目标（YAGNI）

- `attunement_×4` / `luck` / `recharge_speed_mod` —— 见 §0，各自独立立项。
- `mana_max` 的「与法杖取 Min」语义 —— 见 §2.2，独立设计判断。
- 打通暴击链 —— 见 §1.3，`luck` 的前置。
- 货架 C（属性购买 UI 与经济）—— 子计划 ②；本期只提供 `add_modifier` 接入点。
- 升级时自动加属性 —— 本期不引入任何加成来源。
- 玩家侧 armor/resistance —— 见 §2.6，删除并记录立项路径。
- 存档迁移 —— `armor`/`resistance` 从未被写入过有意义的值，旧存档读取时忽略即可，不需要 schema 版本号提升。
