# 玩家属性系统（Player Attributes）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:** 建立数据驱动的玩家属性框架（含独立的加成公式模块），接线三条既有死数据（`cpu_limit` / `move_speed` / `cast_delay_mod`），并为货架 C 提供 `add_modifier` 接入点。

**Architecture:** 公式与状态分离。`AttributeFormula`（`class_name` 纯静态模块，零依赖）是所有合成公式的唯一实现处，因此可脱离游戏进程单元断言；`PlayerStats` 只负责加载 `attributes.json`、持有加成列表、把公式结果写进静态类型裸字段（读取端零开销）。法杖的 `cpu_limit` 以 `source="core"` 的加成来源接入（换杖时重算，与既有 `mana_leech` 同处同时机），使 `hard` 上限天然作用于生效总值。

**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro（公式为纯函数 → `execute_editor_script` 单元断言；接线为运行时 `execute_game_script` 实测）。

> **规范**：提交中文；纯数据驱动，文件缺失 `push_error` 不静默回退。spec：`docs_dev/specs/2026-07-31-player-attributes-design.md`。
> **已知工具坑**：
> ① `validate_script` 对任何带 `class_name` 的脚本必然假阴性（报 `hides a global script class`）—— `attribute_formula.gd` 属此类；`player_stats.gd` / `player_manager.gd` / `spell_evaluator.gd` / `combat_manager.gd` / `attribute_tab.gd` / `designer_panel.gd` 无 `class_name`，可正常 `validate_script`。
> ② **验证 `class_name` 脚本的首选手段（Task 1 实测得出，优于下面两种绕法）**：
> ```gdscript
> var S = ResourceLoader.load(path, "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
> ```
> 强制绕过引擎缓存、从磁盘重新编译，**保留 `class_name` 原样**且走引擎自己的编译器 —— 因此它顺带证明了静态类型标注（如 `Dictionary[String, Combine]`、`for m: Dictionary`）真能被引擎编译，而不只是能被动态副本解析。无需 `restart_editor`，也不必改源码。
> ③ **⚠️ 直接调用已注册的全局类名会拿到过时结果，是假通过陷阱。** Task 1 修完文件后经 `AttributeFormula.compute(...)` 直调，返回的仍是**修复前**的数值 —— 编辑器持有的已编译类是旧的。**改完源码后不要用全局类名验证**，用 ② 的 `CACHE_MODE_IGNORE`。
> ④ `GDScript.new()` + `source_code` + `reload()` 这个绕法，在该类被注册为全局类之后会失败（同一条 `hides a global script class`）；若要用必须先 `src.replace("class_name AttributeFormula\n", "")` 剥掉那行。`@tool` 编辑器类在 dock 里被缓存，同理。**优先用 ②。**
> ⑤ **`var x := <Variant 方法调用>` 在 4.7.1 是编译错误而非警告**，且报错无行号（`Script compilation failed`），极难定位。断言夹具里若持有动态 `GDScript` 引用 `F`，必须写 `var r: float = F.compute(...)` 而非 `var r := F.compute(...)`。
> ⑥ `execute_editor_script` 静默阻止运行时 `FileAccess.WRITE`。
> ⑦ 新增 `.gd` 后 `.gd.uid` 不会自动出现，需 `EditorInterface.get_resource_filesystem().scan()` 触发扫描才生成；项目约定二者一并提交。
> **关键既有事实**：`spell_evaluator.gd:332` `var max_ops: int = MAX_OPS_PER_CPU * 5`（`MAX_OPS_PER_CPU = 40`，`:8`）。`player_manager.gd:7` `const MOVE_SPEED: float = 200.0`，`:45` 消费，`:88` `_cast_interval = core.cast_interval if core else 0.5`。`combat_manager.gd:257` `_rebuild_wand()`，`:267-271` 为 `mana_leech` 重算段。`player_stats.gd`：`hp_max` :10、`cpu_limit` :21、`armor` :22、`resistance` :23、`_ready()` :33、`reset_for_run()` :125、`get_save_data()` :135、`load_save_data()` :138。`CompiledDeck` **没有** `cpu_limit` 字段（只有 `feature_tags`），故 `spell_evaluator` 侧不需要新增字段。

---

### Task 0: 建功能分支

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

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

```bash
git checkout -b feat/player-attributes
```

Expected: `Switched to a new branch 'feat/player-attributes'`

---

### Task 1: `AttributeFormula` 公式模块（TDD）

**Files:**
- Create: `scripts/domain/attribute_formula.gd`
- Create: `scripts/domain/attribute_formula.gd.uid`（Godot 自动生成，随后提交）

> 本任务是纯函数，**先写断言、看它失败、再实现**。断言脚本不落盘为文件（项目无测试框架），而是经 `execute_editor_script` 执行 —— 与既有验收方式一致。

- [ ] **Step 1: 先跑断言脚本，确认它因类不存在而失败**

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
_mcp_print("exists=%s" % str(ClassDB.class_exists("AttributeFormula") or ResourceLoader.exists("res://scripts/domain/attribute_formula.gd")))
```
Expected: `exists=false`。若为 `true` 说明文件已存在，先确认不是残留。

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

Create `scripts/domain/attribute_formula.gd`：
```gdscript
## AttributeFormula — 属性合成公式的唯一实现处
## 纯静态函数：无状态、不依赖 PlayerStats / EventBus / 场景树，故可脱离游戏进程单元测试
## 权威来源：docs_dev/specs/2026-07-31-player-attributes-design.md §2.1 / §2.3
class_name AttributeFormula
extends RefCounted

