# 玩家属性系统（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.
>
> **⚠️ 本文件已于 2026-07-31（Task 5 Step 8）回填为「实际执行版」。** 每个任务的代码块与断言脚本都已替换为**真正落地并跑通**的版本，评审改动处附「原写法为何不可用」。按本计划复跑得到的就是仓库里的代码。未回填的计划会在下一次复跑时喂出过时代码 —— 归航那期踩过这个坑。

**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`。
> **已知工具坑**（① – ⑦ 为实现期所得，⑧ – ⑩ 为 Task 5 验收期新增）：
> ① `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 F = 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 里被缓存，同理。**优先用 ②**（`attribute_tab.gd` 无 `class_name`，用 ④ 即可）。
> ⑤ **`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()` 触发扫描才生成；项目约定二者一并提交。
> ⑧ **⚠️⚠️ 运行中游戏的 `push_error` / `push_warning` 既不进 `get_output_log` 也不进 `get_editor_errors`（Task 5 实测）。** 只有运行时 `print()` 会进 output 面板；**编辑器侧**的 `push_error` 两个通道都进。因此「跑一局看日志里没有报错」这类断言**本身是空的** —— 它分不清「守卫没误报」和「守卫报了但看不见」。真正的运行时错误通道是编辑器 Debugger 的「错误」页，读法见 Task 5 Step 1b。**任何声称「零报错」的断言，必须先用一条故意的错误做正对照，证明通道是活的。**
> ⑨ `execute_game_script` **不支持 `await`**（`Trying to call an async function without "await"` 并使该命令崩溃）。需要跨帧观测时，向 `get_tree().root` 挂一个带 `_physics_process` 的临时节点，它在多次 MCP 调用之间存活。
> ⑩ `execute_game_script` 的三引号字符串会被外层包装破坏（报 `Parse error`）。构造子脚本源码请用 `"\n".join([...])` 的行数组。
> **关键既有事实**：`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: 建功能分支

- [x] **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` 执行 —— 与既有验收方式一致。

- [x] **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` 说明文件已存在，先确认不是残留。

- [x] **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_BY_NAME: Dictionary[String, Combine] = {
	"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_BY_NAME.has(s):
		return _COMBINE_BY_NAME[s]
	push_error("AttributeFormula: 未知 combine「%s」，回退 hybrid" % s)
	return Combine.HYBRID

## 唯一的公式入口
## base     —— attributes.json 的基准值
## mods     —— [{"mode": "flat"|"pct", "value": float}, ...]；调用方负责只传本属性的加成
##             契约：仅由 PlayerStats.add_modifier 构造，故假定 mode/value 键必存在且类型正确；
##             此处不做防御性校验（未知 mode 会 push_error，但缺键会被静默当默认值）
## attr_def —— attributes.json 中该属性的定义（读 combine / hard）
## hard 的语义**因 combine 而异**，勿混淆：
##   · hybrid  —— hard 是上限；`hard == 0.0` 约定为「不钳制」（如 hp_max 无硬上限）
##   · add_int —— 同上，hard 是上限，`0.0` 即不钳制
##   · inverse —— hard 是**下限**；此处 `0.0` **不是**「不钳制」而是下限 0，与上二者相反
## 返回统一为 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: Dictionary 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，避免后期线性失控
			var f: float = (1.0 + v) if combine == Combine.HYBRID else (1.0 - v)
			if f < 0.0:
				# 越界的 pct 会使因子变负；两条负因子相乘会变回正数，产生无报错的错数
				push_error("AttributeFormula: pct 越界（value=%f → 因子 %f），已钳到 0" % [v, f])
				f = 0.0
			pct_prod *= f
		else:
			push_error("AttributeFormula: 未知 mode「%s」，已忽略" % mode)
	match combine:
		Combine.ADD_INT:
			var floored: float = floorf(base + flat_sum)
			# 下界 0：生效值不允许为负（负的运算力无意义）
			return clampf(floored, 0.0, hard) if hard > 0.0 else maxf(0.0, floored)
		Combine.INVERSE:
			# 越低越快：hard 是**下限**。注意 hard=0.0 在此处不是「不钳制」而是下限 0，与 hybrid 相反
			return maxf(hard, (base + flat_sum) * pct_prod)
		_:
			# 下界 0：生效值不允许为负（负的移速无意义）
			var r: float = maxf(0.0, (base + flat_sum) * pct_prod)
			return minf(r, hard) if hard > 0.0 else r   # hard=0.0 约定为「不钳制」
```

> **📌 回填说明 —— 原写法为何不可用**（四处，均为 Task 1 评审所改）：
> 1. **`pct` 越界守卫（新增）**：原实现直接 `pct_prod *= (1.0 + v)`。若两条 `pct` 都越界（如各 `-2.0`），因子是 `(-1) × (-1) = +1`，**结果等于完全没加成**，返回一个看起来完全合理的正数且**零诊断**。这类"负负得正"是最难查的一种错。现在单条因子一旦为负就 `push_error` 并钳到 0，越界的后果变成显眼的 0 而非隐形的原值。
> 2. **`_COMBINE_NAMES` → `_COMBINE_BY_NAME` + `Dictionary[String, Combine]`**：原名读作"combine 的名字们"，实际是"按名字查 combine"；加静态类型后写错枚举值在编译期就报，而不是运行时静默塞进一个 int。
> 3. **`ri` → `floored`**：`ri`（round int）既非 round 也非 int，纯噪声名。
> 4. **`hard` 语义差异写进函数头注释**：`inverse` 的 `hard` 是**下限**、`0.0` 不表示"不钳制"，与另两种相反。这条差异原本只存在于实现者脑中，设计师给 `inverse` 属性填 `hard: 0` 期待"不限制"会得到相反结果。
> 另：`for m: Dictionary in mods` 的静态类型标注只有经工具坑 ② 的 `CACHE_MODE_IGNORE` 才真正被引擎编译器验证过（动态副本能解析 ≠ 引擎能编译）。

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

Run: godot-mcp-pro `execute_editor_script`：
```gdscript
# 工具坑 ②：CACHE_MODE_IGNORE 从磁盘重编译，保留 class_name，走引擎真编译器
var F = ResourceLoader.load("res://scripts/domain/attribute_formula.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
if F == null:
	_mcp_print("LOAD_FAIL"); return

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 oob2 := [{"mode":"pct","value":-2.0},{"mode":"pct","value":-2.0}]

# ⚠️ 工具坑 ⑤：F 是 Variant，必须写 `var r: float = F.compute(...)`，
#    写成 `var r := F.compute(...)` 是**编译错误且不报行号**。
var r1:  float = F.compute(1.0,   m3, HYB)
var r2:  float = F.compute(200.0, [{"mode":"flat","value":50.0},{"mode":"pct","value":0.2}], HYB)
var r3:  float = F.compute(1.0,   m3, INV)
var r3b: float = F.compute(1.0,   many, INV)
var r4:  float = F.compute(0.0,   [{"mode":"flat","value":5.0},{"mode":"pct","value":0.5},{"mode":"flat","value":8.0}], INT)
var r5:  float = F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB_CAP)
var r5b: float = F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB)
var r6:  float = F.compute(123.0, [], HYB)
var r6b: float = F.compute(1.0,   m3, {"combine": "bogus", "hard": 0.0})
var r6c: float = F.compute(0.0,   [{"mode":"flat","value":999.0}], INT)
var r7:  float = F.compute(200.0, oob2, HYB)
var r7b: float = F.compute(1.0,   [{"mode":"pct","value":2.0}], INV)

var cases := [
	["①连乘",         r1,  1.728],
	["②混合",         r2,  300.0],
	["③inverse",      r3,  0.512],
	["③b下限",        r3b, 0.01],
	["④拒pct",        r4,  13.0],
	["⑤上限",         r5,  800.0],
	["⑤b不钳制",      r5b, 5200.0],
	["⑥空",           r6,  123.0],
	["⑥b未知",        r6b, 1.728],
	["⑥c整数上限",    r6c, 50.0],
	["⑦越界不回正",   r7,  0.0],      # 两条 -200%：若无守卫会得 200（负负得正）
	["⑦b越界inverse", r7b, 0.01],     # 单条 +200% → 因子 -1 → 钳 0 → 落到 hard 下限
]
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/%d %s" % [fails, cases.size(), str(bad)])
```
Expected（实测 2026-07-31）：`FAILS=0/12 []`

