# 货架 C（属性购买）Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 商店可购买属性提升，消耗金币，经 E3-① 的加成层叠加到 `PlayerStats`；新增 `PriceFormula` 定价模块。

**Architecture:** 定价集中于 `PriceFormula`（`class_name` 纯静态、零依赖，故可脱离游戏进程单元断言），模块**不认识**「属性/武器/装备」，只认识 `{price_base, price_growth, curve}` —— 划分点在数据里，故货架 B 与出售退款可直接复用。可售集合由 `attributes.json` 的 `shop` 段驱动，**缺段即不可售**。`ShopManager` 持 `_attr_purchases`（attr_id → 次数），每次变动经**唯一写入点** `_apply_attr_purchases()` 重建 `_modifiers`（每属性一条合并值），与 `_rebuild_wand` 处理 `"core"` 同构。存档只存次数，加成回读时重建。

**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro（纯函数经 `execute_editor_script` 断言；集成经 `execute_game_script` 实测）。

## Global Constraints

- **纯数据驱动**：内容只存在于 `res://data/*.json`；代码不含硬编码副本或回退；文件缺失/格式错误 `push_error` 明确报错，不静默用旧值。
- **静态类型**：所有变量与函数签名标注类型（`: float` / `-> void`），启用引擎优化。
- **热路径纪律**：`_apply_attr_purchases()` 是冷路径（购买/换杖/回读时触发），可分配；但 `PlayerStats` 的读取端必须保持裸字段访问。
- **提交中文**，简明说明「做了什么、为什么」。
- **不要提交无关文件**：`git status` 确认范围。
- **spec**：`docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md`（验收标准见其 §4）。

### 已知工具坑（E3-① 实测得出，均为假通过陷阱）

1. **运行中游戏的 `push_error` / `push_warning` 既不进 `get_output_log` 也不进 `get_editor_errors`**，只有 `print()` 进。真通道是编辑器 Debugger 的「错误」`Tree`（经 `execute_editor_script` 走 `ScriptEditorDebugger`，每次 `play_scene` 自动重置）。**任何「零报错」结论必须配阳性对照** —— 先故意触发一次，确认它出现在你读的通道里。
2. **改完源码后用已注册的全局类名或编辑器 autoload 实例验证，会拿到过时结果**（编辑器持有旧编译副本，E3-① 两次差点被骗）。验证 `class_name` 脚本用 `ResourceLoader.load(path, "GDScript", ResourceLoader.CACHE_MODE_IGNORE)` —— 绕缓存从磁盘重编译、保留 `class_name`、走引擎自己的编译器。
3. `validate_script` 对任何带 `class_name` 的脚本必然假阴性（`hides a global script class`）。`price_formula.gd` 属此类；`shop_manager.gd` / `profile_manager.gd` / `combat_manager.gd` / `combat_s2.gd` / `attribute_tab.gd` 无 `class_name`，失败即真错误。
4. **`clear_output` 不清 `get_output_log` 的缓冲区** —— 要证明「零警告」须先快照基线再逐行比对。
5. **编辑器里 autoload 根本没实例化**（`root` 只有 `EditorNode`），依赖 autoload 的验证必须 `play_scene`。
6. `execute_editor_script` 静默阻止运行时 `FileAccess.WRITE`，即使传 `allow_unsafe_editor_io=true`。
7. **`var x := <Variant 方法调用>` 是编译错误且无行号**（`Script compilation failed`）。持有动态 `GDScript` 引用时必须写 `var r: int = F.compute(...)`。
8. 新增 `.gd` 后 `.gd.uid` 不会自动出现，需 `EditorInterface.get_resource_filesystem().scan()` 触发；项目约定二者一并提交。
9. **写断言时先问：把被测代码删掉，这条断言会变红吗？** E3-① 有三条断言答案是「不会」（闭包按值捕获 `int` 使计数恒 0、`0.0001` 容差抓不住 5e-12 漂移、断言里重算了被测公式）。守数据往返用**逐位相等**而非容差。

### 关键既有事实（已核对，行号为改动前）

- `shop_manager.gd`：`SLOT_COUNT=3`（:9）、`REROLL_BASE_COST=20`（:10）、`REROLL_STEP=10`（:11）、`current_slots`（:13）、`reroll_count`（:14）、`_shop_seed`（:15）、`buy_spell`（:55）、`reroll`（:69）、`get_reroll_cost`（:79）、`close_shop`（:82）、`get_shop_seed`（:86）、`reset`（:89）。
- `player_stats.gd`：`MOD_SOURCE_CORE`（:15）、`spend_gold`（:75）、`get_save_data`（:164）、`load_save_data`（:167）、`add_modifier(attr_id, mode, value, source)`（:195）、`remove_modifiers_from(source)`（:206）。`add_modifier` **会校验** `attr_id` / `mode`，非法值 `push_error` 并忽略。
- `profile_manager.gd`：`SCHEMA_VERSION = 2`（:10）、`apply_run`（:63）、`_collect_run_data`（:88）、`_migrate_run`（:110）。
- `attributes.json` 现有四属性：`cpu_limit`（`add_int`, base 0, soft 20, hard 50）、`move_speed`（`hybrid`, 200/600/800）、`cast_delay_mod`（`inverse`, 1.0/0.1/0.05）、`hp_max`（`hybrid`, 100/2000/0）。
- 商店 UI 在 `scenes/main/combat_s2.gd:281 _setup_shop_ui()`（槽位按钮 :305、刷新 :309-314、法杖 :317、背包）。
- `AttributeFormula.compute(base: float, mods: Array, attr_def: Dictionary) -> float`；`add_int` **拒绝 `pct`** 并 `push_error`。

---

### Task 0: 建功能分支

- [ ] **Step 1: 从 master 切分支**

spec 与本计划已提交在 `master`；CLAUDE.md 规定主分支上不做功能开发。

```bash
git checkout -b feat/shelf-c-attribute-shop
```

Expected: `Switched to a new branch 'feat/shelf-c-attribute-shop'`

---

### Task 1: `PriceFormula` 定价模块（TDD）

**Files:**
- Create: `scripts/domain/price_formula.gd`（+ `.gd.uid`）

**Interfaces:**
- Consumes: 无（零依赖）
- Produces: `PriceFormula.compute(spec: Dictionary, purchased: int) -> int`；`PriceFormula.curve_from_string(s: String) -> PriceFormula.Curve`；`enum Curve { GEOMETRIC, LINEAR, FLAT }`