enum Combine { HYBRID, INVERSE, ADD_INT }

const _COMBINE_NAMES: Dictionary = {
	"hybrid":  Combine.HYBRID,
	"inverse": Combine.INVERSE,
	"add_int": Combine.ADD_INT,
}

## JSON 的 combine 字符串 → 枚举；未知值 push_error 并回退 HYBRID
static func combine_from_string(s: String) -> Combine:
	if _COMBINE_NAMES.has(s):
		return _COMBINE_NAMES[s]
	push_error("AttributeFormula: 未知 combine「%s」，回退 hybrid" % s)
	return Combine.HYBRID

## 唯一的公式入口
## base     —— attributes.json 的基准值
## mods     —— [{"mode": "flat"|"pct", "value": float}, ...]；调用方负责只传本属性的加成
## attr_def —— attributes.json 中该属性的定义（读 combine / hard）
## 返回统一为 float；add_int 属性由调用方做 int() 转换（公式模块不感知目标字段类型）
static func compute(base: float, mods: Array, attr_def: Dictionary) -> float:
	var combine: Combine = combine_from_string(String(attr_def.get("combine", "hybrid")))
	var hard: float = float(attr_def.get("hard", 0.0))
	var flat_sum: float = 0.0
	var pct_prod: float = 1.0
	for m in mods:
		var mode: String = String(m.get("mode", "flat"))
		var v: float = float(m.get("value", 0.0))
		if mode == "flat":
			flat_sum += v
		elif mode == "pct":
			if combine == Combine.ADD_INT:
				push_error("AttributeFormula: add_int 属性不接受 pct 加成（value=%f），已忽略" % v)
				continue
			# 连乘而非线性求和：三条 +20% = ×1.728 而非 ×1.6，避免后期线性失控
			pct_prod *= (1.0 + v) if combine == Combine.HYBRID else (1.0 - v)
		else:
			push_error("AttributeFormula: 未知 mode「%s」，已忽略" % mode)
	match combine:
		Combine.ADD_INT:
			var ri: float = floorf(base + flat_sum)
			return clampf(ri, 0.0, hard) if hard > 0.0 else maxf(0.0, ri)
		Combine.INVERSE:
			# 越低越快：hard 是**下限**
			return maxf(hard, (base + flat_sum) * pct_prod)
		_:
			var r: float = maxf(0.0, (base + flat_sum) * pct_prod)
			return minf(r, hard) if hard > 0.0 else r   # hard=0.0 约定为「不钳制」
```

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

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
# 绕开 class_name 缓存/假阴性：从磁盘构造全新类
var S := GDScript.new()
S.source_code = FileAccess.get_file_as_string("res://scripts/domain/attribute_formula.gd")
if S.reload() != OK:
	_mcp_print("PARSE_FAIL"); return
var F = S

var HYB := {"combine": "hybrid",  "hard": 0.0}
var HYB_CAP := {"combine": "hybrid", "hard": 800.0}
var INV := {"combine": "inverse", "hard": 0.01}
var INT := {"combine": "add_int", "hard": 50.0}

# ⚠️ 不要把断言写进 lambda 再累加外层 int —— GDScript 闭包对 int 是**按值捕获**，
#    `fails += 1` 传不回外层，FAILS 会恒为 0，整个断言变空。用 Array 逐条收集。
var m3 := [{"mode":"pct","value":0.2},{"mode":"pct","value":0.2},{"mode":"pct","value":0.2}]
var many := []
for i in 20: many.append({"mode":"pct","value":0.5})

var cases := [
	["①连乘",      F.compute(1.0,   m3, HYB),                                                              1.728],
	["②混合",      F.compute(200.0, [{"mode":"flat","value":50.0},{"mode":"pct","value":0.2}], HYB),        300.0],
	["③inverse",   F.compute(1.0,   m3, INV),                                                              0.512],
	["③b下限",     F.compute(1.0,   many, INV),                                                            0.01],
	["④拒pct",     F.compute(0.0,   [{"mode":"flat","value":5.0},{"mode":"pct","value":0.5},{"mode":"flat","value":8.0}], INT), 13.0],
	["⑤上限",      F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB_CAP),                            800.0],
	["⑤b不钳制",   F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB),                                5200.0],
	["⑥空",        F.compute(123.0, [], HYB),                                                              123.0],
	["⑥b未知",     F.compute(1.0,   m3, {"combine": "bogus", "hard": 0.0}),                                1.728],
	["⑥c整数上限", F.compute(0.0,   [{"mode":"flat","value":999.0}], INT),                                 50.0],
]
var fails := 0
var bad := []
for c in cases:
	if abs(float(c[1]) - float(c[2])) > 0.0001:
		fails += 1
		bad.append("%s got=%.6f want=%.6f" % [c[0], c[1], c[2]])
_mcp_print("FAILS=%d %s" % [fails, str(bad)])
```
Expected: `FAILS=0 []`

> ⚠️ **断言 ④ 的数字部分对「拒绝 `pct`」是空的**（Task 1 评审实测发现）：把整个拒绝块删掉，④ 仍返回 13.0 —— 因为 `ADD_INT` 的 match 分支根本不读 `pct_prod`。数字只证明了「`pct` 之后的 `flat` 仍能累加」。**拒绝行为唯一的可观测证据是 `push_error`**，必须单独断言，否则意图②无人守卫。

- [ ] **Step 3b: 断言 `push_error` 真的发出（补 ④ 的空缺）**