> **📌 回填说明 —— 原写法为何不可用**：
> - 原夹具用 `GDScript.new()` + `source_code` + `reload()`。`AttributeFormula` 被注册为全局类之后，这个内存副本会因 `class_name` 冲突报 `hides a global script class` 而 `reload()` 失败（首次运行侥幸通过只是因为当时还没注册）。`CACHE_MODE_IGNORE` 不需要剥 `class_name`，且走引擎真编译器。
> - 原 `cases` 只有 10 条，把 `F.compute(...)` 直接写在数组字面量里。补第 11/12 条越界断言时若图省事写 `var r7 := F.compute(...)`，会撞上工具坑 ⑤ 的无行号编译错误 —— 故统一改成显式 `var rN: float =` 抽取。
>
> ⚠️ **断言 ④ 的数字部分对「拒绝 `pct`」是空的**（Task 1 评审实测发现）：把整个拒绝块删掉，④ 仍返回 13.0 —— 因为 `ADD_INT` 的 match 分支根本不读 `pct_prod`。数字只证明了「`pct` 之后的 `flat` 仍能累加」。**拒绝行为唯一的可观测证据是 `push_error`**，必须单独断言（Step 3b），否则意图②无人守卫。

- [x] **Step 3b: 断言 `push_error` 真的发出（补 ④ 的空缺 + 新增的越界守卫）**

用**唯一标记值**发起调用，再从编辑器错误日志里找它 —— 标记值确保读到的不是历史遗留日志（工具坑：`clear_output` **不会**清空 `get_editor_errors` 的缓冲区，旧行会一直在）。

Run: godot-mcp-pro `clear_output`，然后 `execute_editor_script`：
```gdscript
var F = ResourceLoader.load("res://scripts/domain/attribute_formula.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
if F == null:
	_mcp_print("LOAD_FAIL"); return
# 四条各带唯一标记，逐一触发报错路径
F.compute(0.0, [{"mode":"pct","value":0.123456}], {"combine":"add_int","hard":50.0})
F.compute(1.0, [], {"combine":"zzzprobe9182","hard":0.0})
F.compute(1.0, [{"mode":"zzzmode9182","value":1.0}], {"combine":"hybrid","hard":0.0})
F.compute(1.0, [{"mode":"pct","value":-9.182}], {"combine":"hybrid","hard":0.0})
_mcp_print("probes_fired")
```
再 Run: godot-mcp-pro `get_editor_errors`，断言四条标记均出现（实测 2026-07-31 全部命中）：
- `add_int 属性不接受 pct 加成（value=0.123456`
- `未知 combine「zzzprobe9182」`
- `未知 mode「zzzmode9182」`
- `pct 越界（value=-9.182000 → 因子 -8.182000）`

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

> **📌 回填说明**：原脚本用 `GDScript.new()` + `.replace("class_name AttributeFormula\n", "")` 剥掉 `class_name` 才能编译。`CACHE_MODE_IGNORE` 不需要改源码，少一处「测的东西和跑的东西不是同一份」的风险。第 4 条探针（`pct` 越界）随 Step 2 的新守卫一并补入。

- [x] **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`

- [x] **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"  }
}
```

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

`player_stats.gd`：将 `var hp_max: float = 100.0`（:10）保留（生效值字段），并在「战斗属性」区（:21-23）**整段替换** —— 删 `cpu_limit` 旧声明、删 `armor`、删 `resistance`，改为（**以下为实际落地版本**）：
```gdscript
# ── 数据源 ────────────────────────────────────────────
const ATTRIBUTES_JSON: String = "res://data/attributes.json"

# ...（HP / 资源 / 等级区不变）...

# ── 属性（生效值：静态类型裸字段，读取端零开销）─────────────
# 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出，勿直接赋值
var cpu_limit:      int   = 0     # 生效运算力；MAX_OPS = 本值 × 40
                                  # 法杖份额待 combat_manager 以 source="core" 注入（Task 3 接线后）
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)
```

`reset_for_run()` 改为（**顺序要紧**）：
```gdscript
func reset_for_run() -> void:
	_modifiers.clear()          # 局内加成不跨局；"core" 随后由 _rebuild_wand 重建
	_recompute_attrs()          # 必须早于 hp = hp_max，否则 hp 会用陈旧的 hp_max 播种
	hp        = hp_max
	mana      = mana_max
	gold      = 0
	xp        = 0
	level     = 1
	xp_to_next = xp_for_level(1)
	_invuln_until_msec = 0
	stats_changed.emit()
```

在文件末尾新增：
```gdscript
# ── 属性框架 ──────────────────────────────────────────────

func _load_attr_definitions() -> void:
	if not FileAccess.file_exists(ATTRIBUTES_JSON):
		push_error("PlayerStats: 缺少 %s" % ATTRIBUTES_JSON)
		return
	var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
	if not (parsed is Dictionary):
		push_error("PlayerStats: %s 格式错误（应为对象）" % ATTRIBUTES_JSON)
		return
	# 逐条守卫：畸形条目（如 "move_speed": 200）会让 _compute_attr 的 Dictionary 赋值硬崩
	for k in parsed:
		if not (parsed[k] is Dictionary):
			push_error("PlayerStats: attributes.json 的「%s」应为对象，整个文件已拒绝加载" % k)
			return
	_attr_def = parsed

## 追加一条加成来源；同一 source 可对多个属性各加一条
## 本函数是 AttributeFormula.compute 的守门人——公式模块明文假定 mode/value 合法且不再校验，
## 故非法 attr_id / mode 必须在此拦下：否则错条目会永远堆在 _modifiers 里且不产生任何诊断
func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void:
	if not _attr_def.has(attr_id):
		push_error("PlayerStats: 未知属性「%s」（来源 %s），加成已忽略" % [attr_id, source])
		return
	if mode != "flat" and mode != "pct":
		push_error("PlayerStats: 未知 mode「%s」（%s→%s），加成已忽略" % [mode, source, attr_id])
		return
	_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)
	if kept.size() == _modifiers.size():
		return   # 无命中：不 emit stats_changed，避免无谓的 HUD / 商店刷新
	_modifiers = kept
	_recompute_attrs()

func _mods_for(attr_id: String) -> Array[Dictionary]:
	var out: Array[Dictionary] = []
	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():
		# 定义未加载，根因已由 _load_attr_definitions push_error。此处不重复报错：本函数在
		# 换杖/换牌/购买时被频繁调用，无条件报错会刷屏并埋掉那条根因。assert 在 release 被
		# 编译掉，开发期则由 _ready 的首次调用立即中断。
		assert(false, "PlayerStats: 属性定义未加载，add_modifier / 重算全部失效")
		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")
	hp             = minf(hp, hp_max)  # hp_max 下调（出售退款/换杖）时避免 hp > 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)
```