> 纯函数，**先写断言、看它失败、再实现**。断言经 `execute_editor_script` 执行（项目无测试框架）。

- [ ] **Step 1: 先确认断言会失败**

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
var exists: bool = ResourceLoader.exists("res://scripts/domain/price_formula.gd")
_mcp_print("exists=%s" % str(exists))
```
Expected: `exists=false`。若为 `true`，先确认不是残留。

- [ ] **Step 2: 实现 `PriceFormula`**

Create `scripts/domain/price_formula.gd`：
```gdscript
## PriceFormula — 价格计算的唯一实现处
## 纯静态函数：无状态、不依赖 ShopManager / PlayerStats / 场景树，故可脱离游戏进程单元测试
## 本模块**不认识**「属性/武器/装备」，只认识 {price_base, price_growth, curve} ——
## 划分点在数据里，故货架 B（核心抽取）与子计划 ④（出售退款）可直接复用。
## 权威来源：docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md §2.1
class_name PriceFormula
extends RefCounted

enum Curve { GEOMETRIC, LINEAR, FLAT }

const _CURVE_BY_NAME: Dictionary[String, Curve] = {
	"geometric": Curve.GEOMETRIC,
	"linear":    Curve.LINEAR,
	"flat":      Curve.FLAT,
}

## JSON 的 curve 字符串 → 枚举；未知值 push_error 并回退 GEOMETRIC
static func curve_from_string(s: String) -> Curve:
	if _CURVE_BY_NAME.has(s):
		return _CURVE_BY_NAME[s]
	push_error("PriceFormula: 未知 curve「%s」，回退 geometric" % s)
	return Curve.GEOMETRIC

## 唯一的价格入口
## spec      —— 数据文件里的定价段，读 price_base / price_growth / curve
## purchased —— 已购次数（0 = 首次购买）
## 返回 int（金币是整数），下限 1：免费购买无意义，且 0 价会让「买不起」的判定失效
static func compute(spec: Dictionary, purchased: int) -> int:
	var base: float = float(spec.get("price_base", 0.0))
	var growth: float = float(spec.get("price_growth", 1.0))
	var n: int = maxi(purchased, 0)
	var curve: Curve = curve_from_string(String(spec.get("curve", "geometric")))
	var raw: float = 0.0
	match curve:
		Curve.LINEAR:
			raw = base + float(n) * growth
		Curve.FLAT:
			raw = base
		_:
			raw = base * pow(growth, float(n))
	return maxi(roundi(raw), 1)
```

- [ ] **Step 3: 跑单元断言（spec §4 验收 1–4、6）**

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
# 工具坑 ②：用 CACHE_MODE_IGNORE 从磁盘重编译，保留 class_name 且走引擎自己的编译器
var F = ResourceLoader.load("res://scripts/domain/price_formula.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
if F == null:
	_mcp_print("LOAD_FAIL"); return

var GEO := {"price_base": 60, "price_growth": 1.15, "curve": "geometric"}
var LIN := {"price_base": 20, "price_growth": 10,   "curve": "linear"}
var FLT := {"price_base": 45, "price_growth": 9.9,  "curve": "flat"}

# 工具坑 ⑦：持有动态 GDScript 引用时必须写显式类型，不能用 :=
var cases := [
	["①geo n=0",   F.compute(GEO, 0), 60],
	["①geo n=1",   F.compute(GEO, 1), 69],    # 60×1.15 = 69.0
	["①geo n=2",   F.compute(GEO, 2), 79],    # 60×1.15² = 79.35 → 79
	["②lin n=0",   F.compute(LIN, 0), 20],    # 与刷新费用 P6-N23 同构，交叉验证
	["②lin n=1",   F.compute(LIN, 1), 30],
	["②lin n=2",   F.compute(LIN, 2), 40],
	["③flat n=0",  F.compute(FLT, 0), 45],
	["③flat n=7",  F.compute(FLT, 7), 45],    # 恒等于 price_base
	["⑥下限1",     F.compute({"price_base": 0, "price_growth": 1.0, "curve": "flat"}, 0), 1],
	["⑥负次数",   F.compute(GEO, -3), 60],    # maxi(purchased,0) → 视为首购
]
var fails := 0
var bad := []
for c in cases:
	if int(c[1]) != int(c[2]):
		fails += 1
		bad.append("%s got=%d want=%d" % [c[0], int(c[1]), int(c[2])])
# 返回类型必须是 int
var t: int = typeof(F.compute(GEO, 1))
if t != TYPE_INT:
	fails += 1
	bad.append("返回类型 %d 应为 TYPE_INT(%d)" % [t, TYPE_INT])
_mcp_print("FAILS=%d %s" % [fails, str(bad)])
```
Expected: `FAILS=0 []`

- [ ] **Step 4: 断言未知 curve 真的 `push_error`（验收 5）**

> 数字断言证明不了报错发出 —— 未知 curve 回退 geometric 后数字是**对的**，所以「回退正确」和「报错缺失」在数字上不可区分。必须单独观测报错通道（工具坑 ①/⑨）。

Run: godot-mcp-pro `clear_output`，然后 `execute_editor_script`：
```gdscript
var F = ResourceLoader.load("res://scripts/domain/price_formula.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
# 唯一标记值确保读到的不是历史遗留日志
var r: int = F.compute({"price_base": 60, "price_growth": 1.15, "curve": "zzzcurve7391"}, 2)
_mcp_print("fallback_price=%d (应=79，证明回退 geometric)" % r)
```
再 Run: godot-mcp-pro `get_editor_errors`，断言含 `未知 curve「zzzcurve7391」`。

Expected: `fallback_price=79` **且**日志命中该标记。缺任一即失败。

- [ ] **Step 5: 生成 `.gd.uid` 并提交**