用**唯一标记值**发起调用，再从编辑器错误日志里找它 —— 标记值确保读到的不是历史遗留日志。

Run: godot-mcp-pro `clear_output`，然后 `execute_editor_script`：
```gdscript
var S := GDScript.new()
# ⚠️ 必须剥掉 class_name：该类此时已被引擎注册为全局类，带 class_name 的内存副本会报
#    "hides a global script class"（Task 1 首次运行时尚未注册才侥幸通过）
S.source_code = FileAccess.get_file_as_string("res://scripts/domain/attribute_formula.gd") \
	.replace("class_name AttributeFormula\n", "")
if S.reload() != OK:
	_mcp_print("PARSE_FAIL"); return
# 三条各带唯一标记，逐一触发报错路径
S.compute(0.0, [{"mode":"pct","value":0.123456}], {"combine":"add_int","hard":50.0})
S.compute(1.0, [], {"combine":"zzzprobe9182","hard":0.0})
S.compute(1.0, [{"mode":"zzzmode9182","value":1.0}], {"combine":"hybrid","hard":0.0})
_mcp_print("probes_fired")
```
再 Run: godot-mcp-pro `get_editor_errors`，断言三条标记均出现：
- `add_int 属性不接受 pct 加成（value=0.123456`
- `未知 combine「zzzprobe9182」`
- `未知 mode「zzzmode9182」`

Expected: 三条全部命中。**任一缺失即说明对应的报错路径未执行** —— 那是真缺陷，不是日志问题。

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

```bash
git add scripts/domain/attribute_formula.gd scripts/domain/attribute_formula.gd.uid
git commit -F- <<'EOF'
feat(attr): AttributeFormula 公式模块——hybrid 连乘 / inverse 反向下限 / add_int 拒 pct

公式集中于单一纯静态模块，无状态零依赖，故可脱离游戏进程单元断言。
乘算用连乘而非线性求和：三条 +20% 得 ×1.728 而非 ×1.6，避免后期线性失控。
add_int 对离散预算拒绝 pct 并 push_error，而非静默取整掩盖配置错误。

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

---

### Task 2: `attributes.json` + `PlayerStats` 框架 + 删孤儿字段

**Files:**
- Create: `data/attributes.json`
- Modify: `scripts/autoloads/player_stats.gd`

- [ ] **Step 1: 新建 `data/attributes.json`**

```json
{
  "cpu_limit":      { "display_name": "运算力 cpu_limit",        "base": 0.0,   "soft": 20.0,   "hard": 50.0,  "combine": "add_int" },
  "move_speed":     { "display_name": "移动速度 move_speed",      "base": 200.0, "soft": 600.0,  "hard": 800.0, "combine": "hybrid"  },
  "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod",  "base": 1.0,   "soft": 0.1,    "hard": 0.01,  "combine": "inverse" },
  "hp_max":         { "display_name": "最大生命 hp_max",          "base": 100.0, "soft": 2000.0, "hard": 0.0,   "combine": "hybrid"  }
}
```

- [ ] **Step 2: `PlayerStats` 加属性框架**

`player_stats.gd`：将 `var hp_max: float = 100.0`（:10）保留（生效值字段），并在「战斗属性」区（:21-23）**整段替换** —— 删 `cpu_limit` 旧声明、删 `armor`、删 `resistance`，改为：
```gdscript
# ── 属性（生效值：静态类型裸字段，读取端零开销）─────────────
# 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出，勿直接赋值
var cpu_limit:      int   = 0     # 生效运算力（含法杖 "core" 加成）；MAX_OPS = 本值 × 40
var move_speed:     float = 200.0 # 像素/秒
var cast_delay_mod: float = 1.0   # 施法间隔乘算系数，越低越快
var _invuln_until_msec: int = 0   # < now 表示可受击；受击后设为 now + iframe 窗口

# 属性定义与加成来源（非热路径）
var _attr_def:  Dictionary = {}          # attributes.json 全量定义，只读
var _modifiers: Array[Dictionary] = []   # [{attr_id, mode, value, source}]
```
（`_invuln_until_msec` 原在 :24，保持不动，此处只是展示相邻上下文。）

在 `_ready()`（:33）**开头**加载定义并首次重算（必须早于任何存档回读）：
```gdscript
func _ready() -> void:
	_load_attr_definitions()
	_recompute_attrs()
	EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
```

在文件末尾新增：
```gdscript
# ── 属性框架 ──────────────────────────────────────────────
const _ATTR_PATH: String = "res://data/attributes.json"

func _load_attr_definitions() -> void:
	if not FileAccess.file_exists(_ATTR_PATH):
		push_error("PlayerStats: 缺少 %s" % _ATTR_PATH)
		return
	var parsed = JSON.parse_string(FileAccess.get_file_as_string(_ATTR_PATH))
	if not (parsed is Dictionary):
		push_error("PlayerStats: %s 格式错误（应为对象）" % _ATTR_PATH)
		return
	_attr_def = parsed

## 追加一条加成来源；同一 source 可对多个属性各加一条
func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void:
	_modifiers.append({"attr_id": attr_id, "mode": mode, "value": value, "source": source})
	_recompute_attrs()

## 撤销某来源的全部加成（换杖、出售退款用）
func remove_modifiers_from(source: String) -> void:
	var kept: Array[Dictionary] = []
	for m in _modifiers:
		if String(m.get("source", "")) != source:
			kept.append(m)
	_modifiers = kept
	_recompute_attrs()

func _mods_for(attr_id: String) -> Array:
	var out: Array = []
	for m in _modifiers:
		if String(m.get("attr_id", "")) == attr_id:
			out.append(m)
	return out

