# 货架 C（属性购买）设计

> **日期**：2026-07-31
> **Epic**：E3 经济与 build · 子计划 ②（缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`）
> **优先级**：P0 · 工作量 S~M
> **目标**：商店可购买属性提升，消耗金币，经 E3-① 的加成层叠加到 `PlayerStats`；新增 `PriceFormula` 定价模块。

---

## 0. 范围与前置

**依赖**：E3-①「属性词条系统」已完成（`master` tip `9361bf8`），提供 `AttributeFormula`、`PlayerStats.add_modifier` / `remove_modifiers_from(source)`、`attributes.json` 的 `soft` 字段（E3-① 特意保留、当时无消费方，本期接上）。

**本期做**：可售属性的数据段、`PriceFormula` 模块、`ShopManager` 的货架 C 状态与购买流程、局内存档、商店 UI、设计器扩展、权威文档补录。

**本期不做**（各有独立理由，见 §1.2）：把 B 类「已定义但被阻塞」的 7 个属性变为可售；出售退款（子计划 ④）；货架 B 核心抽取（子计划 ③）；E7-⑥ 计算模块化重构（已单独立项）。

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

### 1.1 商店现状

`shop_manager.gd` 仅有货架 A：`current_slots`（3 个法术槽）、`buy_spell`、`reroll`（费用 `20 + n×10`，P6-N23，波次结算后重置）。grep 确认无 shelf/sell/core/attr 任何字段。商店 UI 全程序化，在 `combat_s2.gd:281 _setup_shop_ui()`（3 个槽位按钮 + 刷新 + 法杖 + 背包）。

### 1.2 角色属性全貌盘点

货架 C 的可售集合会持续扩展，故先盘清全貌。**只有 A 类今天可卖。**

**A. 已在属性框架内 —— 4 个（本期可售）**

| id | base | soft | hard | combine | 消费点 |
| :-- | --: | --: | --: | :-- | :-- |
| `cpu_limit` | 0 | 20 | 50 | `add_int` | `spell_evaluator` 的 `MAX_OPS = 值 × 40` |
| `move_speed` | 200 | 600 | 800 | `hybrid` | `player_manager` 每物理帧 |
| `cast_delay_mod` | 1.0 | 0.1 | 0.05 | `inverse` | `_handle_auto_cast` 开火时 |
| `hp_max` | 100 | 2000 | 无 | `hybrid` | 治疗钳制 / 血量百分比 / 卖血上限 |

**B. 权威表已定义、未进框架 —— 7 个（各有明确前置，本期不做）**

| id | 权威 base/soft/hard | 卡在哪 |
| :-- | :-- | :-- |
| `mana_max` | 100 / 1000 / — | 现状由核心 `base_mana_max` 直接覆盖；权威 §1.1 说「与法杖取 Min」，该语义未决（E3-① 记为遗留） |
| `recharge_speed_mod` | 1.0 / 5.0 / — | **充能系统不存在**（全项目 grep `recharge` 零命中） |
| `luck` | 0 / 100 / — | 权威说它影响「高阶法术掉率 + 暴击率」，**两条都没接**：暴击链是死的（`bullet_manager.gd:81` 把 `is_crit` 硬编码 `false`），掉率无 luck 输入 |
| `attunement_fire/ice/lightning/poison` | 0 / 50 / 100 | 每点 +3% 元素伤 + 2% 状态触发率，需接入伤害管线与状态系统 |

**C. `PlayerStats` 实际字段但不在权威表内 —— 4 个**

| 字段 | 性质 | 写入方 |
| :-- | :-- | :-- |
| `mana` | 运行时状态，非属性 | 施法消耗 / 每帧回复 |
| `mana_regen` | **事实上的属性**，被核心覆盖 | `set_mana_pool` ← `core.base_mana_regen` |
| `mana_leech` | **事实上的属性**，多来源重算 | `_rebuild_wand` ← 核心 + 卡组 `meta.mana_leech` |
| `mana_heat` | 运行时状态（递增蓝耗热值） | 每次施法 +，每帧衰减 |

> ⚠️ `mana_regen` / `mana_leech` 是**权威表漏了的真实属性**（有基准值、被多来源修正、影响玩法）。而权威给货架 C 举的例子「升级回蓝速度」指的正是 `mana_regen` —— 它是最可能先补进框架的候选。

**D. 资源与进度（不是属性，不上架）**：`hp`（当前值）、`gold`、`xp`、`level`、`xp_to_next`、`_invuln_until_msec`。

**E. 法杖侧属性（属于装备，是货架 B 的范畴）**：`CoreDefinition` 的 `slot_count` / `cpu_limit` / `cast_interval` / 拓扑 / `feature_tags` / `base_mana_max`+`regen`+`leech`。

**F. 全局配置（影响角色但非角色属性，设计师调不该按局买）**：`balance.json` 的 `player_iframe_sec`、`difficulty_mults.player_dmg`、`mana_heat{...}`、`infinite_spells{...}`。

### 1.3 权威文档说了什么

`docs/design/game_design.md §5`：货架 C = 「基础属性提升（生命值、暴击率）」，位于**局内循环**的「购物」环节，与货架 A/B 并列。经济策略原文：

> 是买一个强力的"黑洞"法术？还是**把钱**用来升级"回蓝速度"以支撑现有的构建？

**结论：局内成长、消耗金币、与法术购买抢同一笔钱。** 路线图写的「金币/以太」是含糊 —— 以太（`MetaProgress.aether`）存在 `user://meta.json`、跨局持久、用于**永久解锁法术**，与货架 C 语义不符。