工具坑 ⑧：`.gd.uid` 需扫描触发生成。Run: godot-mcp-pro `execute_editor_script`：
```gdscript
EditorInterface.get_resource_filesystem().scan()
_mcp_print("scan requested")
```
确认 `scripts/domain/price_formula.gd.uid` 已出现后：
```bash
git add scripts/domain/price_formula.gd scripts/domain/price_formula.gd.uid
git commit -F- <<'EOF'
feat(shop): PriceFormula 定价模块——geometric / linear / flat 三曲线

定价集中于单一纯静态模块，无状态零依赖，故可脱离游戏进程单元断言
（与 AttributeFormula 同构）。模块不认识「属性/武器/装备」，只认识
{price_base, price_growth, curve}，划分点在数据里——故货架 B 与出售退款
可直接复用，不必各写一套。

返回值下限 1：免费购买无意义，且 0 价会让「买不起」的判定失效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 2: `shop` 段数据 + `ShopManager` 货架 C 状态与购买

**Files:**
- Modify: `data/attributes.json`（四属性各加 `shop` 段）
- Modify: `scripts/autoloads/shop_manager.gd`

**Interfaces:**
- Consumes: `PriceFormula.compute(spec, purchased) -> int`（Task 1）；`PlayerStats.add_modifier(attr_id, mode, value, source)` / `remove_modifiers_from(source)` / `spend_gold(amount) -> bool`
- Produces: `ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`；`get_sellable_attrs() -> Array[String]`；`get_attr_price(attr_id: String) -> int`；`get_attr_purchases(attr_id: String) -> int`；`can_buy_attribute(attr_id: String) -> String`（返回 `""` 表示可买，否则为禁用原因）；`buy_attribute(attr_id: String) -> bool`；`get_attr_purchases_save() -> Dictionary`；`apply_attr_purchases_save(d: Dictionary) -> void`

- [ ] **Step 1: `attributes.json` 四属性加 `shop` 段**

逐属性追加 `shop` 键（其余字段不动）：
```json
"cpu_limit":      { ..., "shop": { "mode": "flat", "step": 1,    "price_base": 120, "price_growth": 1.30, "curve": "geometric" } },
"move_speed":     { ..., "shop": { "mode": "pct",  "step": 0.08, "price_base": 70,  "price_growth": 1.15, "curve": "geometric" } },
"cast_delay_mod": { ..., "shop": { "mode": "pct",  "step": 0.10, "price_base": 90,  "price_growth": 1.20, "curve": "geometric" } },
"hp_max":         { ..., "shop": { "mode": "pct",  "step": 0.10, "price_base": 60,  "price_growth": 1.15, "curve": "geometric" } }
```

⚠️ **`cpu_limit` 必须 `flat`** —— `AttributeFormula` 的 `add_int` 拒绝 `pct` 并 `push_error`（乘算对离散指令预算无意义）。这是硬约束不是偏好。

- [ ] **Step 2: `ShopManager` 加常量与状态**

`shop_manager.gd`，在 `const REROLL_STEP`（:11）之后新增：
```gdscript

## 货架 C 加成来源标识。必须用常量而非裸字面量：来源串在 remove/add 两侧必须逐字相同——
## remove 那侧打错一个字符，旧份额就不会被撤销而是逐次累积（理由同 PlayerStats.MOD_SOURCE_CORE）
const MOD_SOURCE_SHOP_C: String = "shop_c"
const ATTRIBUTES_JSON:   String = "res://data/attributes.json"
```
在 `var _rng`（:16）之后新增：
```gdscript
var _attr_purchases: Dictionary = {}   # attr_id(String) → 已购次数(int)；唯一写入点 _apply_attr_purchases()
var _attr_def:       Dictionary = {}   # attributes.json 全量定义，只读
```
在 `_ready()`（:18）**开头**加载定义：
```gdscript
func _ready() -> void:
	_load_attr_definitions()
	EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
```

- [ ] **Step 3: `ShopManager` 加货架 C 逻辑**

在文件末尾（`reset()` 之前）新增：
```gdscript
# ── 货架 C：属性购买 ──────────────────────────────────────
func _load_attr_definitions() -> void:
	if not FileAccess.file_exists(ATTRIBUTES_JSON):
		push_error("ShopManager: 缺少 %s" % ATTRIBUTES_JSON)
		return
	var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
	if not (parsed is Dictionary):
		push_error("ShopManager: %s 格式错误（应为对象）" % ATTRIBUTES_JSON)
		return
	for k in parsed:
		if not (parsed[k] is Dictionary):
			push_error("ShopManager: attributes.json 的「%s」应为对象，整个文件已拒绝加载" % k)
			return
	_attr_def = parsed

## 可售属性 = 带 shop 段的属性。缺段即不可售（数据驱动，加属性零代码）
func get_sellable_attrs() -> Array[String]:
	var out: Array[String] = []
	for id in _attr_def:
		if _attr_def[id].get("shop", null) is Dictionary:
			out.append(String(id))
	out.sort()
	return out

func get_attr_purchases(attr_id: String) -> int:
	return int(_attr_purchases.get(attr_id, 0))

func get_attr_price(attr_id: String) -> int:
	var sp = _attr_def.get(attr_id, {}).get("shop", null)
	if not (sp is Dictionary):
		return 0
	return PriceFormula.compute(sp, get_attr_purchases(attr_id))

## 返回 "" 表示可买；否则为禁用原因（UI 直接显示，不要只灰掉按钮）
func can_buy_attribute(attr_id: String) -> String:
	var d: Dictionary = _attr_def.get(attr_id, {})
	var sp = d.get("shop", null)
	if not (sp is Dictionary):
		return "该属性不可购买"
	if _at_soft_cap(attr_id, d):
		return "已达上限"
	if PlayerStats.gold < get_attr_price(attr_id):
		return "金币不足"
	return ""

## 已达 soft 上限？soft 是「常规来源可达上限」（E3-① 保留该字段正为此）
## inverse 属性越低越好，故方向相反
func _at_soft_cap(attr_id: String, d: Dictionary) -> bool:
	var soft: float = float(d.get("soft", 0.0))
	if soft <= 0.0:
		return false
	var cur: float = _effective_value(attr_id)
	if String(d.get("combine", "hybrid")) == "inverse":
		return cur <= soft
	return cur >= soft

## 读取生效值（裸字段，读取端零开销）
func _effective_value(attr_id: String) -> float:
	match attr_id:
		"cpu_limit":      return float(PlayerStats.cpu_limit)
		"move_speed":     return PlayerStats.move_speed
		"cast_delay_mod": return PlayerStats.cast_delay_mod
		"hp_max":         return PlayerStats.hp_max
	push_error("ShopManager: 未知属性「%s」，无法读取生效值" % attr_id)
	return 0.0

func buy_attribute(attr_id: String) -> bool:
	var reason: String = can_buy_attribute(attr_id)
	if not reason.is_empty():
		return false
	var cost: int = get_attr_price(attr_id)
	if not PlayerStats.spend_gold(cost):
		return false
	_attr_purchases[attr_id] = get_attr_purchases(attr_id) + 1
	_apply_attr_purchases()
	shop_refreshed.emit()
	return true