## 逐属性按各自 combine 公式重算生效值，写回裸字段
func _recompute_attrs() -> void:
	if _attr_def.is_empty():
		return
	cpu_limit      = int(_compute_attr("cpu_limit"))
	move_speed     = _compute_attr("move_speed")
	cast_delay_mod = _compute_attr("cast_delay_mod")
	hp_max         = _compute_attr("hp_max")
	stats_changed.emit()

func _compute_attr(attr_id: String) -> float:
	var d: Dictionary = _attr_def.get(attr_id, {})
	if d.is_empty():
		push_error("PlayerStats: attributes.json 缺少属性「%s」" % attr_id)
		return 0.0
	return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)
```

- [ ] **Step 3: 存档字段清理（`hp_max` / `cpu_limit` 现为派生值）**

两者现在由 `attributes.json` + `_modifiers` 算出，存档回读会覆盖公式结果。`get_save_data()`（:135）与 `load_save_data()`（:138）改为：
```gdscript
func get_save_data() -> Dictionary:
	return {"hp": hp, "gold": gold, "xp": xp, "level": level}

func load_save_data(data: Dictionary) -> void:
	hp        = float(data.get("hp",    100.0))
	gold      = int(data.get("gold",    0))
	xp        = int(data.get("xp",      0))
	level     = int(data.get("level",   1))
	xp_to_next = xp_for_level(level)
	stats_changed.emit()
```
旧存档里的 `hp_max` / `cpu_limit` 键被忽略即可，**不需要提升 schema 版本号**：`hp_max` 全项目从无写入方（永远是 100），`cpu_limit` 从未被读取过。

> **留给货架 C 的提示**（写进代码注释）：一旦玩家可购买属性，需要持久化的是 `_modifiers`（来源列表）而非派生的生效值，届时 `get_save_data` 应加 `"attr_modifiers": _modifiers` 并提升 schema 版本。`source == "core"` 的那条**不要**持久化——它在换杖时由 `combat_manager._rebuild_wand` 重建。

在 `_recompute_attrs()` 上方加注释记录这一点。

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

Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/player_stats.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
for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]:
	if not (d is Dictionary) or not d.has(k): fails += 1
if abs(float(d["move_speed"]["base"]) - 200.0) > 0.001: fails += 1
if String(d["cpu_limit"]["combine"]) != "add_int": fails += 1
if String(d["cast_delay_mod"]["combine"]) != "inverse": fails += 1
var src := FileAccess.get_file_as_string("res://scripts/autoloads/player_stats.gd")
if src.contains("var armor") or src.contains("var resistance"): fails += 1
_mcp_print("FAILS=%d" % fails)
```
Expected: `FAILS=0`

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

```bash
git add data/attributes.json scripts/autoloads/player_stats.gd
git commit -F- <<'EOF'
feat(attr): attributes.json + PlayerStats 属性框架；删孤儿字段 armor/resistance

PlayerStats 只负责加载定义、持有加成列表、把公式结果写进静态类型裸字段，
不含任何公式（公式在 AttributeFormula）。读取端零开销以满足热路径纪律
（move_speed 每物理帧被 player_manager 读）。

armor/resistance 零消费方且不在 numerical_design §1.1 权威属性表内，属实现
先于设计的残留，删除；真要做玩家侧减伤时按货架 C 属性词条立项（见 spec §2.6）。

hp_max/cpu_limit 现为派生值，从存档字段移除——回读会覆盖公式结果。
旧存档相应键忽略即可，无需提升 schema 版本（hp_max 全项目从无写入方、
cpu_limit 从未被读取）。

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

---

### Task 3: 四处接线

**Files:**
- Modify: `scripts/domain/spell_system/spell_evaluator.gd:332`
- Modify: `scripts/domain/player_manager.gd:7,45,88`
- Modify: `scripts/domain/combat/combat_manager.gd:267-271`

- [ ] **Step 1: MAX_OPS 读生效 `cpu_limit`**

`spell_evaluator.gd`，把 `:332` 的
```gdscript
	var max_ops:           int = MAX_OPS_PER_CPU * 5
```
改为
```gdscript
	# 生效 cpu_limit 已含法杖份额（combat_manager 以 source="core" 的加成接入）
	# 0 守卫：cpu_limit=0 意味着所有法术执行零步、全局静默失效。此处是该故障唯一可观测处，
	# 能同时兜住上游两种失败（attributes.json 缺失 → 裸字段停在 0；add_modifier 的 attr_id 打错 →
	# "core" 份额没注入），而根因处的报错够不到这两种。
	if PlayerStats.cpu_limit <= 0:
		push_error("SpellEvaluator: PlayerStats.cpu_limit=%d，法术将无法执行；检查 attributes.json 与 _rebuild_wand 的 core 加成注入" % PlayerStats.cpu_limit)
	var max_ops:           int = MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)
```

> **为什么在这里也守一道**（Task 2 代码质量评审建议）：`cpu_limit = 0` 的后果是「所有法术静默什么都不做」—— 症状离根因极远。而这里是它变得**可观测**的地方，一条 `push_error` 就能指向真正该查的两处。`maxi(..., 1)` 使游戏在故障下仍可玩（退化为 40 步）而非完全瘫痪。

- [ ] **Step 2: 移速改读属性**

`player_manager.gd`：删除 `:7` 整行 `const MOVE_SPEED:          float = 200.0`；把 `:45` 的
```gdscript
		_velocity = dir * MOVE_SPEED