> **📌 回填说明 —— 原写法为何不可用**（六处，均为 Task 2 评审所改）：
> 1. **`add_modifier` 的两条校验（新增）**：原实现无条件 `_modifiers.append(...)`。`AttributeFormula.compute` 的函数头**明文写着**"假定 mode/value 合法且不再校验"，所以 `PlayerStats` 是**唯一**的守门人。少了它，一句 `add_modifier("cpu_limt", ...)`（打字错）会永远堆在 `_modifiers` 里、永不生效、**零诊断**，而症状（法术预算退化）离根因极远。
> 2. **`_recompute_attrs` 的 `hp = minf(hp, hp_max)`（新增）**：`hp_max` 是派生值，出售退款/换杖会让它**下调**。没有这句，HP 会停在 `150/100` 这种状态，且 `heal()` 因 `min(hp_max, ...)` 永远无效 —— 一个悄无声息的坏状态。
> 3. **`reset_for_run` 清 `_modifiers` 并在 `hp = hp_max` **之前**重算（新增）**：原版两者都没有。不清会让上一局的加成跨局泄漏；不重算就赋 `hp = hp_max` 会用**上一局残留的** `hp_max` 播种本局血量。
> 4. **空定义分支用 `assert(false, ...)` 而非裸 `return`**：裸 `return` 是完全静默的。`push_error` 又不行 —— 本函数在换杖/换牌/购买时被频繁调用，会刷屏并把 `_load_attr_definitions` 那条真正的根因报错挤出日志。`assert` 在开发期由 `_ready()` 的首次调用立即中断，在 release 被编译掉，正好。
> 5. **`_load_attr_definitions` 的逐条 `is Dictionary` 守卫（新增）**：`"move_speed": 200`（漏了对象包装）这种畸形 JSON 会让 `_compute_attr` 里的 `var d: Dictionary = ...` **硬崩**，而不是报错。整文件拒绝加载 + 明确报错。
> 6. **`_ATTR_PATH` → `ATTRIBUTES_JSON` 并上移到文件头的「数据源」区**：它是数据源路径，与其它 autoload 的同类常量命名一致；下划线前缀暗示"私有实现细节"，但设计器/测试都需要引用它。
> 另：`_mods_for` 的返回类型由 `Array` 收紧为 `Array[Dictionary]`（与 `_modifiers` 同型）。

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

两者现在由 `attributes.json` + `_modifiers` 算出，存档回读会覆盖公式结果。`get_save_data()`（:135）与 `load_save_data()`（:138）改为（**含实际落地的注释块**）：
```gdscript
## hp_max / cpu_limit 不入存档：二者现为 attributes.json + _modifiers 的派生值，
## 回读会覆盖公式结果。旧存档里的同名键忽略即可，无需提升 schema 版本。
##
## 留给货架 C：一旦玩家可购买属性，需要持久化的是 _modifiers（来源列表）而非派生的
## 生效值，届时本函数应加 "attr_modifiers": _modifiers 并提升 schema 版本。
## · source == "core" 的那条**不要**持久化——它在换杖时由 combat_manager._rebuild_wand 重建。
## · 回读须用 _modifiers.assign(...)：JSON.parse_string / data.get(..., []) 产出的是无类型
##   Array，直接 `=` 赋给 Array[Dictionary] 会运行时类型错误。
## · 回读顺序：_modifiers 必须先 assign + _recompute_attrs()，再赋 hp。反过来会让
##   _recompute_attrs 的 hp = minf(hp, hp_max) 拿未加成的 hp_max 去钳，静默吞血。
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 的提示」一句话。评审后补了**两条会真咬人的顺序/类型陷阱**（`assign` vs `=`、`_modifiers` 必须先于 `hp` 回读）—— 前者是运行时类型错误，后者是**静默吞血**：先赋 `hp` 再重算，`hp = minf(hp, hp_max)` 会拿"还没加成的 hp_max"去钳一个"已经加成过的 hp"。

- [x] **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`

- [x] **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,81,88`
- Modify: `scripts/domain/combat/combat_manager.gd:267-271`
- Modify: `scenes/main/combat_test.gd`（原计划漏了这个消费方）

- [x] **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._rebuild_wand 以 source="core" 的加成接入）
	# 下限 1：注入缺失时不把游戏打成砖（0 会让 while 一步都不走）；该异常由 equip_wand 处报出
	var max_ops:           int = MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)
```

> **📌 回填说明 —— 原写法为何不可用**：原计划在**这里**放 `if PlayerStats.cpu_limit <= 0: push_error(...)`。评审改为搬到 `PlayerManager.equip_wand`（见 Step 3）。理由两条：① 本函数**每秒被调用约 2 次**（施法频率），守卫一旦触发就是每秒两条报错刷屏，反而把根因埋掉；`equip_wand` 每次换杖才跑一次。② 这里拿不到杖名，报错说不出"是哪把杖配成 0 的"。**守卫要放在故障"变得可观测"且"报一次就够"的地方，不是放在故障"造成后果"的地方。**

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

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

- [x] **Step 3: 施法间隔乘 `cast_delay_mod`（在开火时，不在装备时）**

`player_manager.gd`：
- `:17` 字段注释改为 `var _cast_interval: float = 0.5   # 法杖基准间隔（core.cast_interval）；实际间隔在开火时 × cast_delay_mod`
- `_handle_auto_cast`（`:81`）的 `_cast_timer` 重置改为：
```gdscript
	_cast_timer -= delta
	if _cast_timer <= 0.0:
		_cast_timer = _cast_interval * PlayerStats.cast_delay_mod
		SpellEvaluator.execute_compiled(_equipped_compiled, 0, _position)
```
- `equip_wand`（`:87`）保持只装法杖基准，并在末尾加 0 守卫：
```gdscript
	_cast_interval     = core.cast_interval if core else 0.5
	...
	# 0 守卫：cpu_limit=0 → MAX_OPS 退化，法术近乎全静默。放在换杖处而非施法处——施法端 2 次/秒
	# 会刷屏埋掉根因，且此处拿得到杖名。兜的是「新调用点忘了注入」与「cores.json 某杖配成 0」，
	# 二者上游无任何报错（缺 attributes.json / attr_id 打错已分别由 PlayerStats._load_attr_definitions
	# 与 PlayerStats.add_modifier 报出）
	if PlayerStats.cpu_limit <= 0:
		push_error("PlayerManager: 装备「%s」后 cpu_limit=0，法术执行预算将退化为 40 步；检查该杖 cores.json 的 cpu_limit 与 source=\"core\" 加成注入" % (core.display_name if core else "—"))
```

> **📌 回填说明 —— 原写法为何不可用**：原计划写 `_cast_interval = (core.cast_interval if core else 0.5) * PlayerStats.cast_delay_mod`，即**在装备时把 mod 折进 `_cast_interval`**。这会把 mod **冻结在换杖那一刻**：局中任何一次 `add_modifier("cast_delay_mod", ...)`（**这正是货架 C 要做的事**）在下次换杖之前完全不生效。改到开火时读，mod 立刻生效，且 `_cast_interval` 恢复"就是法杖基准"这个干净语义。
> 代价是 Task 5 原验收脚本（读 `pm._cast_interval` 期待它减半）随之失效 —— 见 Task 5 Step 4 的适配。

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

