# 货架 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 := ` 是编译错误且无行号**(`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 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)^n−1 # inverse: 需 f=(1−step)^n → merged=1−(1−step)^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 必须是 flat(add_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 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_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 去钳, 静默吞血。触发前提是 _modifiers 里已有同源加成(同进程内二次 apply_run), 此时 remove_modifiers_from 才真正命中并触发一次中间态重算。 Co-Authored-By: Claude Opus 5 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) 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 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 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 | 运行时 |