```
改为
```gdscript
		_velocity = dir * PlayerStats.move_speed
```

- [ ] **Step 3: 施法间隔乘 `cast_delay_mod`**

`player_manager.gd` 的 `equip_wand`（:85），把 `:88` 的
```gdscript
	_cast_interval     = core.cast_interval if core else 0.5
```
改为
```gdscript
	_cast_interval     = (core.cast_interval if core else 0.5) * PlayerStats.cast_delay_mod
```

- [ ] **Step 4: 法杖 `cpu_limit` 作为加成来源接入**

`combat_manager.gd` 的 `_rebuild_wand()`（:257），在既有 `mana_leech` 重算段（:267-271）之后追加：
```gdscript
	# 法杖运算力以加成来源接入（非调用点相加），使 hard 上限作用于生效总值
	PlayerStats.remove_modifiers_from("core")
	PlayerStats.add_modifier("cpu_limit", "flat", float(_equipped_core.cpu_limit) if _equipped_core else 0.0, "core")
```
（顺序要紧：先 `remove` 再 `add`，否则换杖会累积旧法杖份额。）

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

Run: godot-mcp-pro `validate_script` on `res://scripts/domain/spell_system/spell_evaluator.gd`、`res://scripts/domain/player_manager.gd`、`res://scripts/domain/combat/combat_manager.gd` — 均 `valid: true`。

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

```bash
git add scripts/domain/spell_system/spell_evaluator.gd scripts/domain/player_manager.gd scripts/domain/combat/combat_manager.gd
git commit -F- <<'EOF'
feat(attr): 接线 MAX_OPS/移速/施法间隔；法杖 cpu_limit 以加成来源接入

MAX_OPS 由硬编码 40×5 改为读生效 cpu_limit——cores.json 里逐法杖配置的
3/5/6/8 自此生效，法杖间恢复运算力区分度。玩家基准取 0，故小木法杖仍为
5 → 200 步，现有平衡零改动。

法杖份额走 add_modifier 而非调用点相加：否则 hard(50) 只钳制玩家那一份，
法杖份额加在钳制之后可使总值越界。换杖时先 remove_modifiers_from("core")
再 add，防止累积。

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

---

### Task 4: 设计器「属性」页

**Files:**
- Create: `addons/game_designer/attribute_tab.gd`（+ `.gd.uid`）
- Modify: `addons/game_designer/designer_panel.gd`（`_init()` 里 `_add_tab` 注册）

> 按 CLAUDE.md「新增分页 3 步」：`@tool extends VBoxContainer` 标签脚本（复用 `designer_ui.gd`）→ `designer_panel.gd` 的 `_init()` 里 `_add_tab(...)` → 对应加载。控件一律经 `UI.spin/opt/btn/header/status_label/load_json/save_json` 创建，**不要**自建裸控件。标签「中文 English」双语序。`combine` 下拉**存 key 不存显示串**。
>
> **不要做热重载**（Task 2 评审前瞻提醒）：本页只编辑并保存 JSON，**不**尝试让运行中的 `PlayerStats` 立即生效 —— 与其它设计器页一致（保存后提示「重启 F5 生效」）。若日后真要加热重载，注意 `remove_modifiers_from` **不是**强制重新同步的原语（无命中时提前返回），必须自己调 `_load_attr_definitions()` + `_recompute_attrs()`。
>
> **`inverse` 的 `hard` 语义与其它两种相反**（Task 1 实现者发现）：`hybrid`/`add_int` 下 `hard=0` 表示「不钳制」，而 `inverse` 下 `hard` 是**下限**、`0` 表示「钳到 0」。设计器里编辑 `hard` 时应在提示文案中体现这个差异，否则设计师给 `inverse` 属性填 0 期待「不限制」会得到相反结果。

- [ ] **Step 1: 实现 `attribute_tab.gd`**

签名已核对自 `designer_ui.gd`：`UI.spin(min, max, step, val) -> SpinBox`、`UI.opt(items: Array, selected: int) -> OptionButton`、`UI.header(text)`、`UI.cell_label(text)`、`UI.btn(text, cb)`、`UI.status_label()`、`UI.set_status(lbl, msg, is_err)`、`UI.load_json(path)`、`UI.save_json(path, data) -> bool`。结构照 `balance_tab.gd`。

Create `addons/game_designer/attribute_tab.gd`：
```gdscript
## 属性编辑器标签 — 玩家属性的 base / soft / hard / combine
## 数据：res://data/attributes.json（权威属性定义见 docs/design/numerical_design.md §1.1）
@tool
extends VBoxContainer

const UI = preload("res://addons/game_designer/designer_ui.gd")
const PATH = "res://data/attributes.json"
# 显示与存储解耦：LABELS 仅供下拉显示，KEYS 才写进 JSON，二者 index 对齐
const COMBINE_KEYS   = ["hybrid", "inverse", "add_int"]
const COMBINE_LABELS = ["混合 hybrid", "反向 inverse", "整数加 add_int"]
const ATTR_ORDER = ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]

var _data: Dictionary = {}
var _rows: Dictionary = {}   # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton}
var _status: Label