`combat_manager.gd` 的 `_rebuild_wand()`（:257），在既有 `mana_leech` 重算段（:267-271）之后追加：
```gdscript
	# 法杖运算力以加成来源接入（非调用点相加），使 hard 上限作用于生效总值
	# remove 必须在 add 之前：本函数每次 install_spell 都会跑，漏了 remove 会让 cpu_limit
	# 5→10→15 地累积，而 hard: 50 会把它封成一个看起来合理的数字，不是显眼的异常值
	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`」。补充的是**为什么这个 bug 特别难发现**：`_rebuild_wand` 每次装法术都跑，累积会很快撞上 `hard: 50` 被封顶 —— 封顶后它就成了一个"看起来挺合理"的常数，而不是一路涨到显眼的荒谬值。

- [x] **Step 4b: 补 `combat_test.gd` 的注入（原计划遗漏）**

`cpu_limit` 玩家基准改为 0 后，**任何绕过 `CombatManager._rebuild_wand` 的场景都会退化**。`scenes/main/combat_test.gd` 正是这样的调试场景：
```gdscript
	# 本场景绕过 CombatManager，故须自行注入法杖运算力份额（否则 cpu_limit=0 → MAX_OPS 退化）
	# 顺序理由见 combat_manager._rebuild_wand：remove 必须先于 add（否则重复进入会累积），
	# 而两者又必须都先于 equip_wand（equip_wand 里的 cpu_limit<=0 守卫读的就是注入结果）
	PlayerStats.remove_modifiers_from("core")
	PlayerStats.add_modifier("cpu_limit", "flat", float(loadout["core"].cpu_limit), "core")
	PlayerManager.equip_wand(loadout["core"], loadout["compiled"])
```

> **📌 回填说明 —— 原计划为何漏了这处**：spec §2.5 的接线表只列了三处（evaluator / combat_manager / player_manager），是按"谁读属性"梳理的；但基准值从 5 改成 0 这个决定，把**"谁负责注入"**变成了一条新的全局不变式，而它的破坏者是**不读属性的**调用方。**改基准值时要问的不是"谁读它"，而是"谁绕过了它的注入点"。**

- [x] **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`、`res://scenes/main/combat_test.gd` — 均 `valid: true`。

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

```bash
git add scripts/domain/spell_system/spell_evaluator.gd scripts/domain/player_manager.gd scripts/domain/combat/combat_manager.gd scenes/main/combat_test.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
```
（实际分了三个提交：`a3bb778` 主接线、`6f11f4c` 补 combat_test + 记时序、`729c749` 守卫移位 + cast_delay_mod 改开火时生效。）

---

### 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 期待「不限制」会得到相反结果。

- [x] **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
var _loaded: bool = false   # 加载失败时禁止写回，见 _save

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:
		# _load 已保证：_loaded 时每个条目都是 Dictionary，否则 _data 为空
		var d: Dictionary = _data.get(attr_id, {})
		grid.add_child(UI.cell_label(String(d.get("display_name", attr_id))))
		# 四个属性按定义均非负，故 min = 0；且 min 取 0 可避开 Range 的抵消误差（见 _clean）
		var sp_base := UI.spin(0.0, 99999.0, 0.01, float(d.get("base", 0.0)))
		var sp_soft := UI.spin(0.0, 99999.0, 0.01, float(d.get("soft", 0.0)))
		var sp_hard := UI.spin(0.0, 99999.0, 0.01, float(d.get("hard", 0.0)))
		var combine_key := String(d.get("combine", "hybrid"))
		var idx: int = COMBINE_KEYS.find(combine_key)
		# 本页凡是「静默改写权威数据」的路径都必须留下诊断，一条都不能漏——
		# 保存会把改写结果写回 attributes.json，而下游 PlayerStats 只会照单全收
		if d.is_empty():
			# 条目缺失时占位的 0 一旦被保存，键就「存在」了，
			# player_stats.gd 的「缺少属性」报错从此不再触发，hp_max=0 静默进游戏
			push_warning("attribute_tab: %s 缺少属性「%s」，本页以 0 值占位，保存会把 0 写进文件" % [PATH, attr_id])
		else:
			if idx < 0:
				# 不硬拒（打字错误不该让整页打不开），但必须留下诊断：保存会把它改写成 hybrid
				push_warning("attribute_tab: 「%s」的 combine「%s」无法识别，下拉已回落到 hybrid，保存将覆盖原值" % [attr_id, combine_key])
			for f in ["base", "soft", "hard"]:
				if d.has(f) and float(d[f]) < 0.0:
					push_warning("attribute_tab: 「%s」的 %s = %s 为负，输入框下限 0 已将其钳制，保存会把 0 写进文件" % [attr_id, f, str(d[f])])
		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 / add_int 下表示不钳制；inverse 的 hard 是「下限」，填 0 即钳到 0（与 hybrid 相反）；add_int 拒绝 pct 加成；soft 本期不参与计算（留给货架 C）。"
	# dock 的 ScrollContainer 禁用横向滚动，不换行的话这句会被右侧裁掉（正是最需要看到的半句）
	tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	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)

## 加载失败必须留痕并禁止写回：本页的零值载荷（move_speed.base = 0 等）是合法 JSON，
## PlayerStats 会照单全收，游戏表现为玩家不能移动 / 瞬间死亡且毫无诊断。
func _load() -> void:
	var d = UI.load_json(PATH)
	_loaded = d is Dictionary
	if not _loaded:
		push_error("attribute_tab: 无法加载 %s（缺失或格式错误）" % PATH)
		_data = {}
		return
	# 逐条守卫，同 player_stats.gd 的既有做法：畸形条目（如 "hp_max": 5）会让下面的
	# Dictionary 赋值硬崩、dock 停在半构建状态；整个文件拒绝加载并禁止写回
	for k in d:
		if not (d[k] is Dictionary):
			push_error("attribute_tab: %s 的「%s」应为对象，整个文件已拒绝加载" % [PATH, k])
			_loaded = false
			_data = {}
			return
	_data = d

## Range 用 round((v - min) / step) * step + min 吸附取值。min = -99999 与 0.01 的 step
## 相差七个数量级，此式在此发生抵消误差（0.01 → 0.00999999999476），直接写回会把噪声
## 固化进 JSON。本页 min = 0.0 时 12 个值原本就逐位精确，本函数当前不改变落盘结果
##（唯一副作用：0.1 在内存里低 1 ULP，stringify 仍输出 0.1）。
## 勿因此删除 —— 一旦某属性需要负值 / 宽区间而放宽 min，抵消误差立即回来。
static func _clean(v: float) -> float:
	return snappedf(v, 0.000001)

## 表单 → 字典（合并式：以 _data 为基底保留未知属性条目，以及条目内未列入表单的键；
## 顶层标量键不在此列——_load 已因它们整体拒绝加载）
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"]    = _clean(r["base"].value)
		entry["soft"]    = _clean(r["soft"].value)
		entry["hard"]    = _clean(r["hard"].value)
		# 存 key，不存双语显示串；selected 恒为 0..2（UI.opt 构造时已 clampi，本页此后不再赋值）
		entry["combine"] = COMBINE_KEYS[r["combine"].selected]
		out[attr_id] = entry
	return out