## 唯一写入点：所有改动 _attr_purchases 的路径（购买 / 出售退款 / 存档回读 / reset）
## 都必须经此重建加成。两份状态不同步会产生「显示买了 3 次但加成只有 2 次」且无任何诊断
func _apply_attr_purchases() -> void:
	PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
	for id in _attr_purchases:
		var n: int = int(_attr_purchases[id])
		if n <= 0:
			continue
		var sp = _attr_def.get(id, {}).get("shop", null)
		if not (sp is Dictionary):
			push_error("ShopManager: 「%s」有购买记录但无 shop 段，加成已跳过" % id)
			continue
		var mode: String = String(sp.get("mode", "flat"))
		var step: float = float(sp.get("step", 0.0))
		# pct 合并：n 次 +step 在连乘下等价于单条 (1+step)^n − 1
		var merged: float = (pow(1.0 + step, float(n)) - 1.0) if mode == "pct" else (step * float(n))
		PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C)

## 存档：只存次数，加成是派生物（回读时经 _apply_attr_purchases 重建）
func get_attr_purchases_save() -> Dictionary:
	return _attr_purchases.duplicate()

func apply_attr_purchases_save(d: Dictionary) -> void:
	_attr_purchases.clear()
	for k in d:
		_attr_purchases[String(k)] = int(d[k])
	_apply_attr_purchases()
```

- [ ] **Step 4: `reset()` 清空货架 C 状态**

`shop_manager.gd` 的 `reset()`（:89）改为：
```gdscript
func reset() -> void:
	current_slots.clear()
	reroll_count = 0
	_shop_seed   = 0
	_attr_purchases.clear()
	_apply_attr_purchases()   # 撤销 shop_c 加成（PlayerStats.reset_for_run 已清 _modifiers，此处保证独立调用时也正确）
```

- [ ] **Step 5: 校验语法 + 数据**

Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/shop_manager.gd` — `valid: true`（无 `class_name`，失败即真错误）。

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/attributes.json"))
var fails := 0
var bad := []
for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]:
	var sp = d.get(k, {}).get("shop", null)
	if not (sp is Dictionary):
		fails += 1; bad.append("%s 缺 shop 段" % k); continue
	for f in ["mode", "step", "price_base", "price_growth", "curve"]:
		if not sp.has(f):
			fails += 1; bad.append("%s.shop 缺 %s" % [k, f])
if String(d["cpu_limit"]["shop"]["mode"]) != "flat":
	fails += 1; bad.append("cpu_limit 的 mode 必须是 flat（add_int 拒 pct）")
_mcp_print("FAILS=%d %s" % [fails, str(bad)])
```
Expected: `FAILS=0 []`

- [ ] **Step 6: 提交**

```bash
git add data/attributes.json scripts/autoloads/shop_manager.gd
git commit -F- <<'EOF'
feat(shop): 货架 C 状态与购买——shop 段驱动可售集合，唯一写入点重建加成

可售集合纯数据驱动：attributes.json 的 shop 段缺失即不可售，将来解除 7 个
被阻塞属性的前置后只需加一段 JSON，零代码。

_attr_purchases 只存次数；每属性至多一条 _modifiers、value 为合并值
（pct 下 n 次 +step 等价于单条 (1+step)^n − 1）。这样出售退款只需次数减一后
重算，不必给 PlayerStats 新增按条撤销的 API。

所有改动次数的路径都收在 _apply_attr_purchases()——两份状态不同步会产生
「显示买了 3 次但加成只有 2 次」且无任何诊断，收敛写入点是唯一防线。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 3: 局内存档持久化（schema 2 → 3）

**Files:**
- Modify: `scripts/autoloads/profile_manager.gd`（`SCHEMA_VERSION` :10、`apply_run` :63、`_collect_run_data` :88、`_migrate_run` :110）

**Interfaces:**
- Consumes: `ShopManager.get_attr_purchases_save() -> Dictionary` / `apply_attr_purchases_save(d) -> void`（Task 2）
- Produces: 存档键 `"attr_purchases"`；`SCHEMA_VERSION = 3`

- [ ] **Step 1: 提升 schema 版本**

`profile_manager.gd:10`：
```gdscript
const SCHEMA_VERSION: int = 3  # v3：新增 attr_purchases（货架 C 属性购买次数）
```

- [ ] **Step 2: 写入存档**

`_collect_run_data()`（:88）的字典字面量中，在 `"player_stats"` 之后加一行：
```gdscript
		"attr_purchases": ShopManager.get_attr_purchases_save(),
```

- [ ] **Step 3: 回读 —— 顺序至关重要**

`apply_run()`（:63）当前**第一步**就是 `PlayerStats.load_save_data(...)`（设 `hp`）。货架 C 能把 `hp_max` 买到 180 后，若属性加成在此之后才恢复，回读的 `hp=180` 会被尚未加成的 `hp_max=100` 钳掉 —— **静默吞 80 血**（E3-① 在 `player_stats.gd` 注释里记录过这个陷阱）。

故 `attr_purchases` 必须在 `load_save_data` **之前**恢复。把 `apply_run` 的前两行改为：
```gdscript
func apply_run(data: Dictionary) -> void:
	if data.is_empty():
		return
	# 必须早于 load_save_data：后者设 hp，而 _apply_attr_purchases 会经 _recompute_attrs
	# 触发 hp = minf(hp, hp_max)。顺序颠倒会拿未加成的 hp_max 去钳，静默吞血。
	ShopManager.apply_attr_purchases_save(data.get("attr_purchases", {}))
	PlayerStats.load_save_data(data.get("player_stats", {}))
```
（其余行不动。）

- [ ] **Step 4: 迁移旧档**

`_migrate_run()`（:110）在 `if ver < 2:` 块之后追加：
```gdscript
	if ver < 3:
		# v2→v3: 旧档无 attr_purchases；apply_run 的 data.get(..., {}) 已处理缺失，无需补字段
		data["schema_version"] = 3
```

- [ ] **Step 5: 校验语法**

Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/profile_manager.gd` — `valid: true`。

- [ ] **Step 6: 提交**

```bash
git add scripts/autoloads/profile_manager.gd
git commit -F- <<'EOF'
feat(shop): 货架 C 购买次数进局内存档（schema 2→3）

只存次数不存加成——加成是派生物，回读时经 _apply_attr_purchases 重建。
这也躲开了 E3-① 记录的坑：_modifiers 是 Array[Dictionary]，而 JSON 回读的
无类型 Array 直接 = 赋值是运行时类型错误，必须 .assign()；存派生源头则不涉及。