func _ready() -> void:
	add_child(UI.header("📊 属性编辑器 — 玩家属性 base / soft / hard / combine"))
	_load()
	var grid := GridContainer.new(); grid.columns = 5; add_child(grid)
	for h in ["属性", "基准 base", "软上限 soft", "硬上限 hard", "合成 combine"]:
		var l := Label.new(); l.text = h; l.modulate = Color(0.7, 0.8, 1.0)
		grid.add_child(l)
	for attr_id in ATTR_ORDER:
		var d: Dictionary = _data.get(attr_id, {})
		grid.add_child(UI.cell_label(String(d.get("display_name", attr_id))))
		var sp_base := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("base", 0.0)))
		var sp_soft := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("soft", 0.0)))
		var sp_hard := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("hard", 0.0)))
		var idx: int = COMBINE_KEYS.find(String(d.get("combine", "hybrid")))
		var op := UI.opt(COMBINE_LABELS, idx if idx >= 0 else 0)
		grid.add_child(sp_base); grid.add_child(sp_soft); grid.add_child(sp_hard); grid.add_child(op)
		_rows[attr_id] = {"base": sp_base, "soft": sp_soft, "hard": sp_hard, "combine": op}
	add_child(HSeparator.new())
	var tip := Label.new()
	tip.text = "hard=0 在 hybrid 下表示不钳制；inverse 的 hard 是**下限**；add_int 拒绝 pct 加成。"
	tip.modulate = Color(0.7, 0.7, 0.7)
	add_child(tip)
	add_child(UI.btn("💾 保存 attributes.json", _save))
	_status = UI.status_label(); add_child(_status)

func _load() -> void:
	var d = UI.load_json(PATH)
	_data = d if d is Dictionary else {}

## 表单 → 字典（合并式：以 _data 为基底保留未知键）
func _collect() -> Dictionary:
	var out: Dictionary = _data.duplicate(true)
	for attr_id in ATTR_ORDER:
		var r: Dictionary = _rows.get(attr_id, {})
		if r.is_empty():
			continue
		var entry: Dictionary = out.get(attr_id, {}).duplicate(true)
		entry["base"]    = float(r["base"].value)
		entry["soft"]    = float(r["soft"].value)
		entry["hard"]    = float(r["hard"].value)
		entry["combine"] = COMBINE_KEYS[r["combine"].selected]   # 存 key，不存双语显示串
		out[attr_id] = entry
	return out

func _save() -> void:
	if UI.save_json(PATH, _collect()):
		UI.set_status(_status, "💾 已保存（重启 F5 生效）")
	else:
		UI.set_status(_status, "✗ 保存失败", true)
```

> **为什么 UI 建在 `_ready()` 而非 `_init()`**：`@tool` 面板的控件在节点未入树时操作会出错（项目已踩过：`text_edit.cpp` 索引越界）。七个既有分页都为此从 `_init` 改到了 `_ready`，勿重蹈。
> **为什么 `_collect()` 以 `_data.duplicate(true)` 为基底**：防丢键。`balance_tab._save` 曾因新建空字典而丢掉 `mana_heat`/`infinite_spells`，已修，勿重犯。

- [ ] **Step 2: 在 `designer_panel.gd` 注册分页**

在 `_init()` 的 `_add_tab` 序列末尾（现 `:19` 的「⚖ 平衡」之后）追加：
```gdscript
	_add_tab(tabs, "📊 属性", preload("res://addons/game_designer/attribute_tab.gd").new())
```

- [ ] **Step 3: 校验 + 往返实测**

Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/attribute_tab.gd` 与 `res://addons/game_designer/designer_panel.gd` — 均 `valid: true`（两者均无 `class_name`，失败即真错误）。

Run: godot-mcp-pro `execute_editor_script`（用绕缓存法真实实例化并驱动真实控件，**不要**只 grep 源码文本 —— 那不是往返）：
```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()   # 节点不入树也能建出真实 SpinBox/OptionButton
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 abs(float(orig[k][f]) - float(out[k][f])) > 0.0001:
			fails += 1; bad.append("%s.%s %s→%s" % [k, f, str(orig[k][f]), str(out[k][f])])
	if String(orig[k]["combine"]) != String(out[k]["combine"]):
		fails += 1; bad.append("%s.combine %s→%s" % [k, orig[k]["combine"], out[k]["combine"]])
# combine 必须存 key 而非双语显示串
if String(out["cpu_limit"]["combine"]) != "add_int": fails += 1; bad.append("combine 存了显示串")
# display_name 等未列入表单的键不得丢失
if not out["cpu_limit"].has("display_name"): fails += 1; bad.append("丢键 display_name")
_mcp_print("ROUNDTRIP_FAILS=%d %s" % [fails, str(bad)])
```
Expected: `ROUNDTRIP_FAILS=0 []`
> 注意 `0.01` 的 SpinBox 步长：本期四个属性的 base/soft/hard 全部落在 0.01 格点上（0/20/50、200/600/800、1.0/0.1/0.01、100/2000/0），故往返无量化损失。若日后加入非格点值，需先给 `UI.spin` 调步长。

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