func _save() -> void:
	if not _loaded:
		UI.set_status(_status, "✗ 定义未加载，拒绝写回", true)
		return
	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`，已修，勿重犯。
>
> **📌 回填说明 —— 原写法为何不可用**（五处，均为 Task 4 两轮评审所改）：
> 1. **`UI.spin` 的 `min` 由 `-99999.0` 收紧为 `0.0`（+ `_clean`）—— 这条最重要，会真的改坏权威数据**：`Range` 用 `round((v - min) / step) * step + min` 吸附取值；`min = -99999` 与 `step = 0.01` 相差七个数量级，此式发生抵消误差。**实测**：`0.1 → 0.100000000005821`、`0.01 → 0.00999999999476131`，且这两个值**会被 `JSON.stringify` 原样写进文件**。任何人打开一次属性页再按保存，`attributes.json` 就被污染。
> 2. **`_loaded` 守卫 + `_save` 拒绝写回（新增）**：加载失败时表单是一片 0，而**一片 0 是合法 JSON**。保存下去 `move_speed.base = 0`（玩家不能动）、`hp_max = 0`（进场即死），且 `PlayerStats` 照单全收、零诊断。
> 3. **`_load` 的逐条 `is Dictionary` 守卫（新增）**：畸形条目会让 `var d: Dictionary = _data.get(...)` 硬崩，dock 停在半构建状态。
> 4. **提示语加 `autowrap_mode`（新增）**：dock 的 `ScrollContainer` 禁用横向滚动，不换行时这句会被右侧裁掉 —— 被裁掉的**正是 `inverse` 的 hard 是下限**那半句，即最需要被看到的内容。
> 5. **两条 `push_warning`（新增）**：本页有两条「静默改写权威数据」的路径 —— 条目缺失时以 0 占位（保存后这个键就"存在"了，`player_stats.gd` 的"缺少属性"报错**从此永不触发**），以及 `combine` 无法识别时回落 `hybrid`（保存即覆盖原值）。凡静默改写必须留诊断。

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

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

- [x] **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"]:
		# ⚠️ 逐位精确，不用容差。往返要防的量化漂移量级是 ~5e-12，
		#    任何 1e-4 量级的容差都看不见它——见下方「原写法为何不可用」。
		if JSON.stringify(float(orig[k][f])) != JSON.stringify(float(out[k][f])):
			fails += 1
			bad.append("%s.%s %s→%s" % [k, f, JSON.stringify(orig[k][f]), JSON.stringify(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")
# 终局判据：落盘文本逐字节一致（这才是真正会被写进文件的东西）
if JSON.stringify(out, "  ", true) != JSON.stringify(orig, "  ", true):
	fails += 1; bad.append("序列化文本不一致")
tab.free()
_mcp_print("ROUNDTRIP_FAILS=%d %s" % [fails, str(bad)])
```
Expected（实测 2026-07-31）：`ROUNDTRIP_FAILS=0 []`

> **📌 回填说明 —— 原计划这里写了一句假话，且断言够不着它**
>
> 原注释：「本期四个属性的 base/soft/hard **全部落在 0.01 格点上**，故往返无量化损失。」**这句是错的**，而配套的 `abs(a - b) > 0.0001` 容差断言**在原理上就不可能发现它**。
>
> Task 5 实测（构造 `SpinBox` 直接量）：
> | 值 | `min = -99999`（评审前） | `min = 0.0`（已上线） |
> | :-- | :-- | :-- |
> | `0.1` | `0.100000000005821` ❌ | `0.1` ✅ |
> | `0.01` | `0.00999999999476131` ❌ | `0.01` ✅ |
> | 其余 9 个 | 逐位精确 | 逐位精确 |
>
> 两处漂移的值 **`0.1` 和 `0.01` 恰恰都在 0.01 格点上** —— 所以"落在格点上"根本不是无损的理由。真正的成因是 `Range` 的吸附式 `round((v - min) / step) * step + min` 在 `min` 与 `step` 相差七个数量级时发生**抵消误差**，与值是否对齐格点无关。
>
> 而漂移量级是 `~5e-12`，比 `0.0001` 容差小**七个数量级**：这个断言即使在有 bug 的版本上跑也是绿的。**容差断言只有在你知道误差上界时才有意义；用来守"应该完全没有误差"的不变式时，它守的是空气。** 现在改为对 `JSON.stringify` 的输出做逐字节比较 —— 那正是会被写进文件的东西，也是唯一有意义的判据。
>
> （附注：`_clean` 的 `snappedf` 会把 `0.1` 在内存里挪低 1 ULP，但 `stringify` 仍输出 `0.1`，故落盘不变。这也说明"内存逐位相等"不是正确的判据，"落盘文本相等"才是。）