回读顺序：attr_purchases 必须早于 load_save_data。后者设 hp，而重建加成会经
_recompute_attrs 触发 hp = minf(hp, hp_max)；顺序颠倒会拿未加成的 hp_max 去钳，
静默吞血（hp_max 可买到 180 后即可复现）。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 4: 商店 UI「属性」区

**Files:**
- Modify: `scenes/main/combat_s2.gd`（`_setup_shop_ui()` :281 起；刷新逻辑 `_refresh_shop_ui`）

**Interfaces:**
- Consumes: `ShopManager.get_sellable_attrs()` / `get_attr_price(id)` / `get_attr_purchases(id)` / `can_buy_attribute(id) -> String` / `buy_attribute(id) -> bool`（Task 2）
- Produces: 无（UI 末端）

> 项目 UI 全程序化，无 `.tscn`（ADR 记录）。照 `_setup_shop_ui` 现有风格：`Button.new()` + `position` / `size` + `connect`。

- [ ] **Step 1: 先读现有商店 UI 的构建与刷新**

Run: 读 `scenes/main/combat_s2.gd` 的 `_setup_shop_ui()`（:281 起约 60 行）与 `_refresh_shop_ui()`。**按其实际结构与命名写本区**，特别确认：槽位按钮如何存放（`_shop_btns` 数组）、刷新函数如何更新文本、`_shop_layer` 的坐标系与现有控件占用的 y 范围（刷新/法杖/背包按钮在 y=400 一行）。

若发现现有布局没有空间放四行属性（每行约 34px，共约 140px），**停下来报告**，不要挤压现有控件或自行改布局尺寸。

- [ ] **Step 2: 构建属性区**

在 `_setup_shop_ui()` 末尾（`_inv_btn` 之后）追加。**坐标按 Step 1 读到的实际可用区域调整**，下面的 y 值是基于「现有控件止于 y=440」的假设：
```gdscript
	# ── 货架 C：属性购买 ──────────────────────────────
	var attr_title := Label.new()
	attr_title.text = tr("SHOP_ATTR_TITLE")
	attr_title.position = Vector2(40, 450)
	_shop_layer.add_child(attr_title)
	_attr_rows.clear()
	var ids: Array[String] = ShopManager.get_sellable_attrs()
	for i in ids.size():
		var id: String = ids[i]
		var y: float = 480.0 + float(i) * 34.0
		var lbl := Label.new()
		lbl.position = Vector2(40, y)
		lbl.size = Vector2(300, 30)
		_shop_layer.add_child(lbl)
		var btn := Button.new()
		btn.position = Vector2(360, y)
		btn.size = Vector2(160, 30)
		btn.connect("pressed", _on_buy_attr_pressed.bind(id))
		_shop_layer.add_child(btn)
		_attr_rows.append({"id": id, "label": lbl, "button": btn})
```
在文件的成员声明区（`_shop_btns` 附近，约 :37）加：
```gdscript
var _attr_rows: Array = []   # [{id: String, label: Label, button: Button}]
```

- [ ] **Step 3: 刷新与购买回调**

在 `_refresh_shop_ui()` 末尾追加对属性区的刷新（函数名按 Step 1 读到的实际名）：
```gdscript
	for row in _attr_rows:
		var id: String = row["id"]
		var reason: String = ShopManager.can_buy_attribute(id)
		var price: int = ShopManager.get_attr_price(id)
		var n: int = ShopManager.get_attr_purchases(id)
		row["label"].text = tr("SHOP_ATTR_ROW") % [_attr_display_name(id), _attr_effective_text(id), n]
		row["button"].text = (tr("SHOP_ATTR_BUY") % price) if reason.is_empty() else reason
		row["button"].disabled = not reason.is_empty()
```
新增两个辅助与回调：
```gdscript
func _attr_display_name(id: String) -> String:
	match id:
		"cpu_limit":      return tr("ATTR_CPU_LIMIT")
		"move_speed":     return tr("ATTR_MOVE_SPEED")
		"cast_delay_mod": return tr("ATTR_CAST_DELAY")
		"hp_max":         return tr("ATTR_HP_MAX")
	return id

func _attr_effective_text(id: String) -> String:
	match id:
		"cpu_limit":      return str(PlayerStats.cpu_limit)
		"move_speed":     return "%.0f" % PlayerStats.move_speed
		"cast_delay_mod": return "%.2f" % PlayerStats.cast_delay_mod
		"hp_max":         return "%.0f" % PlayerStats.hp_max
	return "-"

func _on_buy_attr_pressed(id: String) -> void:
	if ShopManager.buy_attribute(id):
		_refresh_shop_ui()
```
刷新函数名**已核实为 `_refresh_shop_ui()`**（`combat_s2.gd:413` 的 `_on_stats_changed` 调用它）。

- [ ] **Step 4: 补 i18n 键（4 语言）**

`translations/` 下 `zh_CN.po` / `zh_TW.po` / `en.po` / `ja.po` **各加 6 个键**，键集必须一致（项目已验证 4 语言零缺键）：
`SHOP_ATTR_TITLE`、`SHOP_ATTR_ROW`（格式 `%s  当前 %s  已购 %d 次`）、`SHOP_ATTR_BUY`（`购买 %dG`）、`ATTR_CPU_LIMIT`、`ATTR_MOVE_SPEED`、`ATTR_CAST_DELAY`、`ATTR_HP_MAX`。

⚠️ `can_buy_attribute` 返回的禁用原因目前是**中文裸串**（在 `shop_manager.gd` 里）。本期先如此；i18n 债务（内容名 tr 化）是路线图 E7-③ 的独立小项，**不在本期扩大范围**，但要在 `can_buy_attribute` 的注释里注明这一点。

- [ ] **Step 5: 校验语法**

Run: godot-mcp-pro `validate_script` on `res://scenes/main/combat_s2.gd` — `valid: true`。

Run: godot-mcp-pro `execute_editor_script` 校验 4 语言键集一致：
```gdscript
var keys := ["SHOP_ATTR_TITLE", "SHOP_ATTR_ROW", "SHOP_ATTR_BUY", "ATTR_CPU_LIMIT", "ATTR_MOVE_SPEED", "ATTR_CAST_DELAY", "ATTR_HP_MAX"]
var fails := 0
var bad := []
for loc in ["zh_CN", "zh_TW", "en", "ja"]:
	var txt := FileAccess.get_file_as_string("res://translations/%s.po" % loc)
	for k in keys:
		if not txt.contains('msgid "%s"' % k):
			fails += 1; bad.append("%s 缺 %s" % [loc, k])
_mcp_print("FAILS=%d %s" % [fails, str(bad)])
```
Expected: `FAILS=0 []`