```bash
git add addons/game_designer/attribute_tab.gd addons/game_designer/attribute_tab.gd.uid addons/game_designer/designer_panel.gd
git commit -F- <<'EOF'
feat(attr): 游戏设计器新增「属性」页，字段化编辑 attributes.json

按 CLAUDE.md 编辑器插件规范：控件经 designer_ui 工厂创建、标签中英双语中文在前、
combine 下拉存 key 不存显示串、_save 合并式写回防丢键、UI 在 _ready 而非 _init 构建。

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

---

### Task 5: 运行时验收 + 收尾

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

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

Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。`get_editor_errors` 应无本项目脚本相关报错。

- [ ] **Step 2: `cpu_limit` 生效 + 现有平衡零改动（验收 7、8）**

> `combat_manager` 的换杖入口是 `_CORE_ROSTER` 轮换（商店「法杖」按钮）。本步直接调 `PlayerStats` 与 `SpellEvaluator` 的公开状态验证，避免依赖 UI。

Run: godot-mcp-pro `execute_game_script`：
```gdscript
var fails := 0
var log := []
# 直接模拟 _rebuild_wand 对 "core" 来源的处理，逐个法杖验证生效 cpu_limit 与 MAX_OPS
for pair in [["wand_basic", 5], ["wand_fast", 3], ["matrix_board", 8]]:
	var core = WandPreset.make_core_by_id(pair[0])
	PlayerStats.remove_modifiers_from("core")
	PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core")
	var eff: int = PlayerStats.cpu_limit
	var max_ops: int = SpellEvaluator.MAX_OPS_PER_CPU * eff
	if eff != int(pair[1]):
		fails += 1; log.append("%s eff=%d want=%d" % [pair[0], eff, pair[1]])
	log.append("%s cpu=%d MAX_OPS=%d" % [pair[0], eff, max_ops])
# 换杖不累积：连续换两把后应等于最后一把，而非累加
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core")
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", 8.0, "core")
if PlayerStats.cpu_limit != 8:
	fails += 1; log.append("换杖累积 got=%d want=8" % PlayerStats.cpu_limit)
_mcp_print("FAILS=%d %s" % [fails, str(log)])
```
Expected: `FAILS=0`，且日志含 `wand_basic cpu=5 MAX_OPS=200`（**验收 8：现有平衡零改动**）、`wand_fast cpu=3 MAX_OPS=120`、`matrix_board cpu=8 MAX_OPS=320`（**验收 7、9**）。

- [ ] **Step 3: `move_speed` 数据驱动（验收 10）**

Run: godot-mcp-pro `execute_game_script`：
```gdscript
var fails := 0
var base_spd: float = PlayerStats.move_speed
if abs(base_spd - 200.0) > 0.001: fails += 1          # 基准来自 attributes.json
PlayerStats.add_modifier("move_speed", "pct", 0.5, "test")
if abs(PlayerStats.move_speed - 300.0) > 0.001: fails += 1   # 200 × 1.5
PlayerStats.add_modifier("move_speed", "flat", 100.0, "test")
if abs(PlayerStats.move_speed - 450.0) > 0.001: fails += 1   # (200+100) × 1.5
PlayerStats.remove_modifiers_from("test")
if abs(PlayerStats.move_speed - 200.0) > 0.001: fails += 1   # 撤销后回到基准
_mcp_print("FAILS=%d spd=%.1f" % [fails, PlayerStats.move_speed])
```
Expected: `FAILS=0 spd=200.0`

- [ ] **Step 4: `cast_delay_mod` 生效（验收 11）**

Run: godot-mcp-pro `execute_game_script`：
```gdscript
var pm = get_tree().root.find_child("PlayerManager", true, false)
if pm == null: pm = PlayerManager
var core = WandPreset.make_core_by_id("wand_basic")   # cast_interval 0.5
PlayerStats.remove_modifiers_from("test")
PlayerStats.add_modifier("cast_delay_mod", "pct", 0.5, "test")   # inverse: ×0.5
pm.equip_wand(core, null)
var got: float = pm._cast_interval
PlayerStats.remove_modifiers_from("test")
_mcp_print("FAILS=%d interval=%.4f want=0.2500" % [(0 if abs(got - 0.25) < 0.001 else 1), got])
```
Expected: `FAILS=0 interval=0.2500`
> 适配注：若 `equip_wand(core, null)` 因 `compiled` 为 null 报错，改传 `SpellEvaluator.compile_wand(core, [])` 的产物；核心断言是 `_cast_interval == core.cast_interval × cast_delay_mod`。

- [ ] **Step 5: 多来源互不干扰（验收 12）**

Run: godot-mcp-pro `execute_game_script`：
```gdscript
PlayerStats.remove_modifiers_from("core")
PlayerStats.remove_modifiers_from("shopC")
PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core")
PlayerStats.add_modifier("cpu_limit", "flat", 3.0, "shopC")
var both: int = PlayerStats.cpu_limit                 # 8
PlayerStats.remove_modifiers_from("core")
var only_shop: int = PlayerStats.cpu_limit            # 3（他源仍在）
PlayerStats.remove_modifiers_from("shopC")
var none: int = PlayerStats.cpu_limit                 # 0
var fails := (0 if both == 8 else 1) + (0 if only_shop == 3 else 1) + (0 if none == 0 else 1)
_mcp_print("FAILS=%d both=%d only_shop=%d none=%d" % [fails, both, only_shop, none])
```
Expected: `FAILS=0 both=8 only_shop=3 none=0`

- [ ] **Step 6: 孤儿字段零残留 + 存档往返（验收 13）**

Run: godot-mcp-pro `stop_scene`，然后 `execute_editor_script`：
```gdscript
var fails := 0
var hits := []
for p in ["res://scripts/autoloads/player_stats.gd", "res://scripts/domain/player_manager.gd",
		  "res://scripts/domain/combat/combat_manager.gd", "res://scripts/domain/spell_system/spell_evaluator.gd"]:
	var s := FileAccess.get_file_as_string(p)
	if s.contains("PlayerStats.armor") or s.contains("PlayerStats.resistance"): fails += 1; hits.append(p)
	if s.contains("var armor") or s.contains("var resistance"): fails += 1; hits.append(p + ":decl")
	if s.contains("MOVE_SPEED"): fails += 1; hits.append(p + ":MOVE_SPEED")
