Files
spellforge/docs_dev/plans/2026-07-31-player-attributes.md
T
joywayerandClaude Opus 5 d259205db2 docs(plan): 补跑守卫的另外三条进入路径,六条全部由运行时而非眼读覆盖
resume_game / _apply_padded(买卡) / apply_wand_save_data 三条原本只经静态核对,
现已在运行时跑到:均 0 条 cpu_limit=0 报错,且 deck/属性状态正确。
连同已跑的 start_game / switch_core / combat_test,六条齐全。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:10:24 +08:00

1077 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 玩家属性系统(Player AttributesImplementation 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-31Task 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,与上二者相反
## 返回统一为 floatadd_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_wandremove 必须先于 add(否则重复进入会累积),
# 而两者又必须都先于 equip_wandequip_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 ULPstringify 仍输出 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..2UI.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` 38) 且 `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.1soft** | 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.01hard** | 0.005 s | 1 帧 | **60 /s** |
| `wand_fast`(基准 0.25 s | 1.0 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1soft** | 0.025 s | 2 帧 | **30 /s** |
| | 0.07 | 0.0175 s | 2 帧 | 30 /s |
| | 0.0667 | 0.016675 s | 2 帧 | 30 /s |
| | **0.05** | 0.0125 s | 1 帧 | **60 /s ← 饱和点** |
| | **0.01hard** | 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`,在 `numerical_design.md``attributes.json` 注释里写明「低于 `(1/60)/基准间隔` 为饱和区,不再提升实际射速」;
- (b) 把 `hard` 提到 ~0.05(约等于最慢法杖的饱和点),让 JSON 承诺的是真能交付的东西。
**不要在代码里加下限钳制** —— 下限已由 `attributes.json` + `AttributeFormula``maxf(hard, ...)` 强制,在 `player_manager` 再加一个会形成重复权威,设计师调 `hard` 时立刻脱同步,违反「纯数据驱动,代码不含硬编码副本或回退」。**本步只上报数字,未改 `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`」仅经代码审读,未在运行时构造缺文件场景** | ⚠️ 部分 |