- [x] **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
```
（实际分了三个提交：`a0e205a` 主体、`36c4f79` 自动换行 + 拒绝写回 + 收紧 SpinBox 下限、`7884e43` 补最后两条诊断。）

---

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

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

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

Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。

实测：开局即自动进入第 1 波，HUD 正常（HP 100/100、小木法杯、`action_spark_bolt`），`get_editor_errors` **0 条**。运行时状态快照：
```
cpu_limit=5 move_speed=200.00 cast_delay_mod=1.0000 hp_max=100.0 hp=100.0
modifiers=["cpu_limit/flat/5.0/core"]
attr_def_keys=["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]
MAX_OPS=200 (MAX_OPS_PER_CPU=40)
```

- [x] **Step 1b: ⚠️ 先建立可信的运行时错误通道（否则后面所有"零报错"断言都是空的）**

**这一步是 Task 5 新增的，且必须在 Step 7b 之前做。** 工具坑 ⑧：运行中游戏的 `push_error` / `push_warning` **不会**出现在 `get_output_log` 或 `get_editor_errors` 里（只有运行时 `print()` 会）。实测对照：

| 来源 | `print()` | `push_error()` | `push_warning()` |
| :-- | :-- | :-- | :-- |
| 运行中的游戏 | ✅ 进 output 面板 | ❌ 两个通道都不进 | ❌ 两个通道都不进 |
| 编辑器脚本 | ✅ | ✅ | ✅ |

真正的运行时错误通道是编辑器 **Debugger →「错误」页的 `Tree`**。读法（`execute_editor_script`）：
```gdscript
var dbg: Node = null
var stack: Array = [EditorInterface.get_base_control()]
while not stack.is_empty():
	var n: Node = stack.pop_back()
	if n.get_class() == "ScriptEditorDebugger":
		dbg = n; break
	for ch in n.get_children():
		stack.append(ch)
var tree: Tree = null
var st: Array = [dbg]
while not st.is_empty():
	var n: Node = st.pop_back()
	if n is Tree and n.columns == 2 and n.get_parent().name.begins_with("错误"):
		tree = n; break
	for ch in n.get_children():
		st.append(ch)
for top in tree.get_root().get_children():
	var t: String = top.get_text(1)
	if t.contains("GDScript::reload"):
		continue   # 编译期告警，非运行时
	_mcp_print(top.get_text(0) + " | " + t)
```
该 `Tree` **每次 `play_scene` 会重置**，故天然按会话隔离。

> **为什么这步不能省**：Step 7b① 要证明的是"守卫零误报"。若报错通道本身是死的，"日志里没有 `cpu_limit=0`"和"守卫误报了但你看不见"**产生完全相同的观测**。这正是这个功能已经栽过三次的那类"太松以致守不住东西"的断言。

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

**② -a 状态断言**（`execute_game_script`）：
```gdscript
var fails := 0
var log := []
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
	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, SpellEvaluator.MAX_OPS_PER_CPU * eff])
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)])
```
实测：`FAILS=0 ["wand_basic cpu=5 MAX_OPS=200", "wand_fast cpu=3 MAX_OPS=120", "matrix_board cpu=8 MAX_OPS=320", "换杖不累积 5→8 得 8 (mods=1 条)"]`

**② -b 端到端断言（必做 —— ②-a 是同义反复）**：②-a 里的 `MAX_OPS_PER_CPU * eff` 是把被测公式**在断言里重算一遍**，它只证明了 `PlayerStats.cpu_limit` 对，没证明 `execute_compiled` 真的用了它。真正的可观测量是 `spell_evaluator.gd:370` 的 `if ops_count >= max_ops: push_warning("SpellEvaluator: MAX_OPS reached")`。

做法：编一副 N 个 `modifier_spread_mod`（`mana_cost` 0，且 MODIFIER 不消耗 `actions_remaining`，故能一路跑到 `max_ops`）的 LINEAR 牌组，跨越候选 `max_ops` 值两侧施法，看告警是否按预期出现：

| 探针 | `cpu_limit` | 理论 `max_ops` | 牌组长度 | 预期 | 实测 |
| :-- | --: | --: | --: | :-- | :-- |
| A | 3 | 120 | 150 | 触发 | ✅ 触发（0:03:31:012）|
| B | 5 | 200 | 150 | 不触发 | ✅ 未触发 |
| C | 8 | 320 | 150 | 不触发 | ✅ 未触发 |
| D | 8 | 320 | 250 | 不触发 | ✅ 未触发 |
| E | 5 | 200 | 250 | 触发 | ✅ 触发（0:06:01:709）|

按 Step 1b 的读法统计：`MAX_OPS_reached 次数=2 时间戳=["0:03:31:012", "0:06:01:709"]` —— 恰好是 A 和 E。这把 `max_ops` 夹逼为：`cpu=3 → <150`、`cpu=5 → (150, 250]`、`cpu=8 → >250`，与 120 / 200 / 320 一致。**验收 7、8、9 由此变成真实观测而非同义反复。**

**② -c 真实 UI 换杖路径**：在商店点「法杖」按钮轮换全部 5 把杖（按钮文案依次变为 高速法杯 / 记忆法杖 / 矩阵板 / 分叉回路）。抽样：`wand_basic`→`wand_fast` 后 `cpu_limit` 由 5 变 **3**（而非累加成 8），`matrix_board` 时为 **8**，`_modifiers` 全程恒为 1 条。**验收 9 走的是真实 UI 路径而非合成调用。**

> **📌 回填说明**：原计划只有 ②-a。评审期没人注意到它是同义反复 —— 断言把 `MAX_OPS_PER_CPU * cpu_limit` 重算了一遍，而这正是被测代码本身。②-b 才是端到端。

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

**③ -a 数值断言**（`execute_game_script`）：
```gdscript
PlayerStats.remove_modifiers_from("test")
# base=200 → +50%pct=300 → 再 +100flat=450 → 再 +200%pct 应被 hard(800) 钳住 → 撤销回 200
```
实测：`FAILS=0 ["base=200.00", "+50%pct=300.00", "+100flat=450.00", "再+200%pct(应钳到800)=800.00", "撤销后=200.00"]`
（比原计划多一条：`hard: 800` 的上限钳制在运行时也验过，(200+100)×1.5×3 = 1350 → 800。）

**③ -b 物理位移实测（原计划没有，但验收 10 的字面要求是"玩家实际移速跟随变化"）**：`execute_game_script` 不支持 `await`（工具坑 ⑨），故分两次调用：先 `Input.action_press("ui_right")` 并把位置/物理帧号存进 `Engine.set_meta`，隔约 7 秒再 `action_release` 并算 `Δ位移 / (帧数 × 1/60)`。
- 无加成：`实测=200.00 px/s（Δ=1503.34 px / 451 帧）`
- `+50% pct`：`实测=300.00 px/s（Δ=1665.00 px / 333 帧）`

- [x] **Step 4: `cast_delay_mod` 生效（验收 11）—— 原脚本已失效，此为适配版**

> **📌 回填说明 —— 原脚本为何跑不出正确结论**：原 Step 4 读 `pm._cast_interval` 并期待它等于 `0.25`。Task 3 评审把 `cast_delay_mod` 从**装备时**折叠改到了**开火时**相乘（见 Task 3 Step 3），`_cast_interval` 现在只装法杖基准 `0.5`。原断言会**失败**，但失败的是断言不是实现 —— 而且更糟的是：如果有人为了让它变绿而把实现改回装备时折叠，就把货架 C 的可用性一并改没了。

```gdscript
var core = WandPreset.make_core_by_id("wand_basic")   # cast_interval 0.5
var comp = SpellEvaluator.compile_wand(core, [SpellRegistry.get_spell("action_spark_bolt")])
PlayerStats.remove_modifiers_from("test")
PlayerManager.equip_wand(core, comp)
# ① 装备时 _cast_interval 只装法杖基准，不含 mod
assert(abs(PlayerManager._cast_interval - 0.5) < 0.0001)
PlayerStats.add_modifier("cast_delay_mod", "flat", -0.5, "test")   # inverse: 1.0 + (-0.5) = 0.5
# ② _cast_interval 不随加成变动（证明 mod 没有被折进去 —— 局中加成必须能生效）
assert(abs(PlayerManager._cast_interval - 0.5) < 0.0001)
# ③ 生效间隔 = 基准 × mod = 0.25（减半）
# ④ 开火时写进 _cast_timer 的就是生效间隔
PlayerManager._cast_timer = 0.0
PlayerManager._handle_auto_cast(0.001)
assert(abs(PlayerManager._cast_timer - 0.25) < 0.0001)
PlayerStats.remove_modifiers_from("test")
```
实测：`FAILS=0 ["_cast_interval=0.5000（应=core.cast_interval 0.5，不含 mod）", "cast_delay_mod=0.5000", "加成后 _cast_interval 仍=0.5000", "生效间隔=0.2500（应 0.25，减半）", "开火后 _cast_timer=0.2500（应 0.25）"]`

另有 Step 7b② 的扫描作为端到端佐证：`wand_basic` 在 `mod=1.0` 时实测帧距 31 帧，`mod=0.5` 时 16 帧 —— 实际施法周期确实减半（差 1 帧是整帧量化，见 7b②）。

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

```gdscript
PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core")
PlayerStats.add_modifier("cpu_limit", "flat", 3.0, "shopC")   # both = 8
PlayerStats.remove_modifiers_from("core")                     # only_shop = 3
PlayerStats.remove_modifiers_from("shopC")                    # none = 0
# 加强：同一 source 对两个属性各加一条，撤销须两条一起走、且不碰他源
PlayerStats.add_modifier("cpu_limit", "flat", 4.0, "shopC")
PlayerStats.add_modifier("move_speed", "flat", 50.0, "shopC")
PlayerStats.add_modifier("move_speed", "flat", 30.0, "other")
PlayerStats.remove_modifiers_from("shopC")                    # 应得 cpu=0, spd=230
```
实测：`FAILS=0 both=8 only_shop=3 none=0 | 跨属性同源: cpu=4 spd=280.0 → 撤 shopC → cpu=0 spd=230.0（应 cpu=0 spd=230）`

- [x] **Step 5b: 评审改动的运行时回归（原计划没有，补四条）**

```
非法加成后 _modifiers 条数 1→1（应不变）                      ← add_modifier 校验
hp_max 200→100 后 hp 由 200 被钳到 100（非停在 200）          ← _recompute_attrs 的 minf
两条 -200% pct → move_speed=0.00（非负负得正回到 200）        ← AttributeFormula 越界守卫
reset_for_run 后 mods=0 hp_max=100.0 hp=100.0                ← 清 _modifiers + 重算早于 hp=hp_max
get_save_data 键=["hp", "gold", "xp", "level"]               ← 派生值已出存档
```
实测：`FAILS=0`，且 Debugger 错误页同步出现三种预期诊断共 5 条（`未知属性「zzz_bogus_attr」`×1 / `未知 mode「zzz_bogus_mode」`×1 / `pct 越界（value=-2.000000 → 因子 -1.000000）`×3 —— 3 条是因为第二条 `pct` 加入时 `_recompute_attrs` 会把两条一起重算）。

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

Run: godot-mcp-pro `stop_scene`，然后 `execute_editor_script` 扫四个文件的 `PlayerStats.armor` / `PlayerStats.resistance` / `var armor` / `var resistance` / `MOVE_SPEED`，外加 `get_save_data`/`load_save_data` 函数体内不得提及 `hp_max` / `cpu_limit`。
实测：`FAILS=0 []`

全项目 grep 复核：`armor` / `resistance` 在 `scripts/` 下只剩敌人侧用法 —— `damage_context.calc_damage(armor, resistance)` 的形参、`enemy_manager` 的 `_ARMOR` / `resistances`、设计器 `enemy_tab`。**玩家侧零残留**，敌人 armor 与元素抗性是另一套，不受本次影响。

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

- `docs/design/numerical_design.md` §1.1 `move_speed` 行：基准 **300 → 200**，并注明「原写 300 从未被任何代码读取过，是纸面孤值；200 自 S0 沿用，20 波内容/Boss 弹幕密度/无敌帧 0.5 s 均按此值调校，以既成事实为准」，引 spec §2.2。
- 同表 `cpu_limit` 行：删掉 `⚠️实现现状(2026-07-20)：代码硬编码 MAX_OPS = 40×5 = 200，从不读 cpu_limit（待修）`，改为 ✅ 已修复；并把**基准值也由 5 改为 0**（否则重蹈 `move_speed` 那种"纸面值与实现长期脱节"），注明生效值 = 玩家(0) + 法杖(`cores.json` 3–8) 且 `hard`(50) 作用于加总后的总值。
- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`：E3 切分 ① 勾除并标注**实际范围**（只做框架 + 三条死数据；`attunement_×4` / `luck` / `recharge_speed_mod` 各附延后理由；`mana_max` 的 Min 语义记为遗留）；订正 E3「现状」里「`player_stats.gd` 有 `resistance` 等占位属性（4/5 stats 未接线）」这句（权威属性表是 11 个，`armor`/`resistance` 根本不在表内）；E1 现状里同源的「玩家侧抗性仍占位」一句一并订正。