这也意味着 E3-① 的 `reset_for_run()` 清空 `_modifiers` 是**正确的**，无需改动。

> 权威举的三个例子中，「生命值」可用，「暴击率」被暴击链阻塞，「回蓝速度」(`mana_regen`) 不在框架内。**举例是示意不是规格** —— 本期按「框架内现成可卖的」取集合。

### 1.4 定价无权威（本设计需补录）

`numerical_design.md` §1.2 经济系统的**消耗清单只有三项**：购买法术 (50-500G)、购买法杖 (200-2000G)、刷新商店 (20G)。**属性购买缺席**；§1.1 也只给 base/soft/hard，无成长曲线。

故本期定价属**设计而非查表**，且定稿后必须补回 §1.2（见 §2.7）。

> 与归航（E1-③）那次的对照：那次是**有权威而没查**（`numerical_design` 的 Mana 列与每个已实现法术吻合，证明它是活的），属真错误。本次是**查了、确认没有**。「以权威为准」的前提是先确认权威存在。

## 2. 设计

### 2.1 `PriceFormula` —— 定价的唯一实现处

**架构原则（用户 2026-07-31 指定）**：凡涉及计算的都由专门功能模块负责，便于测试与配置。`AttributeFormula` 是已验证的模板。

`scripts/domain/price_formula.gd`：
```gdscript
## PriceFormula — 价格计算的唯一实现处
## 纯静态函数：无状态、不依赖 ShopManager / PlayerStats / 场景树，故可脱离游戏进程单元测试
class_name PriceFormula
extends RefCounted

enum Curve { GEOMETRIC, LINEAR, FLAT }

## 唯一入口。spec 来自数据文件；本模块**不认识**「属性/法杖/装备」，只认识 {price_base, price_growth, curve}
## purchased —— 已购次数（0 = 首次购买）
static func compute(spec: Dictionary, purchased: int) -> int

## curve 字符串 → 枚举；未知值 push_error 并回退 GEOMETRIC
static func curve_from_string(s: String) -> Curve
```

| curve | 公式 |
| :-- | :-- |
| `geometric`（默认） | `price_base × price_growth^n` |
| `linear` | `price_base + n × price_growth` |
| `flat` | `price_base` |

返回 `int`（金币是整数），内部 `roundi`；下限 1（免费购买无意义）。

**模块对「卖什么」一无所知**，这是货架 B（核心抽取）与子计划 ④（出售退款）能直接复用它的前提。「按不同属性、武器、装备细致划分」的划分点在**数据**里，不在模块里。

### 2.2 `attributes.json` 的 `shop` 段

```json
"hp_max": {
  "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid",
  "shop": { "mode": "pct", "step": 0.10, "price_base": 60, "price_growth": 1.15, "curve": "geometric" }
}
```

