Files
spellforge/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md
joywayerandClaude Opus 5 7c8c1ec564 docs(plan): 订正 Task 6 两处评审指出的归因表述(不改代码/数值)
Step 6 归因写反:脚本手工内联复现两种顺序,从未调用 ProfileManager.apply_run(),
故对调 apply_run() 内部顺序不影响其结果;真正的 apply_run() 顺序回归哨兵是 Step 5b
(评审对调 profile_manager.gd:68-69 顺序后,Step 5b 未改一字即 FAILS=1)。

「22」出处指代错误:task-6-brief.md 全文无此数字,实际出自
docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md:133 设计表,数值本身无误。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:30:58 +08:00

1057 lines
62 KiB
Markdown
Raw Permalink 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.PriceCurve``enum PriceCurve { 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 PriceCurve { GEOMETRIC, LINEAR, FLAT }
const _CURVE_BY_NAME: Dictionary[String, PriceCurve] = {
"geometric": PriceCurve.GEOMETRIC,
"linear": PriceCurve.LINEAR,
"flat": PriceCurve.FLAT,
}
## JSON 的 curve 字符串 → 枚举;未知值 push_error 并回退 GEOMETRIC
static func curve_from_string(s: String) -> PriceCurve:
if _CURVE_BY_NAME.has(s):
return _CURVE_BY_NAME[s]
push_error("PriceFormula: 未知 curve「%s」,回退 geometric" % s)
return PriceCurve.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: PriceCurve = curve_from_string(String(spec.get("curve", "geometric")))
var raw: float = 0.0
match curve:
PriceCurve.LINEAR:
raw = base + float(n) * growth
PriceCurve.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: `scripts/autoloads/player_stats.gd`(通用访问器,见 Step 0
- 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: `PlayerStats.get_attr_value(attr_id: String) -> float``ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"``get_sellable_attrs() -> Array[String]``get_attr_def(attr_id: String) -> Dictionary``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 0: `PlayerStats` 加通用生效值访问器(消除硬编码属性名)**
> 📌 **预检发现(2026-07-31,开工前)**:本计划初稿在 `ShopManager._effective_value()` 与 `combat_s2` 的两个辅助里各写了一个 `match attr_id:` 硬编码四个属性名。**那违反本计划自己的 Global Constraint(纯数据驱动、代码不含硬编码副本),也违反本特性的立身之本「加可售属性 = 加 JSON 段,零代码」** —— 第 5 个属性加 `shop` 段后,`get_sellable_attrs()` 会返回它、UI 会生成那一行,但生效值读出 `0.0` 并 `push_error`、名字显示成裸 id。故先加通用访问器,三处 `match` 全部删除。
`player_stats.gd`:在 `_modifiers` 声明(约 :39)之后新增
```gdscript
var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取
```
`_recompute_attrs()` 内,在写回裸字段之后、`stats_changed.emit()` 之前追加:
```gdscript
# 冷路径通用视图:热路径(move_speed 每物理帧)仍走裸字段,此表只服务商店/UI 等按 id 取值的场景。
# 必须与裸字段在同一处更新——两者不同步会让商店显示的值与实际生效值不一致且无诊断。
_attr_effective = {
"cpu_limit": float(cpu_limit),
"move_speed": move_speed,
"cast_delay_mod": cast_delay_mod,
"hp_max": hp_max,
}
```
文件末尾新增:
```gdscript
## 按 id 取生效值(冷路径)。热路径请直接读裸字段(move_speed 等),本函数有字典查找开销。
func get_attr_value(attr_id: String) -> float:
if not _attr_effective.has(attr_id):
push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id)
return 0.0
return float(_attr_effective[attr_id])
```
> 注:`_attr_effective` 的键仍逐个列出,因为裸字段本身就是逐个声明的(热路径要求)。但**消费方**(商店、UI)自此不再硬编码属性名 —— 新增属性时只需在此表加一行,而不是在三个文件里各改一个 `match`。这是「热路径零开销」与「消费端数据驱动」之间的必要接缝。
- [ ] **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 = PlayerStats.get_attr_value(attr_id)
if String(d.get("combine", "hybrid")) == "inverse":
return cur <= soft
return cur >= soft
## 属性定义(供 UI 读 display_name 等,避免在 UI 侧再复制一份属性名表)
func get_attr_def(attr_id: String) -> Dictionary:
var d = _attr_def.get(attr_id, {})
return d if d is Dictionary else {}
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 合并必须按属性的 combine 分支——AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1-v
# 故两者的等价单条值不同。写错会让 inverse 属性(cast_delay_mod)的曲线整体偏离且零诊断。
var combine: String = String(_attr_def.get(id, {}).get("combine", "hybrid"))
var merged: float = 0.0
if mode == "pct":
# hybrid: 需 f=(1+step)^n → merged=(1+step)^n1
# inverse: 需 f=(1step)^n → merged=1(1step)^n
merged = (1.0 - pow(1.0 - step, float(n))) if combine == "inverse" else (pow(1.0 + step, float(n)) - 1.0)
else:
merged = step * float(n) # flat 线性可加,与 combine 无关
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 scripts/autoloads/player_stats.gd 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`)。若属性加成在此之后才恢复,回读的 `hp` 会被尚未加成的 `hp_max` 钳掉 —— **静默吞血**E3-① 在 `player_stats.gd` 注释里记录过这个陷阱)。
> 📌 **成因订正(Task 3 实测 + 评审独立复现)**:本步初稿称「全新启动、`hp_max` 买到 180 后即可复现」—— **该路径实际不触发**。`remove_modifiers_from` 在 `_modifiers` 为空时提前返回、不触发 `_recompute_attrs`,随后单次合并的 `add_modifier` 一步算出最终 `hp_max`,中间从未出现「陈旧 `hp_max`」的可观察状态。
>
> **真实风险窗口**:同一进程内 `_modifiers` 里**已有**一条 `shop_c` 加成时再次 `apply_run`(如「主菜单 → 继续 → 死亡 → 主菜单 → 再次继续」)。此时 `remove_modifiers_from` 真正命中旧加成、触发一次 `_recompute_attrs`,而 `hp` 已被颠倒的顺序设为 130、`hp_max` 尚未回升 → 钳到 100,吞 30 血。评审复现数据:
> ```
> 场景A(无历史加成,即初稿描述):颠倒顺序 → hp=130 hp_max=133.1 未吞血
> 场景B(已有一条 shop_c 加成): 颠倒顺序 → hp=100 hp_max=133.1 吞 30 血
> 场景B + 实际交付的 apply_run() hp=130 hp_max=133.1 正确
> ```
> **写回归测试必须用场景 B 的前提**(预置一条同源加成),否则断言恒绿、证明不了任何事。
`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 去钳,
静默吞血。触发前提是 _modifiers 里已有同源加成(同进程内二次 apply_run),
此时 remove_modifiers_from 才真正命中并触发一次中间态重算。
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),**停下来报告**,不要挤压现有控件或自行改布局尺寸。
> 📌 **实测订正(Task 4 执行时)**:**确实放不下**Step 2 里那组 `y=450` 起的坐标是错的。实测:商店面板 `position=(200,200) size=(600,340)`(覆盖 y=200540),现有控件已排到 y≈479`_inv_btn`/`_lang_btn`/设置按钮那一行 y=445479),`_shop_warn` 在 y=502(底部约 522)—— panel 内仅余约 18px。视口 1152×648,即便越出 panel 也不足以容纳「标题 + 4 行 × 34px ≈ 161px」。
>
> **决定:改用独立子面板**(方案 B)—— 商店面板加一个「📊 属性」按钮,点击打开独立 `CanvasLayer` 子面板,结构照既有的 `_setup_settings_ui()`。理由:① 与项目既有「主面板 + 按钮开子面板」模式一致(背包、设置都是这样),非新发明;② 零坐标风险,不动任何现有控件——方案 A 需把 panel 高度撑到 ~420,而起点 y=200、视口高 648,撑完离底仅 28px,后续任何新增控件会再次撞墙;③ 为四行内容引入滚动容器(方案 C)是过度设计且该面板无先例。
>
> 接受的代价:子面板比内联少一层可见性,而经济设计本意是让「买属性」与「买法术」争夺注意力。但多一次点击不改变取舍本身(钱是同一笔),可发现性留给将来 UI 打磨。
- [ ] **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)
var disp: String = String(ShopManager.get_attr_def(id).get("display_name", id))
row["label"].text = tr("SHOP_ATTR_ROW") % [disp, "%.2f" % PlayerStats.get_attr_value(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 _on_buy_attr_pressed(id: String) -> void:
if ShopManager.buy_attribute(id):
_refresh_shop_ui()
```
> 📌 **预检订正**:初稿在此写了 `_attr_display_name()` 与 `_attr_effective_text()` 两个 `match id:` 辅助,**各硬编码一遍四个属性名** —— 而 `display_name` 本就在 `attributes.json` 里、生效值有 `PlayerStats.get_attr_value()`。两个辅助全部删除,改为读数据。第 5 个属性自此无需改动本文件。
> 统一用 `%.2f` 显示:`cast_delay_mod` 需要两位小数,而 `hp_max`/`move_speed` 显示 `133.10` 也可接受 —— 逐属性定制格式又会引回一张硬编码表。若日后确需,把格式串放进 `attributes.json` 而非代码。
刷新函数名**已核实为 `_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` **各加 3 个键**,键集必须一致(项目已验证 4 语言零缺键):
`SHOP_ATTR_TITLE``SHOP_ATTR_ROW`(格式 `%s 当前 %s 已购 %d 次`)、`SHOP_ATTR_BUY``购买 %dG`)。
> 📌 **预检订正**:初稿还要求加 `ATTR_CPU_LIMIT` 等 4 个属性名键 —— 那是硬编码属性表的另一种形态(加第 5 个属性就要改 4 个 `.po`)。属性显示名读 `attributes.json` 的 `display_name`(已是「中文 English」双语),故这 4 个键不需要。
> 代价如实说明:`display_name` 不经 `tr()`,即属性名不随语言切换 —— 但这与法术/核心/状态的 `display_name` 现状一致(路线图 E7-③「内容名 tr 化」是统一处理这批债务的独立小项),**本期不制造新的例外**。
⚠️ `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"]
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` + 路线图 + 本计划
> **回填说明(本节全部为 Task 6 实际执行版,2026-08-03**:简报里的 `ShopManager.buy_attribute("不存在的属性")` 作为阳性对照**不可用**——`buy_attribute` 对未知 id 走的是 `can_buy_attribute` 的字符串「禁用原因」分支(`"该属性不可购买"`),不触发 `push_error`,不会出现在错误通道里,会误判「阳性对照已确认」。改用 `PlayerStats.add_modifier("不存在的属性xyz", "flat", 1.0, "probe_unknown_attr")`,它经 `add_modifier` 的未知 `attr_id` 守卫真实 `push_error`。以下各 Step 均为实测脚本与实测输出,非简报原样照抄。
- [x] **Step 1: 启动战斗场景**
Run: godot-mcp-pro `play_scene``res://scenes/main/combat_s2.tscn`。实际输出 `{"mode": "res://scenes/main/combat_s2.tscn", "playing": true}`
⚠️ 工具坑 ①:运行时 `push_error` 不进 `get_output_log` / `get_editor_errors`。读**编辑器 Debugger 的「错误」树**(经 `execute_editor_script``ScriptEditorDebugger`)。**先做阳性对照**——原写「故意触发 `buy_attribute("不存在的属性")`」不可用(见上方回填说明),改用:
```gdscript
PlayerStats.add_modifier("不存在的属性xyz", "flat", 1.0, "probe_unknown_attr")
```
随后用下列 `execute_editor_script` 读错误树(对齐 E3-① Task 5 已验证的读法):
```gdscript
var dbg: Node = null
var stack: Array = [EditorInterface.get_base_control()]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n.get_class() == "ScriptEditorDebugger":
dbg = n; break
for ch in n.get_children():
stack.append(ch)
var tree: Tree = null
var st: Array = [dbg]
while not st.is_empty():
var n: Node = st.pop_back()
if n is Tree and n.columns == 2 and n.get_parent().name.begins_with("错误"):
tree = n; break
for ch in n.get_children():
st.append(ch)
for top in tree.get_root().get_children():
var t: String = top.get_text(1)
if t.contains("GDScript::reload"):
continue # 编译期告警,非运行时
_mcp_print(top.get_text(0) + " | " + t)
```
实际输出:`player_stats.gd:198 @ add_modifier(): PlayerStats: 未知属性「不存在的属性xyz」(来源 probe_unknown_attr),加成已忽略` —— 通道确认存活,后续「零报错」结论才有意义。会话结束前复读同一棵树,全程仅出现本条与 Step 4 的 `add_int` 拒绝各一条,共 2 条,无意外报错。
- [x] **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]`。**实际输出**(原样,无偏离):`FAILS=0 hp_max=133.1000 prices=[60, 69, 79] []`
- [x] **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」。实测确认开局装备确为 `wand_basic``combat_manager.gd:54` `_equipped_core = WandPreset.make_core_by_id("wand_basic")`),故 `0.5` 常数有效。
**实际输出**`FAILS=0 买了22次 mod=0.0985 原因=已达上限 帧距=3(应>1,证明未饱和)`。**校准**:到 `soft` 门槛实测需 **22 次购买**`cast_delay_mod` 每次 `pct` 复利 `step=0.10``combine=inverse``merged=1-(1-0.10)^n``base×(1-merged)``soft`(0.1) 在 `n=22` 时首次成立)——`task-6-brief.md` 未写具体次数;「22」这一数字的出处是 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md:133` 的设计表,本步运行时实测与之吻合(`1×0.9²²≈0.0985≤0.1` 首次在 `n=22` 成立),此处补全为确定值,供日后回归对照。
- [x] **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 加成`。**实际输出**`FAILS=0 before=5 after=5pct 应被 add_int 拒绝,二者相等)`;错误树命中 `attribute_formula.gd:45 @ compute(): AttributeFormula: add_int 属性不接受 pct 加成(value=0.500000),已忽略`,原样确认。
- [x] **Step 5: 存档往返(内存 API+ 旧档兼容 + 与 `"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)。
**实际输出**`FAILS=0 hp 133.10→清零→100.00 cpu 7``cpu_before=7` = 法杖 `wand_basic``core` 份额 5 + 购买 `cpu_limit` 两次的 `flat` 份额 2,回读后原样恢复,验证与 `"core"` 来源互不干扰)。
- [x] **Step 5b(新增,补前序评审 ⚠️):磁盘往返独立复核**
Task 3 评审记录「磁盘 save_run→load_run 完整往返未被独立复核」(`progress.md` Task 3 minor)。Step 5 用的是内存态 `get_attr_purchases_save`/`apply_attr_purchases_save` 往返,**不经过 JSON 序列化落盘**,不能替代磁盘复核。本步用 `ProfileManager.save_run()`/`load_run()`/`apply_run()` 走真实文件 I/O`play_scene` 运行时,`execute_editor_script` 会静默拦截 `FileAccess.WRITE`,此步骤只能在游戏运行时里做,工具坑 ⑥):
```gdscript
ShopManager.reset()
PlayerStats.gold = 9999
for i in 3: ShopManager.buy_attribute("hp_max")
for i in 2: ShopManager.buy_attribute("cpu_limit")
PlayerStats.hp = 130.0
var hp_before: float = PlayerStats.hp
var hp_max_before: float = PlayerStats.hp_max
var cpu_before: int = PlayerStats.cpu_limit
var purchases_before: Dictionary = ShopManager.get_attr_purchases_save()
ProfileManager.mark_dirty()
ProfileManager.save_run() # 真实写盘(user://run_a.json 或 run_b.jsonA/B 槽交替)
# 模拟"未加载前"的清空态:只清货架 C 购买(真实 apply_run 也不重置 corecore 由 wand 数据自身重建)
ShopManager.reset()
var loaded: Dictionary = ProfileManager.load_run() # 真实读盘 + JSON.parse_string
ProfileManager.apply_run(loaded)
var fails := 0
if abs(PlayerStats.hp - hp_before) > 0.0001: fails += 1
if abs(PlayerStats.hp_max - hp_max_before) > 0.0001: fails += 1
if PlayerStats.cpu_limit != cpu_before: fails += 1
if ShopManager.get_attr_purchases_save() != purchases_before: fails += 1
_mcp_print("DISK_ROUNDTRIP FAILS=%d hp=%.4f(应%.4f) hp_max=%.4f(应%.4f) cpu=%d(应%d)" % [
fails, PlayerStats.hp, hp_before, PlayerStats.hp_max, hp_max_before, PlayerStats.cpu_limit, cpu_before])
ProfileManager.clear_run() # 清理:不留测试存档
ShopManager.reset()
```
**实际输出**`DISK_ROUNDTRIP FAILS=0 cleared_hp_max=100.00 cleared_cpu=5 loaded_keys=["attr_purchases", "player_stats", "saved_at", "schema_version", "shop_seed", "wand", "wave_num"] attr_purchases={ "cpu_limit": 2.0, "hp_max": 3.0 } hp=130.0000(应130.0000) hp_max=133.1000(应133.1000) cpu=7(应7)``attr_purchases` 经 JSON 序列化后数值型变为 `float``2.0`/`3.0`),`get_attr_purchases_save()` 内部按 `int(d[k])` 转换,往返后与购买前逐位相等。
> **踩坑记录**:第一轮跑此脚本时得到 `FAILS=1``cpu=7` 应 `2`),排查后发现是脚本本身的问题——上一段测试脚本的清理代码里手误调了 `PlayerStats.reset_for_run()`(会连带清掉法杖的 `"core"` 加成来源且不会自动重建),污染了本段测试开始前的基线,与被测代码无关。教训:`reset_for_run()` 只应由 `combat_manager` 在真正开局/重开时调用,且必须伴随法杖重新装备(`_rebuild_wand()` 会重建 `"core"` 加成);手工验收脚本里绝不能单独调它做"清空状态",否则会污染同会话后续脚本的基线。
>
> **附带作用(评审发现)**:本步直接调用真实 `ProfileManager.apply_run()`(而非手工内联复现顺序),故它同时是**该函数顺序回归的哨兵**——对调 `profile_manager.gd:68-69` 的两行顺序会使本步 `FAILS=1`(评审实测 `hp=100.0000` 应 `130.0000`,脚本本身未改一字)。Step 6 验证的是机制本身(见下),本步(Step 5b)才是覆盖 `apply_run()` 实现的回归哨兵。
- [x] **Step 6: 回读顺序不吞血(Task 3 的核心风险)**
⚠️ **回填:简报原脚本是空断言,已替换**。原脚本在**全新会话**`_modifiers` 本就为空)下跑:`ShopManager.reset()` 之后 `_modifiers` 已空,`apply_attr_purchases_save` 内部 `remove_modifiers_from` 因无可移除项而提前 `return`(不触发 `_recompute_attrs`),单次 `add_modifier` 直接算出最终 `hp_max`,**两种顺序结果一样**——把两行顺序对调,原脚本的 `FAILS` 依然是 0。这正是 Task 3 报告里记录的同一个陷阱(`progress.md` Task 3:「按后者写的回归断言恒绿」),Task 6 沿用简报原脚本会重蹈覆辙,故按 Task 3 报告给出的真实复现场景重写:
```gdscript
ShopManager.reset()
PlayerStats.gold = 9999
ShopManager.buy_attribute("hp_max") # 1 次既有购买 → hp_max=110_modifiers 非空
var saved: Dictionary = {"hp_max": 3} # 模拟存档目标:3 次购买
var ps: Dictionary = {"hp": 130.0, "gold": PlayerStats.gold, "xp": 0, "level": 1}
# 错误顺序:先 load_save_data,再 apply_attr_purchases_save
# (此时 _modifiers 非空,remove_modifiers_from 会真正命中并触发即时 recompute)
PlayerStats.load_save_data(ps)
ShopManager.apply_attr_purchases_save(saved)
# WRONG ORDER 实测: hp=100.00 hp_max=133.10 —— hp 被钳到 100,吞了 30 血
# 复原到同样的「1 次既有购买」前提,测正确顺序
ShopManager.reset(); PlayerStats.gold = 9999; ShopManager.buy_attribute("hp_max")
PlayerStats.hp = PlayerStats.hp_max
# 正确顺序:先 apply_attr_purchases_save,再 load_save_data= apply_run 实际顺序)
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("CORRECT ORDER FAILS=%d final hp=%.2f hp_max=%.2f(应 130/133.1" % [fails, PlayerStats.hp, PlayerStats.hp_max])
```
**实际输出**(两遍都跑,红/绿对照):
- 错误顺序:`WRONG after load_save_data only: hp=130.00 hp_max=110.00``WRONG ORDER final: hp=100.00 hp_max=133.10`(吞 30 血,复现 Task 3 报告的原始发现)。
- 正确顺序:`CORRECT ORDER FAILS=0 final hp=130.00 hp_max=133.10`(未被钳,与 `hp_before`=130 完全一致)。
本步独立复核 Task 3 的核心风险机制(`_modifiers` 非空时 `remove_modifiers_from` 才真正触发 `_recompute_attrs`,从而在「先 load 后 apply」下产生错误的中间钳位)。**注意本步是手工内联两种顺序、独立于 `apply_run()` 实现**(脚本全程未调用 `ProfileManager.apply_run()`),故它验证的是机制本身而非 `apply_run()`——对调 `apply_run()` 内部两行顺序**不会**影响本步的红/绿结果(评审已实测确认);`apply_run()` 的顺序回归哨兵是 **Step 5b**(见下)。
- [x] **Step 7: `shop` 段缺失即不上架(验收 16)**
⚠️ **回填:简报原脚本对"过滤机制"是空断言,已补充**。原脚本只断言 `get_sellable_attrs().size() == 4`——但 `attributes.json` 目前**恰好 4 个属性且都有 `shop` 段**,若把 `get_sellable_attrs()` 里按 `shop` 段过滤的 `if` 整个删掉(返回全部属性),结果仍是这 4 个,断言依然为绿,不构成对「无 `shop` 段不上架」这条机制的真实检验。补一段直接向 `ShopManager._attr_def` 注入合成属性(无 `shop` 段)的机制测试:
```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)])
# 机制测试:注入一个无 shop 段的合成属性,验证真被排除(而非仅验证现有 4 个数据事实)
ShopManager._attr_def["synthetic_no_shop"] = {"display_name": "合成测试属性", "base": 0.0, "soft": 0.0, "hard": 0.0, "combine": "flat"}
var ids_with_synthetic: Array = ShopManager.get_sellable_attrs()
var excluded_ok: bool = not ids_with_synthetic.has("synthetic_no_shop")
_mcp_print("FAILS=%d ids_with_synthetic=%s excluded_ok=%s" % [(0 if excluded_ok else 1), str(ids_with_synthetic), str(excluded_ok)])
ShopManager._attr_def.erase("synthetic_no_shop") # 清理注入,恢复原定义
```
Expected: `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]`
> 若日后有属性无 `shop` 段,此断言的期望数需随之调整 —— 它守的是「按 `shop` 段筛选」这个机制,不是「恰好 4 个」。
**实际输出**:数据事实断言 `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]`(四个都有 shop 段);机制断言 `FAILS=0 ids_with_synthetic=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"] excluded_ok=true`(合成属性被正确排除);清理后 `cleanup_ok=true ids=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]`,未污染真实定义。
- [x] **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` 才是实际约束。
**实际落地**:两处均按简报原样写入 `docs/design/numerical_design.md`(消耗清单新增「购买属性 (60-120G 起,几何递增)...」一句;`cast_delay_mod` 行追加饱和点/`soft` 关系一句,并补上 `soft` 门槛实测 22 次购买、帧距仍为 3(未饱和)的交叉引用),无文字偏离。
- [x] **Step 9: 路线图勾除**
`docs_dev/plans/2026-07-23-missing-features-roadmap.md` 的 E3 切分列表第 2 项「货架 C(属性购买)」已标记完成,格式对齐已完成的第 1 项:勾除 + 完成摘要(`PriceFormula` 模块、`ShopManager` 状态、`attributes.json` `shop` 段、`ProfileManager` schema 3、UI 子面板、设计器字段)+ 实际范围说明(仅 4 个框架内属性;B 类 7 个属性待各自前置解除后加 `shop` 段即可零代码上架,已用注入合成属性的方式运行时验证该机制)+ `cast_delay_mod` soft 验证摘要。
- [x] **Step 10: 回填本计划为实际执行版**
实现过程中若因评审改动了代码或断言,**本计划对应的代码块与脚本必须回填为实际落地的版本**,并勾上各任务复选框(只留 Step 11 未勾)。每处回填旁注明**原写法为何不可用** —— 那是最耐久的价值。归航与 E3-① 两期都踩过「计划里留着过时代码和失效断言」的坑。
**本步执行记录**:本 Task 6 一节(Step 1–9)已全部回填为实测脚本与实测输出,三处发现简报原脚本不可用并已替换/补充:
1. **Step 1 阳性对照**`ShopManager.buy_attribute("不存在的属性")` 不触发 `push_error`(走禁用原因字符串分支),改用 `PlayerStats.add_modifier` 未知属性守卫。
2. **Step 5b(新增)**:Task 3 评审记录磁盘往返未被独立复核,原计划里 Step 5 的"存档往返"实为内存态 API 往返(不落盘),补一段真实 `ProfileManager.save_run()/load_run()/apply_run()` 磁盘往返验证,回应该评审意见。
3. **Step 6**:简报原脚本在全新会话(`_modifiers` 为空)下测,`remove_modifiers_from` 因无可移除项提前 return,两种顺序结果相同,是空断言(与 Task 3 报告记录的同一陷阱)。改用 Task 3 报告里给出的真实复现场景(先有一次既有购买,`_modifiers` 非空),红/绿对照跑通。
4. **Step 7**:简报原脚本只断言 `ids.size()==4`,对"按 `shop` 段过滤"这一机制是空断言(当前数据恰好 4 个都有 `shop` 段,删掉过滤逻辑结果不变)。补一段注入无 `shop` 段合成属性的机制测试。
全部 17 条验收标准 + Task 3 遗留的回读顺序风险,逐条有实际运行输出,无空断言(自审要点已核对:删掉对应实现代码,每条断言均会变红)。
- [ ] **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(内存 API+ Step 5b(真实磁盘 `save_run`/`load_run`,补前序评审 ⚠️ | 运行时 |
| 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 | 运行时 |