提交：`1aeae19`

- [x] **Step 7b: 守卫零误报 + `cast_delay_mod` 饱和点（Task 3 复审转来）**

**① 守卫零误报 —— 实测结论：零误报，且通道已证明是活的。**

先决条件是 Step 1b（否则观测为空）。观测结果：

复审静态核对过的六条进入路径，**全部**在运行时跑到了：

| # | 入口路径 | 怎么跑到的 | 结果 |
| :- | :-- | :-- | :-- |
| 1 | `start_game` → `_transition_to_battle` | `play_scene` 开局 | 0 条，`cpu_limit=5` |
| 2 | `resume_game` → `_transition_to_battle` | `ProfileManager.save_run()` 后调 `resume_game()` | 0 条，`cpu_limit=5`，deck 正确恢复 |
| 3 | `switch_core` | 商店「法杖」按钮 ×4，轮换覆盖全部 5 把杖 | 0 条；抽样 `wand_basic`=5、`wand_fast`=3、`matrix_board`=8，`_modifiers` 恒 1 条 |
| 4 | `_apply_padded`（`install_spell`） | 商店买一张 Spark Bolt | 0 条，`cpu_limit=5`，deck 变 2 张 |
| 5 | `apply_wand_save_data` | 由路径 2 的 `ProfileManager.apply_run` 带到（其自身不调 `equip_wand`） | 0 条 |
| 6 | `combat_test.gd` | `play_scene combat_test.tscn`（独立会话） | 0 条，`cpu_limit=5` |
| — | **正对照**：手动 `remove_modifiers_from("core")` 后调 `equip_wand` | 故意制造 `cpu_limit=0` | ✅ **报了 1 条** `player_manager.gd:98 @ equip_wand(): PlayerManager: 装备「小木法杯」后 cpu_limit=0…` |

正对照是这一步的关键：它证明守卫**是活的**，因此"其余路径 0 条"才有信息量。整场会话（约 13 分钟）的运行时报错逐条清点后**全部**能归因到本次验收自身（故意探针 + MCP 注入脚本的解析告警），无一条来自游戏代码的意外路径。

**② `cast_delay_mod` 的 `hard: 0.01` 承诺了循环交付不了的东西 —— 设计决策，仅上报数字，不在代码里解决。**

`_handle_auto_cast` 用 `if` 而非 `while`，**每物理帧至多一次施法**，60 Hz 物理步长下硬顶 60 次/秒。且 `:81` 是**赋值**而非累积扣减，亚帧余量每周期被丢弃，故实际速率按**整帧量化**。

测法（工具坑 ⑨/⑩）：向 `get_tree().root` 挂一个临时 `_physics_process` 节点，按相位自动切换法杖与 `cast_delay_mod`（用 `flat` 模式精确取值：`inverse` 下 `mod = 1.0 + flat`），每相位 180 帧，记录**相邻两次施法的帧距分布**。每个相位的帧距分布都是**单值**（如「1 帧×179」「2 帧×89」），这本身就是量化的直接证据。

| 法杖 | `cast_delay_mod` | 生效间隔 T | 实测帧距 | 实际速率 |
| :-- | --: | --: | --: | --: |
| `wand_basic`（基准 0.5 s） | 1.0 | 0.5 s | 31 帧 | 1.94 /s |
| | 0.5 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1（soft）** | 0.05 s | 3 帧 | **20 /s** |
| | 0.07 | 0.035 s | 3 帧 | 20 /s |
| | 0.06 | 0.03 s | 2 帧 | 30 /s |
| | 0.04 | 0.02 s | 2 帧 | 30 /s |
| | 0.034 | 0.017 s | 2 帧 | 30 /s |
| | **0.033** | 0.0165 s | 1 帧 | **60 /s ← 饱和点** |
| | 0.02 | 0.01 s | 1 帧 | 60 /s |
| | **0.01（hard）** | 0.005 s | 1 帧 | **60 /s** |
| `wand_fast`（基准 0.25 s） | 1.0 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1（soft）** | 0.025 s | 2 帧 | **30 /s** |
| | 0.07 | 0.0175 s | 2 帧 | 30 /s |
| | 0.0667 | 0.016675 s | 2 帧 | 30 /s ← 采样点比真实边界 1/15 高 3.3e-5，故落在 2 帧侧 |
| | **0.05** | 0.0125 s | 1 帧 | **60 /s ← 已在饱和区内（饱和点是 0.0667，非 0.05）** |
| | **0.01（hard）** | 0.0025 s | 1 帧 | 60 /s |

结论（供决策）：

