Files
spellforge/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md
T
joywayerandClaude Opus 5 e069b76ee1 docs(plan): E3-② 货架 C 逐任务实现计划(7 任务 · 含回读顺序吞血风险)
Task0 建分支 / Task1 PriceFormula(TDD,含报错通道断言)/ Task2 shop 段 +
ShopManager 购买与唯一写入点 / Task3 存档 schema 2→3 / Task4 商店 UI +
4 语言 i18n / Task5 设计器 shop 段 / Task6 运行时验收 + 权威文档补录 + 回填。

Task3 写死一条顺序要求:apply_run 目前第一步就 load_save_data 设 hp,而属性
加成若在其后恢复,回读的 hp 会被尚未加成的 hp_max 钳掉——hp_max 可买到 133
之后即可复现静默吞血。Task6 Step6 专门验证该顺序。

自审订正 spec §3 影响文件表:原列 combat_manager「存档读写接入」是想当然,
profile_manager._collect_run_data 本就直接调 ShopManager.get_shop_seed(),
同样可直接调 get_attr_purchases_save(),不必绕一层。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:51:25 +08:00

910 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 货架 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 必须是 flatadd_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_purchasesapply_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 必须是 flatadd_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.5ssoft(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=%dpct 应被 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 | 运行时 |