# 存档不再含派生字段
var ps := FileAccess.get_file_as_string("res://scripts/autoloads/player_stats.gd")
if ps.contains('"hp_max": hp_max') or ps.contains('"cpu_limit": cpu_limit'): fails += 1; hits.append("save_derived")
_mcp_print("FAILS=%d %s" % [fails, str(hits)])
```
Expected: `FAILS=0 []`
> 另跑全项目 grep 复核 `armor` / `resistance` 在 `scripts/` 下只剩 `EnemyManager` 的敌人侧用法（敌人 armor 与元素抗性是另一套，**不受本次影响**）。

- [ ] **Step 7: 订正权威文档 + 路线图**

- `docs/design/numerical_design.md` §1.1：`move_speed` 基准 **300 → 200**，并在该行「说明」列注明：`⚠️ 2026-07-31 订正：原写 300 从未被任何代码读取；实际实现自 S0 起为 200，20 波内容与 Boss 弹幕密度均按此值调校，故以既成事实为准。见 docs_dev/specs/2026-07-31-player-attributes-design.md §2.2。`
- 同表 `cpu_limit` 行：删去或更新那条 `⚠️实现现状(2026-07-20)：代码硬编码 MAX_OPS = 40×5 = 200，从不读 cpu_limit（待修，本设计更优）` —— 改为已修复，并注明生效值 = 玩家(基准 0) + 法杖(`cores.json` 3–8)。
- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`：E3 切分列表第 1 项「属性词条系统」勾除并标注**实际范围**（只做框架 + 三条死线，`attunement_×4`/`luck`/`recharge_speed_mod` 另行立项），格式对齐 E1 已完成项。同时订正 E3「现状」里「`player_stats.gd` 有 `resistance` 等占位属性（4/5 stats 未接线）」这句 —— 它与本次查明的事实不符（权威属性表是 11 个，`armor`/`resistance` 根本不在表内）。

```bash
git add docs/design/numerical_design.md docs_dev/plans/2026-07-23-missing-features-roadmap.md
git commit -F- <<'EOF'
docs: 订正 move_speed 权威值 300→200 与 cpu_limit 已修复；路线图 E3-① 勾除

move_speed 300 从未被任何代码读取，200 自 S0 沿用并已围绕它调校 20 波内容，
以既成事实为准。cpu_limit 的「从不读」告警随本次接线解除。
路线图 E3 现状原写「4/5 stats 未接线」与事实不符——权威属性表是 11 个，
armor/resistance 根本不在表内，一并订正。

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

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

实现过程中若因评审而改动了代码或断言，**本计划里对应的代码块与脚本必须回填为实际落地的版本**，并勾上各任务的复选框。否则任何人按这份计划复跑，得到的是过时代码和可能已失效的断言 —— 归航那期就踩过这个坑。

已知必须回填的（Task 1 评审后代码有变）：
- Task 1 Step 2 的实现代码块 → 加上 `pct` 越界守卫、`_COMBINE_NAMES`→`_COMBINE_BY_NAME`、`ri`→`floored`、`Dictionary[String, Combine]` 与 `for m: Dictionary` 的静态类型、以及 `hard` 语义差异的注释。
- Task 1 Step 3 的夹具 → 改用工具坑 ② 的 `ResourceLoader.load(..., CACHE_MODE_IGNORE)`；`cases` 补上 ⑦/⑦b 两条越界断言（共 12 条）；注意工具坑 ⑤（`var r: float =` 而非 `var r :=`）。

其余任务同理：**以实际跑通的为准**，并在每处回填旁注明「原写法为何不可用」。

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

用 `superpowers:finishing-a-development-branch` 决定分支去向。前四个已完成子项（i-frames / 抗性 / 弹跳 / 归航）均合并进 `master` 并删分支。

---

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

| # | 验收标准 | 覆盖 | 观测方式 |
| :- | :-- | :-- | :-- |
| 1 | `hybrid` 连乘 ×1.728 非 ×1.6 | Task 1 Step 3 ① | 纯函数断言 |
| 2 | `hybrid` 混合 (200+50)×1.2=300 | Task 1 Step 3 ② | 纯函数断言 |
| 3 | `inverse` 0.512 + 下限钳制 | Task 1 Step 3 ③③b | 纯函数断言 |
| 4 | `add_int` 拒 `pct`，`flat` 仍累加 | Task 1 Step 3 ④ | 纯函数断言 |
| 5 | `hard` 两方向 + `hard=0` 不钳制 | Task 1 Step 3 ⑤⑤b⑥c | 纯函数断言 |
| 6 | 空列表 → base；未知 `combine` 回退 | Task 1 Step 3 ⑥⑥b | 纯函数断言 |
| 7 | `cores.json` `cpu_limit` 生效 | Task 5 Step 2 | 运行时 |
| 8 | 现有平衡零改动（小木仍 200 步） | Task 5 Step 2 | 运行时 |
| 9 | 换杖不累积（5→8 得 8 非 13） | Task 5 Step 2 | 运行时 |
| 10 | `move_speed` 数据驱动 + 加成生效 | Task 5 Step 3 | 运行时 |
| 11 | `cast_delay_mod` 使间隔减半 | Task 5 Step 4 | 运行时 |
| 12 | `remove_modifiers_from` 不误删他源 | Task 5 Step 5 | 运行时 |
| 13 | 孤儿字段零残留 + 存档不含派生值 | Task 5 Step 6 | 源码断言 |
| 14 | 设计器往返一致 + `combine` 存 key | Task 4 Step 4 | 绕缓存实例化 |