1. **饱和点 = `(1/60) / 法杖基准间隔`**。`wand_basic` ≈ **0.0333**、`wand_fast` ≈ **0.0667**、`circuit_fork`(0.6 s) ≈ 0.0278。
2. **从饱和点到 `hard`(0.01) 之间买不到任何东西**：`wand_basic` 白费 3.3 倍区间，`wand_fast` 白费 6.7 倍。
3. **soft→hard 整段只有 3 个可达档位**（`wand_basic`：20 / 30 / 60 次每秒），**`wand_fast` 只有 2 个**（30 / 60）—— 快端是台阶不是平滑曲线。
4. 帧距总是 `ceil(T × 60)`，且 `T` 恰为整帧倍数时会**多一帧**（`T=0.5` 得 31 而非 30，`T=0.25` 得 16 而非 15）：`_cast_timer` 是赋值，浮点余量使它多跑一帧。故**实际速率永远 ≤ 名义速率**，不会超发。

**两个选项曾提交人定**：(a) 保持 `hard: 0.01` 并在文档写明饱和区；(b) 把 `hard` 提到 ~0.05。

**✅ 用户 2026-07-31 决定：采用 (b)，`hard: 0.01 → 0.05`。** 落地三处：`data/attributes.json`、`numerical_design.md` §1.1、spec 新增 §2.2b（收录完整实测数据集）。

依据：饱和点 = `(1/60) / 法杖基准间隔` = `circuit_fork` 0.0278 / `wand_basic` **0.0333** / `wand_fast` **0.0667**。**该值随杖而异，故不存在对所有杖都最优的单一 `hard`**：取 0.05 使 `wand_basic` 与 `circuit_fork` 无无效区间，`wand_fast` 仍剩 `[0.05, 0.0667)` 一小段（从原来的 6.7 倍缩到 1.33 倍）；若改取 0.0667 让 `wand_fast` 干净，则 `wand_basic`/`circuit_fork` 反而够不到自己的饱和点，制造新的不可达区间。

> **📌 订正（2026-07-31）**：本步初稿曾写「`wand_fast` 恰好在 0.05 饱和 / 两把主力杖都不再有无效区间」，**算错了** —— `(1/60)/0.25 = 0.0667` 而非 0.05。上方实测表里的 0.0667 一直是对的，错的是从口头转述派生的标注。`hard` 数值不受影响（0.05 仍是选定值，只是理由要说准）。

**边界复测**（改动后用同一套扫描重跑，这是本次改动唯一的行为差异，值得实测而非推导）：

| 法杖 | 请求 `mod` | 生效 `mod` | T | 实测帧距 | 速率 |
| :-- | --: | --: | --: | --: | --: |
| `wand_fast` | 0.05 | 0.05 | 0.0125 s | **1 帧×179** | 60 /s（已入饱和区；饱和点是 0.0667，故 `[0.05, 0.0667)` 仍是无效区间）|
| `wand_fast` | 0.01 | **0.05** | 0.0125 s | 1 帧×179 | 60 /s（`maxf(hard,…)` 钳制生效）|
| `wand_basic` | 0.05 | 0.05 | 0.025 s | **2 帧×89** | 30 /s |
| `wand_basic` | 0.01 | **0.05** | 0.025 s | 2 帧×89 | 30 /s（钳制生效）|
| `wand_basic` | 0.1（soft） | 0.1 | 0.05 s | 3 帧×59 | 20 /s（对照，未受影响）|
| `circuit_fork` | 0.05 | 0.05 | 0.03 s | 2 帧×89 | 30 /s（最慢杖吃不满硬顶，见 spec §2.2b 遗留）|

**未在代码里加下限钳制** —— 下限已由 `attributes.json` + `AttributeFormula` 的 `maxf(hard, ...)` 强制，在 `player_manager` 再加一个会形成重复权威，设计师调 `hard` 时立刻脱同步，违反「纯数据驱动，代码不含硬编码副本或回退」。

**③ 顺带收两条 Task 3 复审的 Minor**（提交 `9f71108`）：
- `player_manager.gd` 守卫注释里的 `player_stats.gd:173/191` 行号引用会腐烂，改为函数名 `PlayerStats._load_attr_definitions` / `PlayerStats.add_modifier`。
- `scenes/main/combat_test.gd` 的注入补上顺序理由（`remove` 先于 `add` 防累积；两者都先于 `equip_wand`，因守卫读的正是注入结果）。

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

已完成（即本文件）。回填清单：Task 1 Step 2/3/3b、Task 2 Step 2/3、Task 3 Step 1/3/4/**4b（新增）**、Task 4 Step 1/3、Task 5 全部（新增 Step 1b/2b/2c/3b/5b）。每处附「原写法为何不可用」。

**回填出的一条通用教训**：本期有三条断言"太松以致守不住它名义上守的东西"——
- Task 1 Step 3 的断言 ④（`add_int` 拒 `pct`）：数字部分删掉守卫也照样绿。
- Task 4 Step 3 的 `0.0001` 容差：比要防的漂移大七个数量级。
- Task 5 Step 2 的 `MAX_OPS_PER_CPU * eff`：把被测公式在断言里重算了一遍。
共同点是**断言复述了实现，而非观测实现的后果**。判据：*把被测代码删掉，这条断言会变红吗？* 三条的答案都是"不会"。

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

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

---

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

> spec §4 的编号有笔误（出现两个 `12`），下表以本计划的编号为准：12 = `remove_modifiers_from`、13 = 孤儿字段/存档、14 = 设计器往返。

| # | 验收标准 | 覆盖 | 观测方式 | 结果 |
| :- | :-- | :-- | :-- | :-- |
| 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 ④ **+ Step 3b** | 数字断言**不够**，靠 `push_error` 探针 | ✅ |
| 5 | `hard` 两方向 + `hard=0` 不钳制 | Task 1 Step 3 ⑤⑤b⑥c | 纯函数断言 | ✅ |
| 6 | 空列表 → base；未知 `combine` 回退 | Task 1 Step 3 ⑥⑥b + 3b | 纯函数断言 + 探针 | ✅ |
| — | （新增）`pct` 越界不得负负得正 | Task 1 Step 3 ⑦⑦b + 3b | 纯函数断言 + 探针 | ✅ |
| 7 | `cores.json` `cpu_limit` 生效 | Task 5 Step 2b | 运行时 `MAX_OPS reached` 告警五点夹逼 | ✅ |
| 8 | 现有平衡零改动（小木仍 200 步） | Task 5 Step 1 / 2b | 开局实测 + 夹逼 | ✅ |
| 9 | 换杖不累积（5→8 得 8 非 13） | Task 5 Step 2a / 2c | 商店按钮真实换杖 | ✅ |
| 10 | `move_speed` 数据驱动 + 加成生效 | Task 5 Step 3a / 3b | 数值断言 + **物理位移实测** | ✅ |
| 11 | `cast_delay_mod` 使间隔减半 | Task 5 Step 4（**已适配**） | `_cast_timer` + 施法帧距扫描 | ✅ |
| 12 | `remove_modifiers_from` 不误删他源 | Task 5 Step 5 | 运行时 | ✅ |
| 13 | 孤儿字段零残留 + 存档不含派生值 | Task 5 Step 6 | 源码断言 + 全项目 grep | ✅ |
| 14 | 设计器往返一致 + `combine` 存 key | Task 4 Step 3 | 绕缓存实例化 + **落盘文本逐字节比较** | ✅ |
| spec 14 | 解析通过；文件缺失 `push_error` 不静默回退 | Task 1–4 各 Step | 解析已由全流程运行证明；**「文件缺失 `push_error`」仅经代码审读，未在运行时构造缺文件场景** | ⚠️ 部分 |