**`shop` 段缺失 = 不可售。** 这自动覆盖 §1.2 B 类那 7 个被阻塞的属性 —— 它们将来进框架时不写 `shop` 段即不上架，前置解决后加一段即可，**零代码**。

本期初值（设计器可调，真正的平衡交给试玩）：

| 属性 | mode | step | price_base | price_growth | 到 `soft` 约需 |
| :-- | :-- | --: | --: | --: | --: |
| `hp_max` | `pct` | 0.10 | 60 | 1.15 | 31 次 |
| `move_speed` | `pct` | 0.08 | 70 | 1.15 | 14 次 |
| `cast_delay_mod` | `pct` | 0.10 | 90 | 1.20 | 22 次 |
| `cpu_limit` | **`flat`** | 1 | 120 | 1.30 | 12–17 次 |

**`cpu_limit` 必须 `flat`** —— `AttributeFormula` 的 `add_int` 明确拒绝 `pct` 并 `push_error`（乘算对离散指令预算无意义）。这条硬约束正是「增量必须逐属性配置」的原因，不是偏好。

首购 60–120G 与最便宜的法术（50G）同档，形成真实竞争（每波收入约 150–250G：击杀 1–5G + 波次结算 100G + 10% 利息）。

**为什么 `pct` 配 `geometric`**：`pct` 模式下第 n 次购买的**绝对收益**是 `base × (1+step)^(n-1) × step`，本身几何增长；价格若只线性涨，后期购买会越来越划算，最优解退化为「无脑堆一条轨」，取舍消失。取 `price_growth` 略大于 `1+step` 得到温和的边际递减 —— 每次仍值得买，但一直堆同轨会逐渐不划算，自然鼓励铺开。

> **刷新费用（P6-N23）用线性是对的**，因为刷新的价值每次都一样；属性购买的价值在复利，情况不同，不该照搬。

**价格递增按「本局该属性累计购买次数」计，不随波次重置**（与刷新费用相反）—— 它代表累计投资深度，波次重置会让「每波买一次」变成无脑最优。

### 2.3 `cast_delay_mod` 的饱和点够不到 —— `soft` 才是约束（自审订正）

E3-① 实测：`_handle_auto_cast` 用 `if` 而非 `while`，每物理帧至多施法一次，故存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic` 0.0333、`wand_fast` 0.0667、`circuit_fork` 0.0278。

**但正常购买够不到任何一个饱和点。** `cast_delay_mod` 越低越快，购买从 1.0 往下走，而 `soft`(0.1) 先把你拦住；三个饱和点（0.0333 / 0.0667 / 0.0278）**全部低于 0.1**：

| 法杖 | 基准间隔 | `soft`(0.1) 时的实际间隔 | 帧距 | 速率 | 饱和？ |
| :-- | --: | --: | --: | --: | :-- |
| `wand_basic` | 0.5 s | 0.050 s | 3 帧 | 20 /s | 否（饱和需 ≤ 0.0333） |
| `wand_fast` | 0.25 s | 0.025 s | 2 帧 | 30 /s | 否（饱和需 ≤ 0.0667） |
| `circuit_fork` | 0.6 s | 0.060 s | 4 帧 | 15 /s | 否（饱和需 ≤ 0.0278） |

**故货架 C 的 UI 只需 `soft` 一道闸，不需要动态的逐杖饱和判定。**

> 📌 **自审订正记录**：本节初稿写「饱和点高于 `soft`，买到 `soft` 之前就已饱和，UI 必须做逐杖动态判定」—— **方向搞反了**。`inverse` 属性的「高/低」在语义上易反：数值更低 = 更快 = 更好，故「饱和点 0.0333 低于 soft 0.1」意味着**先撞 soft**，而非先饱和。差点规定了一个永远触发不了的 UI 功能。教训与本项目已记录的三次「断言宽到抓不住」同源：**`inverse` 语义下任何「高于/低于」的论断都必须代入具体数字验算一次**。

饱和点只在**将来出现能突破 `soft` 的特殊来源**（传奇核心 / 元进展）时才成为约束，届时才需要那套判定。完整实测数据见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。

### 2.4 状态与唯一写入点

`ShopManager` 新增 `_attr_purchases: Dictionary`（`attr_id: String` → `次数: int`）。

购买流程：
```
校验 shop 段存在 → 校验金币足够 → 校验未达 soft / 未饱和
→ gold -= PriceFormula.compute(spec, n)
→ _attr_purchases[id] = n + 1
→ _apply_attr_purchases()
```

`_apply_attr_purchases()` 重建加成（与 `_rebuild_wand` 处理 `"core"` 完全同构）：
```gdscript
PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
for id in _attr_purchases:
    var n: int = _attr_purchases[id]
    if n <= 0: continue
    var sp: Dictionary = <attributes.json[id].shop>
    var merged: float = (pow(1.0 + step, n) - 1.0) if mode == "pct" else (step * n)
    PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C)