- [ ] **Step 6: 提交**

```bash
git add scenes/main/combat_s2.gd translations/
git commit -F- <<'EOF'
feat(shop): 商店「属性」区 UI + 4 语言 i18n 键

按 get_sellable_attrs() 动态生成行，故加可售属性无需改 UI 代码。
禁用态显示具体原因（金币不足/已达上限）而非只灰掉按钮。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 5: 设计器「属性」页扩展 `shop` 段

**Files:**
- Modify: `addons/game_designer/attribute_tab.gd`

**Interfaces:**
- Consumes: `UI.spin(min, max, step, val)` / `UI.opt(items, selected)` / `UI.cell_label(text)`（`designer_ui.gd`）
- Produces: 无（编辑器末端）

- [ ] **Step 1: 先读现有实现**

Run: 读 `addons/game_designer/attribute_tab.gd` 全文。确认 `_rows` 的结构、`_collect()` 的合并式写回、`_loaded` 守卫、以及 `UI.spin` 的实际调用形式。**按其实际结构扩展**。

- [ ] **Step 2: 加 `shop` 段五字段**

在现有 5 列网格之后新增第二个网格（6 列：属性 / mode / step / 首价 / 涨幅 / 曲线），每属性一行。结构照现有网格（`_ready()` 内构建，**不可在 `_init()`** —— `@tool` 控件未入树时会出错，项目已踩过）：

```gdscript
	add_child(HSeparator.new())
	add_child(UI.header("🛒 货架 C — 可售配置（无 shop 段 = 不可购买）"))
	var g2 := GridContainer.new(); g2.columns = 6; add_child(g2)
	for h in ["属性", "增量类型 mode", "每级 step", "首价 price_base", "涨幅 growth", "曲线 curve"]:
		var l := Label.new(); l.text = h; l.modulate = Color(0.7, 0.8, 1.0)
		g2.add_child(l)
	for attr_id in ATTR_ORDER:
		var sp: Dictionary = _data.get(attr_id, {}).get("shop", {})
		g2.add_child(UI.cell_label(String(_data.get(attr_id, {}).get("display_name", attr_id))))
		var mi: int = MODE_KEYS.find(String(sp.get("mode", "flat")))
		var op_mode := UI.opt(MODE_LABELS, mi if mi >= 0 else 0)
		var sp_step  := UI.spin(0.0, 99999.0, 0.01, float(sp.get("step", 0.0)))
		var sp_pbase := UI.spin(0.0, 99999.0, 1.0,  float(sp.get("price_base", 0.0)))
		var sp_grow  := UI.spin(0.0, 99999.0, 0.01, float(sp.get("price_growth", 1.0)))
		var ci: int = CURVE_KEYS.find(String(sp.get("curve", "geometric")))
		var op_curve := UI.opt(CURVE_LABELS, ci if ci >= 0 else 0)
		g2.add_child(op_mode); g2.add_child(sp_step); g2.add_child(sp_pbase)
		g2.add_child(sp_grow); g2.add_child(op_curve)
		_shop_rows[attr_id] = {"mode": op_mode, "step": sp_step,
			"price_base": sp_pbase, "price_growth": sp_grow, "curve": op_curve}
```
成员声明区加 `var _shop_rows: Dictionary = {}`。

`_collect()` 中在现有的逐属性循环内追加（以现有 `shop` 子字典为基底 `duplicate(true)`，防丢未列入表单的键）：
```gdscript
		var r2: Dictionary = _shop_rows.get(attr_id, {})
		if not r2.is_empty():
			var shop_entry: Dictionary = entry.get("shop", {}).duplicate(true)
			shop_entry["mode"]         = MODE_KEYS[r2["mode"].selected]     # 存 key 不存显示串
			shop_entry["step"]         = _clean(float(r2["step"].value))
			shop_entry["price_base"]   = int(r2["price_base"].value)
			shop_entry["price_growth"] = _clean(float(r2["price_growth"].value))
			shop_entry["curve"]        = CURVE_KEYS[r2["curve"].selected]
			entry["shop"] = shop_entry
```
（`_clean` 是现有的 `snappedf(v, 1e-6)` 辅助；`price_base` 是整数金币故取 `int`。）

常量：
```gdscript
const MODE_KEYS   = ["flat", "pct"]
const MODE_LABELS = ["固定量 flat", "百分比 pct"]
const CURVE_KEYS   = ["geometric", "linear", "flat"]
const CURVE_LABELS = ["几何 geometric", "线性 linear", "固定 flat"]
```
控件：`mode` / `curve` 用 `UI.opt`（**存 key 不存双语显示串**）；`step` / `price_base` / `price_growth` 用 `UI.spin`。

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

`_collect()` 中把这五个字段合并进各属性的 `shop` 子字典（以现有条目为基底 `duplicate(true)`，防丢键）。

- [ ] **Step 3: 提示文案**

在现有提示 `Label` 的文本后追加一句（该 `Label` 已设 `AUTOWRAP_WORD_SMART`）：
```
cpu_limit 的 mode 必须是 flat（add_int 拒绝 pct）；shop 段缺失即该属性不可购买。
```

- [ ] **Step 4: 往返实测（逐位相等，不用容差）**

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
var S := GDScript.new()
S.source_code = FileAccess.get_file_as_string("res://addons/game_designer/attribute_tab.gd")
if S.reload() != OK:
	_mcp_print("PARSE_FAIL"); return
var tab = S.new()
tab._ready()
var orig = JSON.parse_string(FileAccess.get_file_as_string("res://data/attributes.json"))
var out: Dictionary = tab._collect()
var fails := 0
var bad := []
for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]:
	for f in ["base", "soft", "hard"]:
		if str(orig[k][f]) != str(out[k][f]):
			fails += 1; bad.append("%s.%s %s→%s" % [k, f, str(orig[k][f]), str(out[k][f])])
	for f in ["mode", "curve"]:
		if String(orig[k]["shop"][f]) != String(out[k]["shop"][f]):
			fails += 1; bad.append("%s.shop.%s 显示串泄漏或丢失" % [k, f])
	for f in ["step", "price_base", "price_growth"]:
		if str(orig[k]["shop"][f]) != str(out[k]["shop"][f]):
			fails += 1; bad.append("%s.shop.%s %s→%s" % [k, f, str(orig[k]["shop"][f]), str(out[k]["shop"][f])])
	if not out[k].has("display_name"):
		fails += 1; bad.append("%s 丢键 display_name" % k)
_mcp_print("ROUNDTRIP_FAILS=%d %s" % [fails, str(bad)])
```
Expected: `ROUNDTRIP_FAILS=0 []`

