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>
62 KiB
货架 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-① 实测得出,均为假通过陷阱)
- 运行中游戏的
push_error/push_warning既不进get_output_log也不进get_editor_errors,只有print()进。真通道是编辑器 Debugger 的「错误」Tree(经execute_editor_script走ScriptEditorDebugger,每次play_scene自动重置)。任何「零报错」结论必须配阳性对照 —— 先故意触发一次,确认它出现在你读的通道里。 - 改完源码后用已注册的全局类名或编辑器 autoload 实例验证,会拿到过时结果(编辑器持有旧编译副本,E3-① 两次差点被骗)。验证
class_name脚本用ResourceLoader.load(path, "GDScript", ResourceLoader.CACHE_MODE_IGNORE)—— 绕缓存从磁盘重编译、保留class_name、走引擎自己的编译器。 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,失败即真错误。clear_output不清get_output_log的缓冲区 —— 要证明「零警告」须先快照基线再逐行比对。- 编辑器里 autoload 根本没实例化(
root只有EditorNode),依赖 autoload 的验证必须play_scene。 execute_editor_script静默阻止运行时FileAccess.WRITE,即使传allow_unsafe_editor_io=true。var x := <Variant 方法调用>是编译错误且无行号(Script compilation failed)。持有动态GDScript引用时必须写var r: int = F.compute(...)。- 新增
.gd后.gd.uid不会自动出现,需EditorInterface.get_resource_filesystem().scan()触发;项目约定二者一并提交。 - 写断言时先问:把被测代码删掉,这条断言会变红吗? 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 规定主分支上不做功能开发。
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:
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:
## 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:
# 工具坑 ②:用 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:
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:
EditorInterface.get_resource_filesystem().scan()
_mcp_print("scan requested")
确认 scripts/domain/price_formula.gd.uid 已出现后:
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)之后新增
var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取
_recompute_attrs() 内,在写回裸字段之后、stats_changed.emit() 之前追加:
# 冷路径通用视图:热路径(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,
}
文件末尾新增:
## 按 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 键(其余字段不动):
"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)之后新增:
## 货架 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)之后新增:
var _attr_purchases: Dictionary = {} # attr_id(String) → 已购次数(int);唯一写入点 _apply_attr_purchases()
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
在 _ready()(:18)开头加载定义:
func _ready() -> void:
_load_attr_definitions()
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
- Step 3:
ShopManager加货架 C 逻辑
在文件末尾(reset() 之前)新增:
# ── 货架 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)改为:
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:
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: 提交
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:
const SCHEMA_VERSION: int = 3 # v3:新增 attr_purchases(货架 C 属性购买次数)
- Step 2: 写入存档
_collect_run_data()(:88)的字典字面量中,在 "player_stats" 之后加一行:
"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 的前两行改为:
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: 块之后追加:
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: 提交
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=200–540),现有控件已排到 y≈479(_inv_btn/_lang_btn/设置按钮那一行 y=445–479),_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」的假设:
# ── 货架 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)加:
var _attr_rows: Array = [] # [{id: String, label: Label, button: Button}]
- Step 3: 刷新与购买回调
在 _refresh_shop_ui() 末尾追加对属性区的刷新(函数名按 Step 1 读到的实际名):
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()
新增回调:
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 语言键集一致:
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: 提交
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 控件未入树时会出错,项目已踩过):
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),防丢未列入表单的键):
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。)
常量:
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:
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。
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 均为实测脚本与实测输出,非简报原样照抄。
- 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("不存在的属性")」不可用(见上方回填说明),改用:
PlayerStats.add_modifier("不存在的属性xyz", "flat", 1.0, "probe_unknown_attr")
随后用下列 execute_editor_script 读错误树(对齐 E3-① Task 5 已验证的读法):
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 条,无意外报错。
- Step 2: 连乘正确性 + 金币序列(验收 7、8)
Run: godot-mcp-pro execute_game_script:
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] []。
- Step 3:
soft闸 +cast_delay_mod尚未饱和(验收 9、10)
spec §2.3 自审订正:三个饱和点全部低于
soft(0.1),正常购买够不到,UI 只需soft一道闸。本步同时证明这一点。
Run: godot-mcp-pro execute_game_script:
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」。实测确认开局装备确为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 成立),此处补全为确定值,供日后回归对照。
- Step 4:
cpu_limit的add_int硬约束(验收 11)
Run: godot-mcp-pro execute_game_script —— 直接对 PlayerStats 施加 pct 加成,确认被拒(shop 段配的是 flat,此处验证的是底层约束仍在):
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 加成。实际输出:FAILS=0 before=5 after=5(pct 应被 add_int 拒绝,二者相等);错误树命中 attribute_formula.gd:45 @ compute(): AttributeFormula: add_int 属性不接受 pct 加成(value=0.500000),已忽略,原样确认。
- Step 5: 存档往返(内存 API)+ 旧档兼容 + 与
"core"共存(验收 12、14)
Run: godot-mcp-pro execute_game_script:
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" 来源互不干扰)。
- 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,此步骤只能在游戏运行时里做,工具坑 ⑥):
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.json,A/B 槽交替)
# 模拟"未加载前"的清空态:只清货架 C 购买(真实 apply_run 也不重置 core,core 由 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()实现的回归哨兵。
- 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 报告给出的真实复现场景重写:
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(见下)。
- Step 7:
shop段缺失即不上架(验收 16)
⚠️ 回填:简报原脚本对"过滤机制"是空断言,已补充。原脚本只断言 get_sellable_attrs().size() == 4——但 attributes.json 目前恰好 4 个属性且都有 shop 段,若把 get_sellable_attrs() 里按 shop 段过滤的 if 整个删掉(返回全部属性),结果仍是这 4 个,断言依然为绿,不构成对「无 shop 段不上架」这条机制的真实检验。补一段直接向 ShopManager._attr_def 注入合成属性(无 shop 段)的机制测试:
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"],未污染真实定义。
- Step 8: 权威文档补录(spec §2.7)
docs/design/numerical_design.md §1.2 经济系统的消耗清单(现仅「购买法术 50-500G / 购买法杖 200-2000G / 刷新商店 20G*」三项)新增一项:
购买属性 (60-120G 起,几何递增):各属性首购价与成长曲线见
data/attributes.json的shop段(hp_max60×1.15ⁿ /move_speed70×1.15ⁿ /cast_delay_mod90×1.20ⁿ /cpu_limit120×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(未饱和)的交叉引用),无文字偏离。
- 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 验证摘要。
- Step 10: 回填本计划为实际执行版
实现过程中若因评审改动了代码或断言,本计划对应的代码块与脚本必须回填为实际落地的版本,并勾上各任务复选框(只留 Step 11 未勾)。每处回填旁注明原写法为何不可用 —— 那是最耐久的价值。归航与 E3-① 两期都踩过「计划里留着过时代码和失效断言」的坑。
本步执行记录:本 Task 6 一节(Step 1–9)已全部回填为实测脚本与实测输出,三处发现简报原脚本不可用并已替换/补充:
- Step 1 阳性对照:
ShopManager.buy_attribute("不存在的属性")不触发push_error(走禁用原因字符串分支),改用PlayerStats.add_modifier未知属性守卫。 - Step 5b(新增):Task 3 评审记录磁盘往返未被独立复核,原计划里 Step 5 的"存档往返"实为内存态 API 往返(不落盘),补一段真实
ProfileManager.save_run()/load_run()/apply_run()磁盘往返验证,回应该评审意见。 - Step 6:简报原脚本在全新会话(
_modifiers为空)下测,remove_modifiers_from因无可移除项提前 return,两种顺序结果相同,是空断言(与 Task 3 报告记录的同一陷阱)。改用 Task 3 报告里给出的真实复现场景(先有一次既有购买,_modifiers非空),红/绿对照跑通。 - 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 | 运行时 |