```

**每属性至多一条 `_modifiers`**，`value` 为合并值。选此形状而非「每次购买追加一条」的理由：
- `pct` 模式下 3 条 `+10%` 连乘为 ×1.331，**没有等价的单一 `pct` 值**（除非写 0.331）；合并时直接算 `(1+step)^n - 1`，语义明确，不必要求公式模块对同源多条做特殊处理。
- 出售退款（子计划 ④）只需次数减一后重算，**不需要给 `PlayerStats` 新增按条撤销的 API**。

**唯一写入点**：所有改动 `_attr_purchases` 的路径（购买、出售退款、存档回读、`reset`）都必须经 `_apply_attr_purchases()`。`_attr_purchases` 与 `_modifiers` 不同步会产生「显示买了 3 次但加成只有 2 次」，且**无任何诊断** —— 收敛写入点是唯一防线。

新增常量 `ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`（理由同 `PlayerStats.MOD_SOURCE_CORE`：来源串在 remove/add 两侧必须逐字相同，打错即静默累积）。

### 2.5 持久化

**只存 `_attr_purchases`，不存 `_modifiers`** —— 后者是前者的派生物，回读时经 `_apply_attr_purchases()` 重建。

这躲开了 E3-① 在 `player_stats.gd` 留的警告：`_modifiers` 是 `Array[Dictionary]`，而 `JSON.parse_string` / `data.get(..., [])` 产出无类型 `Array`，直接 `=` 赋值是运行时类型错误，必须 `.assign()`。存派生源头则完全不涉及这个坑。

- 进**局内存档**（`ProfileManager` run_a/run_b），不进 `meta.json` —— 货架 C 是局内成长（§1.3）。
- `SCHEMA_VERSION` 2 → 3；旧存档无该键 → 视为空字典（无购买），不需要迁移逻辑。
- 回读顺序：先 `_attr_purchases` 赋值，再 `_apply_attr_purchases()`，最后才是依赖 `hp_max` 的量（E3-① 记录的顺序陷阱：`_recompute_attrs` 的 `hp = minf(hp, hp_max)` 若拿未加成的 `hp_max` 去钳会静默吞血）。
- `ShopManager.reset()` 清空 `_attr_purchases`（`PlayerStats.reset_for_run()` 已清 `_modifiers`）。

### 2.6 UI 与设计器

**商店面板**（`combat_s2.gd` 现有程序化风格，项目无 `.tscn` UI）新增「属性」区：每个可售属性一行 —— 名称 / 当前生效值 / 下次价格 / 购买按钮。禁用态需**说明原因**（金币不足 / 已达 `soft` 上限），而非只是灰掉。

**设计器「属性」页**扩展 `shop` 段五字段：`mode` 下拉（存 key 不存显示串）、`step` / `price_base` / `price_growth` SpinBox、`curve` 下拉。

⚠️ **`UI.spin` 的 `min` 必须为 `0.0`** —— E3-① 实测：大负数 `min` 与细 `step` 相差数量级时，`Range` 的 `round((v-min)/step)*step+min` 产生抵消误差（`0.01 → 0.00999999999476`），会把噪声写进权威数据文件，且容差断言察觉不到。

### 2.7 权威文档补录

定稿后把属性购买补进 `numerical_design.md` §1.2 的**消耗清单**（现仅法术/法杖/刷新三项）：各属性的首购价与成长曲线、「按累计购买次数递增、不随波次重置」的规则、以及 `cast_delay_mod` 的饱和点上限。

这是本设计的**组成部分而非可选项** —— §1.4 查明该清单缺失属性购买，补上它才闭环。

## 3. 影响文件

| 文件 | 改动 |
| :-- | :-- |
| `scripts/domain/price_formula.gd` | **新增** —— 定价模块（+ `.gd.uid`） |
| `data/attributes.json` | 四个属性各加 `shop` 段 |
| `scripts/autoloads/shop_manager.gd` | `_attr_purchases` + `MOD_SOURCE_SHOP_C` + 购买/校验/`_apply_attr_purchases` + `reset` |
| `scripts/autoloads/profile_manager.gd` | `SCHEMA_VERSION` 2 → 3；`_collect_run_data` 写入、`apply_run` 回读（**顺序必须早于 `load_save_data`**，见 §2.5） |
| `scenes/main/combat_s2.gd` | 商店「属性」区 UI |
| `addons/game_designer/attribute_tab.gd` | `shop` 段五字段 |
| `docs/design/numerical_design.md` | §1.2 消耗清单补属性购买 |

## 4. 验收标准

**纯函数（不启动游戏，`ResourceLoader.load(..., CACHE_MODE_IGNORE)` 加载）**：
1. `geometric`：`{base:60, growth:1.15}` + `purchased 0/1/2` → `60 / 69 / 79`。
2. `linear`：`{base:20, growth:10}` + `0/1/2` → `20 / 30 / 40`（与刷新费用 P6-N23 同构，作交叉验证）。
3. `flat`：任意 `purchased` 恒等于 `price_base`。
4. `purchased = 0` 恒返回 `price_base`（三种曲线皆然）。
5. 未知 `curve` 字符串 → `push_error` 且回退 `geometric`（**用唯一标记值 + Debugger 错误树断言报错真的发出**）。
6. 返回类型为 `int`，且下限 1。

**运行时**：
7. 买 3 次 `hp_max` → 生效 `hp_max = 100 × 1.1³ = 133.1`（**验证连乘而非线性的 130**）。
8. 金币按几何序列扣除（60 → 69 → 79），且 `_attr_purchases` 与生效值一致。
9. 达 `soft` 后按钮禁用且 `buy` API 拒绝。
10. `cast_delay_mod` 买到 `soft`(0.1) 即禁用；并断言此时**尚未饱和**（`wand_basic` 实测 3 帧/次 = 20 /s，非 1 帧）—— 证明 §2.3 的订正成立、无需逐杖饱和判定。
11. `cpu_limit` 的 `shop.mode` 若误配为 `pct` → `add_modifier` 拒绝并 `push_error`（`add_int` 硬约束）。
12. 存档 → 回读：次数与生效值均还原；旧存档（无该键）不崩、视为零购买。
13. `reset_for_run` / `ShopManager.reset()` 后次数与加成均清零。
14. 与 `"core"` 来源共存：换杖不影响货架 C 的加成，反之亦然。

**规范**：
15. 设计器 `shop` 段回读往返**逐位相等**（不用容差 —— E3-① 栽过三次）；`mode`/`curve` 存 key 不存双语显示串。
16. 无 `shop` 段的属性不上架。
17. `validate_script` 通过（`price_formula.gd` 有 `class_name`，须用 `CACHE_MODE_IGNORE` 验证，`validate_script` 对其必然假阴性）。

## 5. 非目标（YAGNI）

- **B 类 7 个属性变为可售** —— 各有前置（§1.2），前置解决后加 `shop` 段即可，零代码。
- **`mana_regen` / `mana_leech` 迁入属性框架** —— 它们是权威表漏掉的真实属性、也是最可能的下一批候选，但迁移是独立工作（`mana_max` 还牵连「与法杖取 Min」的未决语义）。
- **出售退款 G5** —— 子计划 ④。本期的 `_attr_purchases` 形状已为其铺好（次数减一后重算即可）。
- **货架 B 核心抽取** —— 子计划 ③。`PriceFormula` 已设计为可直接复用。
- **随机属性词条 / 稀有大幅提升** —— 固定轨先行，将来觉得缺惊喜再议。
- **以太（跨局）购买属性** —— 语义不符（§1.3）。
- **E7-⑥ 计算模块化重构** —— 已独立立项（路线图 E7-⑥）。`PriceFormula` 会成为第二个已模块化的例子，与 `AttributeFormula` 一起确立命名/签名/测试惯例。