- [ ] **Step 5: 校验 + 提交**

Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/attribute_tab.gd` — `valid: true`。
```bash
git add addons/game_designer/attribute_tab.gd
git commit -F- <<'EOF'
feat(shop): 设计器「属性」页扩展 shop 段五字段

mode/curve 下拉存 key 不存双语显示串；UI.spin 的 min 取 0 以免 Range 的
抵消误差把浮点噪声写进权威数据文件（E3-① 实测）。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 6: 运行时验收 + 权威文档补录 + 计划回填

**Files:** 无（运行时断言）+ `docs/design/numerical_design.md` + 路线图 + 本计划

- [ ] **Step 1: 启动战斗场景**

Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。

⚠️ 工具坑 ①：运行时 `push_error` 不进 `get_output_log` / `get_editor_errors`。读**编辑器 Debugger 的「错误」树**（经 `execute_editor_script` 走 `ScriptEditorDebugger`）。**先做阳性对照** —— 故意触发一次已知报错（如 `ShopManager.buy_attribute("不存在的属性")`），确认它出现在你读的通道里，否则后面的「零报错」结论无意义。

- [ ] **Step 2: 连乘正确性 + 金币序列（验收 7、8）**

Run: godot-mcp-pro `execute_game_script`：
```gdscript
PlayerStats.gold = 9999
ShopManager.reset()
var log := []
var fails := 0
var prices := []
for i in 3:
	prices.append(ShopManager.get_attr_price("hp_max"))
	if not ShopManager.buy_attribute("hp_max"):
		fails += 1; log.append("第 %d 次购买失败" % (i + 1))
# 连乘：100 × 1.1³ = 133.1（线性叠加会得 130）
if abs(PlayerStats.hp_max - 133.1) > 0.01:
	fails += 1; log.append("hp_max=%.4f 应 133.1（连乘）而非 130（线性）" % PlayerStats.hp_max)
if prices != [60, 69, 79]:
	fails += 1; log.append("价格序列 %s 应 [60, 69, 79]" % str(prices))
if ShopManager.get_attr_purchases("hp_max") != 3:
	fails += 1; log.append("次数 %d 应 3" % ShopManager.get_attr_purchases("hp_max"))
_mcp_print("FAILS=%d hp_max=%.4f prices=%s %s" % [fails, PlayerStats.hp_max, str(prices), str(log)])
```
Expected: `FAILS=0 hp_max=133.1000 prices=[60, 69, 79]`

- [ ] **Step 3: `soft` 闸 + `cast_delay_mod` 尚未饱和（验收 9、10）**

> spec §2.3 自审订正：三个饱和点全部低于 `soft`(0.1)，正常购买够不到，UI 只需 `soft` 一道闸。本步**同时证明**这一点。

Run: godot-mcp-pro `execute_game_script`：
```gdscript
PlayerStats.gold = 999999
ShopManager.reset()
var n := 0
while ShopManager.can_buy_attribute("cast_delay_mod").is_empty() and n < 100:
	ShopManager.buy_attribute("cast_delay_mod"); n += 1
var mod: float = PlayerStats.cast_delay_mod
var reason: String = ShopManager.can_buy_attribute("cast_delay_mod")
# wand_basic 基准 0.5s：soft(0.1) 时实际间隔 0.05s = 3 帧 = 20/s，未饱和（饱和需 ≤0.0333）
var eff_t: float = 0.5 * mod
var frames: int = int(ceil(eff_t * 60.0))
var fails := 0
if mod > 0.1 + 0.0001: fails += 1
if reason.is_empty(): fails += 1          # 到 soft 后必须禁用
if frames <= 1: fails += 1                # 未饱和：帧距应 >1
_mcp_print("FAILS=%d 买了%d次 mod=%.4f 原因=%s 帧距=%d(应>1，证明未饱和)" % [fails, n, mod, reason, frames])
```
Expected: `FAILS=0`，`帧距=3`（`wand_basic`），`原因=已达上限`。

> ⚠️ 脚本里的 `0.5` 是 `wand_basic` 的基准间隔。**若开局装备的不是 `wand_basic`，此常数与期望帧距都要改** —— 用 `_equipped_core.cast_interval` 读实际值更稳。断言的实质是「帧距 > 1（未饱和）」，不是「恰好等于 3」。

- [ ] **Step 4: `cpu_limit` 的 `add_int` 硬约束（验收 11）**

Run: godot-mcp-pro `execute_game_script` —— 直接对 `PlayerStats` 施加 `pct` 加成，确认被拒（`shop` 段配的是 `flat`，此处验证的是底层约束仍在）：
```gdscript
var before: int = PlayerStats.cpu_limit
PlayerStats.add_modifier("cpu_limit", "pct", 0.5, "zzzprobe")
var after: int = PlayerStats.cpu_limit
PlayerStats.remove_modifiers_from("zzzprobe")
_mcp_print("FAILS=%d before=%d after=%d（pct 应被 add_int 拒绝，二者相等）" % [(0 if before == after else 1), before, after])
```
Expected: `FAILS=0`，且 Debugger 错误树中出现 `add_int 属性不接受 pct 加成`。

- [ ] **Step 5: 存档往返 + 旧档兼容 + 与 `"core"` 共存（验收 12、14）**

Run: godot-mcp-pro `execute_game_script`：
```gdscript
PlayerStats.gold = 9999
ShopManager.reset()
for i in 3: ShopManager.buy_attribute("hp_max")
for i in 2: ShopManager.buy_attribute("cpu_limit")
var hp_before: float = PlayerStats.hp_max
var cpu_before: int = PlayerStats.cpu_limit
var saved: Dictionary = ShopManager.get_attr_purchases_save()
ShopManager.reset()
var cleared_ok: bool = (abs(PlayerStats.hp_max - 100.0) < 0.01)
ShopManager.apply_attr_purchases_save(saved)
var fails := 0
if not cleared_ok: fails += 1
if abs(PlayerStats.hp_max - hp_before) > 0.01: fails += 1
if PlayerStats.cpu_limit != cpu_before: fails += 1
# 旧档（无该键）不崩
ShopManager.apply_attr_purchases_save({})
if abs(PlayerStats.hp_max - 100.0) > 0.01: fails += 1
_mcp_print("FAILS=%d hp %.2f→清零→%.2f cpu %d" % [fails, hp_before, PlayerStats.hp_max, cpu_before])
```
Expected: `FAILS=0`。
> `cpu_before` 含法杖的 `"core"` 份额 + 购买的 flat，回读后应完全一致 —— 这同时验证了两个来源互不干扰（验收 14）。

- [ ] **Step 6: 回读顺序不吞血（Task 3 的核心风险）**

Run: godot-mcp-pro `execute_game_script` —— 模拟 `apply_run` 的真实顺序：
```gdscript
PlayerStats.gold = 9999
ShopManager.reset()
for i in 3: ShopManager.buy_attribute("hp_max")   # hp_max → 133.1
PlayerStats.hp = 130.0
var saved: Dictionary = ShopManager.get_attr_purchases_save()
var ps: Dictionary = PlayerStats.get_save_data()
# 正确顺序：先恢复加成，再 load_save_data
ShopManager.reset()
ShopManager.apply_attr_purchases_save(saved)
PlayerStats.load_save_data(ps)
var fails := (0 if abs(PlayerStats.hp - 130.0) < 0.01 else 1)
_mcp_print("FAILS=%d hp=%.2f hp_max=%.2f（应 130/133.1，颠倒顺序会被钳到 100）" % [fails, PlayerStats.hp, PlayerStats.hp_max])
```
Expected: `FAILS=0 hp=130.00 hp_max=133.10`

- [ ] **Step 7: `shop` 段缺失即不上架（验收 16）**

Run: godot-mcp-pro `execute_game_script`：
```gdscript
var ids: Array = ShopManager.get_sellable_attrs()
var fails := (0 if ids.size() == 4 else 1)
_mcp_print("FAILS=%d sellable=%s（四个都有 shop 段）" % [fails, str(ids)])
```
Expected: `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]`
> 若日后有属性无 `shop` 段，此断言的期望数需随之调整 —— 它守的是「按 `shop` 段筛选」这个机制，不是「恰好 4 个」。

- [ ] **Step 8: 权威文档补录（spec §2.7）**

`docs/design/numerical_design.md` §1.2 经济系统的**消耗清单**（现仅「购买法术 50-500G / 购买法杖 200-2000G / 刷新商店 20G*」三项）新增一项：

> **购买属性 (60-120G 起，几何递增)**：各属性首购价与成长曲线见 `data/attributes.json` 的 `shop` 段（`hp_max` 60×1.15ⁿ / `move_speed` 70×1.15ⁿ / `cast_delay_mod` 90×1.20ⁿ / `cpu_limit` 120×1.30ⁿ）。**按本局该属性累计购买次数递增，不随波次重置**（与刷新费用相反 —— 刷新的价值每次相同故线性递增即可，属性购买的价值在复利）。上限为各属性的 `soft`（「常规来源可达上限」）。设计依据见 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md` §2.2。

同时在 §1.1 的 `cast_delay_mod` 行补一句：其饱和点（`wand_basic` 0.0333 等）**全部低于 `soft`(0.1)**，故常规购买够不到饱和区，`soft` 才是实际约束。

- [ ] **Step 9: 路线图勾除**

`docs_dev/plans/2026-07-23-missing-features-roadmap.md` 的 E3 切分列表第 2 项「货架 C（属性购买）」标记完成，格式对齐已完成的第 1 项，并注明实际范围（四个框架内属性；B 类 7 个属性待各自前置解除后加 `shop` 段即可，零代码）。

- [ ] **Step 10: 回填本计划为实际执行版**

实现过程中若因评审改动了代码或断言，**本计划对应的代码块与脚本必须回填为实际落地的版本**，并勾上各任务复选框（只留 Step 11 未勾）。每处回填旁注明**原写法为何不可用** —— 那是最耐久的价值。归航与 E3-① 两期都踩过「计划里留着过时代码和失效断言」的坑。

- [ ] **Step 11: 合并决策**

用 `superpowers:finishing-a-development-branch` 决定分支去向。前五个已完成特性均合并进 `master` 并删分支。

---

## 验收标准回溯（spec §4）

| # | 验收标准 | 覆盖 | 观测方式 |
| :- | :-- | :-- | :-- |
| 1 | `geometric` 60/69/79 | Task 1 Step 3 | 纯函数断言 |
| 2 | `linear` 20/30/40（与 P6-N23 交叉验证） | Task 1 Step 3 | 纯函数断言 |
| 3 | `flat` 恒等 | Task 1 Step 3 | 纯函数断言 |
| 4 | `purchased = 0` 恒返回 `price_base` | Task 1 Step 3 | 纯函数断言 |
| 5 | 未知 `curve` → `push_error` + 回退 | Task 1 Step 4 | **错误通道 + 唯一标记值** |
| 6 | 返回 `int`，下限 1 | Task 1 Step 3 | 纯函数断言 |
| 7 | 买 3 次 `hp_max` → 133.1（连乘非 130） | Task 6 Step 2 | 运行时 |
| 8 | 金币按几何序列扣除 | Task 6 Step 2 | 运行时 |
| 9 | 达 `soft` 后禁用且 API 拒绝 | Task 6 Step 3 | 运行时 |
| 10 | `soft` 时尚未饱和（证明 §2.3 订正） | Task 6 Step 3 | 运行时（帧距 >1） |
| 11 | `cpu_limit` 拒 `pct` | Task 6 Step 4 | 运行时 + 错误通道 |
| 12 | 存档往返 + 旧档兼容 | Task 6 Step 5 | 运行时 |
| 13 | `reset` 后清零 | Task 6 Step 5 | 运行时（`cleared_ok`） |
| 14 | 与 `"core"` 来源共存 | Task 6 Step 5 | 运行时（`cpu_before` 含两源） |
| 15 | 设计器往返**逐位相等** | Task 5 Step 4 | 绕缓存实例化 |
| 16 | 无 `shop` 段不上架 | Task 6 Step 7 | 运行时 |
| 17 | `validate_script` 通过 | 各 Task 的校验步 | 工具（`price_formula.gd` 用 `CACHE_MODE_IGNORE`） |
| — | 回读顺序不吞血（Task 3 核心风险） | Task 6 Step 6 | 运行时 |
