From 04ca402f3c9639db392f6d222ac5ad7548c38b5a Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 31 Jul 2026 13:05:40 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E5=9B=9E=E5=A1=AB=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E7=B3=BB=E7=BB=9F=E8=AE=A1=E5=88=92=E4=B8=BA=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E6=89=A7=E8=A1=8C=E7=89=88=EF=BC=8C=E5=8B=BE=E9=99=A4?= =?UTF-8?q?=E9=99=A4=E5=90=88=E5=B9=B6=E5=86=B3=E7=AD=96=E5=A4=96=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Task 5 Step 8:每个因评审而变的代码块与断言脚本都替换为真正跑通的版本, 逐处附「原写法为何不可用」——这才是回填的持久价值,否则下次复跑喂出的是 过时代码和可能已失效的断言(归航那期踩过)。 主要回填:AttributeFormula 的 pct 越界守卫与静态类型;PlayerStats 的 add_modifier 校验 / hp 钳制 / reset_for_run 清空 / 空定义分支用 assert;守卫从施法处移到 equip_wand; cast_delay_mod 改开火时生效(原计划的装备时折叠会让局中加成到下次换杖才生效, 正好废掉货架 C);补 combat_test 注入这个原计划遗漏的消费方;属性页 SpinBox 下限、 _loaded 拒写回、autowrap、两条 push_warning。 订正计划自己的一句假话:原写「四个属性的值全部落在 0.01 格点上,故往返无量化损失」。 实测 min=-99999 时 0.1→0.100000000005821、0.01→0.00999999999476131,二者恰恰都在 0.01 格点上——成因是 Range 吸附式在 min 与 step 相差七个数量级时的抵消误差,与格点 无关;而 0.0001 容差比该漂移大七个数量级,原断言在有 bug 的版本上也是绿的。改为对 落盘文本逐字节比较。 Task 5 另记两条工具坑:运行中游戏的 push_error/push_warning 不进 MCP 的两个日志通道 (只有 print 进),故「跑一局看日志无报错」本身是空断言,必须读编辑器 Debugger 错误页 并先用故意错误做正对照;execute_game_script 不支持 await、三引号会被外层包装破坏。 Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-31-player-attributes.md | 761 ++++++++++++------ 1 file changed, 527 insertions(+), 234 deletions(-) diff --git a/docs_dev/plans/2026-07-31-player-attributes.md b/docs_dev/plans/2026-07-31-player-attributes.md index 332cf9c..2ba994f 100644 --- a/docs_dev/plans/2026-07-31-player-attributes.md +++ b/docs_dev/plans/2026-07-31-player-attributes.md @@ -1,6 +1,8 @@ # 玩家属性系统(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` 接入点。 @@ -9,25 +11,28 @@ **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 S = ResourceLoader.load(path, "GDScript", ResourceLoader.CACHE_MODE_IGNORE) +> 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 里被缓存,同理。**优先用 ②。** +> ④ `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 := ` 在 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: 建功能分支 -- [ ] **Step 1: 从 master 切分支** +- [x] **Step 1: 从 master 切分支** spec 与本计划已提交在 `master`;CLAUDE.md 规定主分支上不做功能开发。 @@ -47,7 +52,7 @@ Expected: `Switched to a new branch 'feat/player-attributes'` > 本任务是纯函数,**先写断言、看它失败、再实现**。断言脚本不落盘为文件(项目无测试框架),而是经 `execute_editor_script` 执行 —— 与既有验收方式一致。 -- [ ] **Step 1: 先跑断言脚本,确认它因类不存在而失败** +- [x] **Step 1: 先跑断言脚本,确认它因类不存在而失败** Run: godot-mcp-pro `execute_editor_script`: ```gdscript @@ -55,9 +60,9 @@ _mcp_print("exists=%s" % str(ClassDB.class_exists("AttributeFormula") or Resourc ``` Expected: `exists=false`。若为 `true` 说明文件已存在,先确认不是残留。 -- [ ] **Step 2: 实现 `AttributeFormula`** +- [x] **Step 2: 实现 `AttributeFormula`** -Create `scripts/domain/attribute_formula.gd`: +Create `scripts/domain/attribute_formula.gd`(**以下为实际落地版本,含评审后的四处改动**): ```gdscript ## AttributeFormula — 属性合成公式的唯一实现处 ## 纯静态函数:无状态、不依赖 PlayerStats / EventBus / 场景树,故可脱离游戏进程单元测试 @@ -67,7 +72,7 @@ extends RefCounted enum Combine { HYBRID, INVERSE, ADD_INT } -const _COMBINE_NAMES: Dictionary = { +const _COMBINE_BY_NAME: Dictionary[String, Combine] = { "hybrid": Combine.HYBRID, "inverse": Combine.INVERSE, "add_int": Combine.ADD_INT, @@ -75,22 +80,28 @@ const _COMBINE_NAMES: Dictionary = { ## JSON 的 combine 字符串 → 枚举;未知值 push_error 并回退 HYBRID static func combine_from_string(s: String) -> Combine: - if _COMBINE_NAMES.has(s): - return _COMBINE_NAMES[s] + 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 in mods: + 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": @@ -100,31 +111,43 @@ static func compute(base: float, mods: Array, attr_def: Dictionary) -> float: push_error("AttributeFormula: add_int 属性不接受 pct 加成(value=%f),已忽略" % v) continue # 连乘而非线性求和:三条 +20% = ×1.728 而非 ×1.6,避免后期线性失控 - pct_prod *= (1.0 + v) if combine == Combine.HYBRID else (1.0 - v) + 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 ri: float = floorf(base + flat_sum) - return clampf(ri, 0.0, hard) if hard > 0.0 else maxf(0.0, ri) + 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 是**下限**。注意 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 约定为「不钳制」 ``` -- [ ] **Step 3: 跑公式单元断言(spec §4 验收 1–6)** +> **📌 回填说明 —— 原写法为何不可用**(四处,均为 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 -# 绕开 class_name 缓存/假阴性:从磁盘构造全新类 -var S := GDScript.new() -S.source_code = FileAccess.get_file_as_string("res://scripts/domain/attribute_formula.gd") -if S.reload() != OK: - _mcp_print("PARSE_FAIL"); return -var F = S +# 工具坑 ②: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} @@ -136,18 +159,36 @@ var INT := {"combine": "add_int", "hard": 50.0} 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 := [ - ["①连乘", F.compute(1.0, m3, HYB), 1.728], - ["②混合", F.compute(200.0, [{"mode":"flat","value":50.0},{"mode":"pct","value":0.2}], HYB), 300.0], - ["③inverse", F.compute(1.0, m3, INV), 0.512], - ["③b下限", F.compute(1.0, many, INV), 0.01], - ["④拒pct", F.compute(0.0, [{"mode":"flat","value":5.0},{"mode":"pct","value":0.5},{"mode":"flat","value":8.0}], INT), 13.0], - ["⑤上限", F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB_CAP), 800.0], - ["⑤b不钳制", F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB), 5200.0], - ["⑥空", F.compute(123.0, [], HYB), 123.0], - ["⑥b未知", F.compute(1.0, m3, {"combine": "bogus", "hard": 0.0}), 1.728], - ["⑥c整数上限", F.compute(0.0, [{"mode":"flat","value":999.0}], INT), 50.0], + ["①连乘", 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 := [] @@ -155,39 +196,43 @@ for c in cases: if abs(float(c[1]) - float(c[2])) > 0.0001: fails += 1 bad.append("%s got=%.6f want=%.6f" % [c[0], c[1], c[2]]) -_mcp_print("FAILS=%d %s" % [fails, str(bad)]) +_mcp_print("FAILS=%d/%d %s" % [fails, cases.size(), str(bad)]) ``` -Expected: `FAILS=0 []` +Expected(实测 2026-07-31):`FAILS=0/12 []` -> ⚠️ **断言 ④ 的数字部分对「拒绝 `pct`」是空的**(Task 1 评审实测发现):把整个拒绝块删掉,④ 仍返回 13.0 —— 因为 `ADD_INT` 的 match 分支根本不读 `pct_prod`。数字只证明了「`pct` 之后的 `flat` 仍能累加」。**拒绝行为唯一的可观测证据是 `push_error`**,必须单独断言,否则意图②无人守卫。 +> **📌 回填说明 —— 原写法为何不可用**: +> - 原夹具用 `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),否则意图②无人守卫。 -- [ ] **Step 3b: 断言 `push_error` 真的发出(补 ④ 的空缺)** +- [x] **Step 3b: 断言 `push_error` 真的发出(补 ④ 的空缺 + 新增的越界守卫)** -用**唯一标记值**发起调用,再从编辑器错误日志里找它 —— 标记值确保读到的不是历史遗留日志。 +用**唯一标记值**发起调用,再从编辑器错误日志里找它 —— 标记值确保读到的不是历史遗留日志(工具坑:`clear_output` **不会**清空 `get_editor_errors` 的缓冲区,旧行会一直在)。 Run: godot-mcp-pro `clear_output`,然后 `execute_editor_script`: ```gdscript -var S := GDScript.new() -# ⚠️ 必须剥掉 class_name:该类此时已被引擎注册为全局类,带 class_name 的内存副本会报 -# "hides a global script class"(Task 1 首次运行时尚未注册才侥幸通过) -S.source_code = FileAccess.get_file_as_string("res://scripts/domain/attribute_formula.gd") \ - .replace("class_name AttributeFormula\n", "") -if S.reload() != OK: - _mcp_print("PARSE_FAIL"); return -# 三条各带唯一标记,逐一触发报错路径 -S.compute(0.0, [{"mode":"pct","value":0.123456}], {"combine":"add_int","hard":50.0}) -S.compute(1.0, [], {"combine":"zzzprobe9182","hard":0.0}) -S.compute(1.0, [{"mode":"zzzmode9182","value":1.0}], {"combine":"hybrid","hard":0.0}) +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`,断言三条标记均出现: +再 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: 三条全部命中。**任一缺失即说明对应的报错路径未执行** —— 那是真缺陷,不是日志问题。 +Expected: 四条全部命中。**任一缺失即说明对应的报错路径未执行** —— 那是真缺陷,不是日志问题。 -- [ ] **Step 4: 提交** +> **📌 回填说明**:原脚本用 `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 @@ -210,7 +255,7 @@ EOF - Create: `data/attributes.json` - Modify: `scripts/autoloads/player_stats.gd` -- [ ] **Step 1: 新建 `data/attributes.json`** +- [x] **Step 1: 新建 `data/attributes.json`**(与原计划一致,未改) ```json { @@ -221,13 +266,19 @@ EOF } ``` -- [ ] **Step 2: `PlayerStats` 加属性框架** +- [x] **Step 2: `PlayerStats` 加属性框架** -`player_stats.gd`:将 `var hp_max: float = 100.0`(:10)保留(生效值字段),并在「战斗属性」区(:21-23)**整段替换** —— 删 `cpu_limit` 旧声明、删 `armor`、删 `resistance`,改为: +`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 # 生效运算力(含法杖 "core" 加成);MAX_OPS = 本值 × 40 +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 窗口 @@ -246,23 +297,50 @@ func _ready() -> void: 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 # ── 属性框架 ────────────────────────────────────────────── -const _ATTR_PATH: String = "res://data/attributes.json" func _load_attr_definitions() -> void: - if not FileAccess.file_exists(_ATTR_PATH): - push_error("PlayerStats: 缺少 %s" % _ATTR_PATH) + if not FileAccess.file_exists(ATTRIBUTES_JSON): + push_error("PlayerStats: 缺少 %s" % ATTRIBUTES_JSON) return - var parsed = JSON.parse_string(FileAccess.get_file_as_string(_ATTR_PATH)) + var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON)) if not (parsed is Dictionary): - push_error("PlayerStats: %s 格式错误(应为对象)" % _ATTR_PATH) + 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() @@ -272,24 +350,31 @@ func remove_modifiers_from(source: String) -> void: 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: - var out: Array = [] +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 公式重算生效值,写回裸字段 +## 逐属性按各自 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: @@ -300,10 +385,29 @@ func _compute_attr(attr_id: String) -> float: return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d) ``` -- [ ] **Step 3: 存档字段清理(`hp_max` / `cpu_limit` 现为派生值)** +> **📌 回填说明 —— 原写法为何不可用**(六处,均为 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` 同型)。 -两者现在由 `attributes.json` + `_modifiers` 算出,存档回读会覆盖公式结果。`get_save_data()`(:135)与 `load_save_data()`(:138)改为: +- [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} @@ -317,11 +421,9 @@ func load_save_data(data: Dictionary) -> void: ``` 旧存档里的 `hp_max` / `cpu_limit` 键被忽略即可,**不需要提升 schema 版本号**:`hp_max` 全项目从无写入方(永远是 100),`cpu_limit` 从未被读取过。 -> **留给货架 C 的提示**(写进代码注释):一旦玩家可购买属性,需要持久化的是 `_modifiers`(来源列表)而非派生的生效值,届时 `get_save_data` 应加 `"attr_modifiers": _modifiers` 并提升 schema 版本。`source == "core"` 的那条**不要**持久化——它在换杖时由 `combat_manager._rebuild_wand` 重建。 +> **📌 回填说明**:原计划只写了「留给货架 C 的提示」一句话。评审后补了**两条会真咬人的顺序/类型陷阱**(`assign` vs `=`、`_modifiers` 必须先于 `hp` 回读)—— 前者是运行时类型错误,后者是**静默吞血**:先赋 `hp` 再重算,`hp = minf(hp, hp_max)` 会拿"还没加成的 hp_max"去钳一个"已经加成过的 hp"。 -在 `_recompute_attrs()` 上方加注释记录这一点。 - -- [ ] **Step 4: 校验语法 + 数据** +- [x] **Step 4: 校验语法 + 数据** Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/player_stats.gd` — `valid: true`(该文件无 `class_name`,失败即真错误)。 @@ -340,7 +442,7 @@ _mcp_print("FAILS=%d" % fails) ``` Expected: `FAILS=0` -- [ ] **Step 5: 提交** +- [x] **Step 5: 提交** ```bash git add data/attributes.json scripts/autoloads/player_stats.gd @@ -368,31 +470,28 @@ EOF **Files:** - Modify: `scripts/domain/spell_system/spell_evaluator.gd:332` -- Modify: `scripts/domain/player_manager.gd:7,45,88` +- 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`(原计划漏了这个消费方) -- [ ] **Step 1: MAX_OPS 读生效 `cpu_limit`** +- [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 以 source="core" 的加成接入) - # 0 守卫:cpu_limit=0 意味着所有法术执行零步、全局静默失效。此处是该故障唯一可观测处, - # 能同时兜住上游两种失败(attributes.json 缺失 → 裸字段停在 0;add_modifier 的 attr_id 打错 → - # "core" 份额没注入),而根因处的报错够不到这两种。 - if PlayerStats.cpu_limit <= 0: - push_error("SpellEvaluator: PlayerStats.cpu_limit=%d,法术将无法执行;检查 attributes.json 与 _rebuild_wand 的 core 加成注入" % PlayerStats.cpu_limit) + # 生效 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) ``` -> **为什么在这里也守一道**(Task 2 代码质量评审建议):`cpu_limit = 0` 的后果是「所有法术静默什么都不做」—— 症状离根因极远。而这里是它变得**可观测**的地方,一条 `push_error` 就能指向真正该查的两处。`maxi(..., 1)` 使游戏在故障下仍可玩(退化为 40 步)而非完全瘫痪。 +> **📌 回填说明 —— 原写法为何不可用**:原计划在**这里**放 `if PlayerStats.cpu_limit <= 0: push_error(...)`。评审改为搬到 `PlayerManager.equip_wand`(见 Step 3)。理由两条:① 本函数**每秒被调用约 2 次**(施法频率),守卫一旦触发就是每秒两条报错刷屏,反而把根因埋掉;`equip_wand` 每次换杖才跑一次。② 这里拿不到杖名,报错说不出"是哪把杖配成 0 的"。**守卫要放在故障"变得可观测"且"报一次就够"的地方,不是放在故障"造成后果"的地方。** -- [ ] **Step 2: 移速改读属性** +- [x] **Step 2: 移速改读属性** -`player_manager.gd`:删除 `:7` 整行 `const MOVE_SPEED: float = 200.0`;把 `:45` 的 +`player_manager.gd`:删除 `:7` 整行 `const MOVE_SPEED: float = 200.0`;把 `:45` 的 ```gdscript _velocity = dir * MOVE_SPEED ``` @@ -401,35 +500,67 @@ EOF _velocity = dir * PlayerStats.move_speed ``` -- [ ] **Step 3: 施法间隔乘 `cast_delay_mod`** +- [x] **Step 3: 施法间隔乘 `cast_delay_mod`(在开火时,不在装备时)** -`player_manager.gd` 的 `equip_wand`(:85),把 `:88` 的 +`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 -``` -改为 -```gdscript - _cast_interval = (core.cast_interval if core else 0.5) * PlayerStats.cast_delay_mod + ... + # 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 "—")) ``` -- [ ] **Step 4: 法杖 `cpu_limit` 作为加成来源接入** +> **📌 回填说明 —— 原写法为何不可用**:原计划写 `_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`,否则换杖会累积旧法杖份额。) -- [ ] **Step 5: 校验语法** +> **📌 回填说明**:原计划只写「顺序要紧:先 `remove` 再 `add`」。补充的是**为什么这个 bug 特别难发现**:`_rebuild_wand` 每次装法术都跑,累积会很快撞上 `hard: 50` 被封顶 —— 封顶后它就成了一个"看起来挺合理"的常数,而不是一路涨到显眼的荒谬值。 -Run: godot-mcp-pro `validate_script` on `res://scripts/domain/spell_system/spell_evaluator.gd`、`res://scripts/domain/player_manager.gd`、`res://scripts/domain/combat/combat_manager.gd` — 均 `valid: true`。 +- [x] **Step 4b: 补 `combat_test.gd` 的注入(原计划遗漏)** -- [ ] **Step 6: 提交** +`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 +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 以加成来源接入 @@ -444,6 +575,7 @@ MAX_OPS 由硬编码 40×5 改为读生效 cpu_limit——cores.json 里逐法 Co-Authored-By: Claude Opus 5 EOF ``` +(实际分了三个提交:`a3bb778` 主接线、`6f11f4c` 补 combat_test + 记时序、`729c749` 守卫移位 + cast_delay_mod 改开火时生效。) --- @@ -459,11 +591,11 @@ EOF > > **`inverse` 的 `hard` 语义与其它两种相反**(Task 1 实现者发现):`hybrid`/`add_int` 下 `hard=0` 表示「不钳制」,而 `inverse` 下 `hard` 是**下限**、`0` 表示「钳到 0」。设计器里编辑 `hard` 时应在提示文案中体现这个差异,否则设计师给 `inverse` 属性填 0 期待「不限制」会得到相反结果。 -- [ ] **Step 1: 实现 `attribute_tab.gd`** +- [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`: +Create `addons/game_designer/attribute_tab.gd`(**以下为实际落地版本**): ```gdscript ## 属性编辑器标签 — 玩家属性的 base / soft / hard / combine ## 数据:res://data/attributes.json(权威属性定义见 docs/design/numerical_design.md §1.1) @@ -480,6 +612,7 @@ 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")) @@ -489,28 +622,70 @@ func _ready() -> void: 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)))) - var sp_base := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("base", 0.0))) - var sp_soft := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("soft", 0.0))) - var sp_hard := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("hard", 0.0))) - var idx: int = COMBINE_KEYS.find(String(d.get("combine", "hybrid"))) + # 四个属性按定义均非负,故 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 下表示不钳制;inverse 的 hard 是**下限**;add_int 拒绝 pct 加成。" + 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) - _data = d if d is Dictionary else {} + _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 -## 表单 → 字典(合并式:以 _data 为基底保留未知键) +## 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: @@ -518,14 +693,18 @@ func _collect() -> Dictionary: if r.is_empty(): continue var entry: Dictionary = out.get(attr_id, {}).duplicate(true) - entry["base"] = float(r["base"].value) - entry["soft"] = float(r["soft"].value) - entry["hard"] = float(r["hard"].value) - entry["combine"] = COMBINE_KEYS[r["combine"].selected] # 存 key,不存双语显示串 + 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: @@ -534,15 +713,22 @@ func _save() -> void: > **为什么 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`(保存即覆盖原值)。凡静默改写必须留诊断。 -- [ ] **Step 2: 在 `designer_panel.gd` 注册分页** +- [x] **Step 2: 在 `designer_panel.gd` 注册分页** 在 `_init()` 的 `_add_tab` 序列末尾(现 `:19` 的「⚖ 平衡」之后)追加: ```gdscript _add_tab(tabs, "📊 属性", preload("res://addons/game_designer/attribute_tab.gd").new()) ``` -- [ ] **Step 3: 校验 + 往返实测** +- [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`,失败即真错误)。 @@ -560,20 +746,43 @@ var fails := 0 var bad := [] for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]: for f in ["base", "soft", "hard"]: - if abs(float(orig[k][f]) - float(out[k][f])) > 0.0001: - fails += 1; bad.append("%s.%s %s→%s" % [k, f, str(orig[k][f]), str(out[k][f])]) + # ⚠️ 逐位精确,不用容差。往返要防的量化漂移量级是 ~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: `ROUNDTRIP_FAILS=0 []` -> 注意 `0.01` 的 SpinBox 步长:本期四个属性的 base/soft/hard 全部落在 0.01 格点上(0/20/50、200/600/800、1.0/0.1/0.01、100/2000/0),故往返无量化损失。若日后加入非格点值,需先给 `UI.spin` 调步长。 +Expected(实测 2026-07-31):`ROUNDTRIP_FAILS=0 []` -- [ ] **Step 4: 提交** +> **📌 回填说明 —— 原计划这里写了一句假话,且断言够不着它** +> +> 原注释:「本期四个属性的 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 @@ -586,36 +795,77 @@ combine 下拉存 key 不存显示串、_save 合并式写回防丢键、UI 在 Co-Authored-By: Claude Opus 5 EOF ``` +(实际分了三个提交:`a0e205a` 主体、`36c4f79` 自动换行 + 拒绝写回 + 收紧 SpinBox 下限、`7884e43` 补最后两条诊断。) --- ### Task 5: 运行时验收 + 收尾 -**Files:** 无(运行时断言)+ `docs/design/numerical_design.md` + 路线图 +**Files:** 无(运行时断言)+ `docs/design/numerical_design.md` + 路线图 + 本计划 -- [ ] **Step 1: 启动战斗场景** +- [x] **Step 1: 启动战斗场景** -Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。`get_editor_errors` 应无本项目脚本相关报错。 +Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。 -- [ ] **Step 2: `cpu_limit` 生效 + 现有平衡零改动(验收 7、8)** +实测:开局即自动进入第 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) +``` -> `combat_manager` 的换杖入口是 `_CORE_ROSTER` 轮换(商店「法杖」按钮)。本步直接调 `PlayerStats` 与 `SpellEvaluator` 的公开状态验证,避免依赖 UI。 +- [x] **Step 1b: ⚠️ 先建立可信的运行时错误通道(否则后面所有"零报错"断言都是空的)** -Run: godot-mcp-pro `execute_game_script`: +**这一步是 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 := [] -# 直接模拟 _rebuild_wand 对 "core" 来源的处理,逐个法杖验证生效 cpu_limit 与 MAX_OPS for pair in [["wand_basic", 5], ["wand_fast", 3], ["matrix_board", 8]]: var core = WandPreset.make_core_by_id(pair[0]) PlayerStats.remove_modifiers_from("core") PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core") var eff: int = PlayerStats.cpu_limit - var max_ops: int = SpellEvaluator.MAX_OPS_PER_CPU * eff if eff != int(pair[1]): fails += 1; log.append("%s eff=%d want=%d" % [pair[0], eff, pair[1]]) - log.append("%s cpu=%d MAX_OPS=%d" % [pair[0], eff, max_ops]) -# 换杖不累积:连续换两把后应等于最后一把,而非累加 + 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") @@ -624,133 +874,172 @@ if PlayerStats.cpu_limit != 8: fails += 1; log.append("换杖累积 got=%d want=8" % PlayerStats.cpu_limit) _mcp_print("FAILS=%d %s" % [fails, str(log)]) ``` -Expected: `FAILS=0`,且日志含 `wand_basic cpu=5 MAX_OPS=200`(**验收 8:现有平衡零改动**)、`wand_fast cpu=3 MAX_OPS=120`、`matrix_board cpu=8 MAX_OPS=320`(**验收 7、9**)。 +实测:`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 条)"]` -- [ ] **Step 3: `move_speed` 数据驱动(验收 10)** +**② -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")`。 -Run: godot-mcp-pro `execute_game_script`: +做法:编一副 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 -var fails := 0 -var base_spd: float = PlayerStats.move_speed -if abs(base_spd - 200.0) > 0.001: fails += 1 # 基准来自 attributes.json -PlayerStats.add_modifier("move_speed", "pct", 0.5, "test") -if abs(PlayerStats.move_speed - 300.0) > 0.001: fails += 1 # 200 × 1.5 -PlayerStats.add_modifier("move_speed", "flat", 100.0, "test") -if abs(PlayerStats.move_speed - 450.0) > 0.001: fails += 1 # (200+100) × 1.5 PlayerStats.remove_modifiers_from("test") -if abs(PlayerStats.move_speed - 200.0) > 0.001: fails += 1 # 撤销后回到基准 -_mcp_print("FAILS=%d spd=%.1f" % [fails, PlayerStats.move_speed]) +# base=200 → +50%pct=300 → 再 +100flat=450 → 再 +200%pct 应被 hard(800) 钳住 → 撤销回 200 ``` -Expected: `FAILS=0 spd=200.0` +实测:`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。) -- [ ] **Step 4: `cast_delay_mod` 生效(验收 11)** +**③ -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 的可用性一并改没了。 -Run: godot-mcp-pro `execute_game_script`: ```gdscript -var pm = get_tree().root.find_child("PlayerManager", true, false) -if pm == null: pm = PlayerManager var core = WandPreset.make_core_by_id("wand_basic") # cast_interval 0.5 +var comp = SpellEvaluator.compile_wand(core, [SpellRegistry.get_spell("action_spark_bolt")]) PlayerStats.remove_modifiers_from("test") -PlayerStats.add_modifier("cast_delay_mod", "pct", 0.5, "test") # inverse: ×0.5 -pm.equip_wand(core, null) -var got: float = pm._cast_interval +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") -_mcp_print("FAILS=%d interval=%.4f want=0.2500" % [(0 if abs(got - 0.25) < 0.001 else 1), got]) ``` -Expected: `FAILS=0 interval=0.2500` -> 适配注:若 `equip_wand(core, null)` 因 `compiled` 为 null 报错,改传 `SpellEvaluator.compile_wand(core, [])` 的产物;核心断言是 `_cast_interval == core.cast_interval × cast_delay_mod`。 +实测:`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 5: 多来源互不干扰(验收 12)** +另有 Step 7b② 的扫描作为端到端佐证:`wand_basic` 在 `mod=1.0` 时实测帧距 31 帧,`mod=0.5` 时 16 帧 —— 实际施法周期确实减半(差 1 帧是整帧量化,见 7b②)。 + +- [x] **Step 5: 多来源互不干扰(验收 12)** -Run: godot-mcp-pro `execute_game_script`: ```gdscript -PlayerStats.remove_modifiers_from("core") -PlayerStats.remove_modifiers_from("shopC") PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core") -PlayerStats.add_modifier("cpu_limit", "flat", 3.0, "shopC") -var both: int = PlayerStats.cpu_limit # 8 -PlayerStats.remove_modifiers_from("core") -var only_shop: int = PlayerStats.cpu_limit # 3(他源仍在) -PlayerStats.remove_modifiers_from("shopC") -var none: int = PlayerStats.cpu_limit # 0 -var fails := (0 if both == 8 else 1) + (0 if only_shop == 3 else 1) + (0 if none == 0 else 1) -_mcp_print("FAILS=%d both=%d only_shop=%d none=%d" % [fails, both, only_shop, none]) +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 ``` -Expected: `FAILS=0 both=8 only_shop=3 none=0` +实测:`FAILS=0 both=8 only_shop=3 none=0 | 跨属性同源: cpu=4 spd=280.0 → 撤 shopC → cpu=0 spd=230.0(应 cpu=0 spd=230)` -- [ ] **Step 6: 孤儿字段零残留 + 存档往返(验收 13)** +- [x] **Step 5b: 评审改动的运行时回归(原计划没有,补四条)** -Run: godot-mcp-pro `stop_scene`,然后 `execute_editor_script`: -```gdscript -var fails := 0 -var hits := [] -for p in ["res://scripts/autoloads/player_stats.gd", "res://scripts/domain/player_manager.gd", - "res://scripts/domain/combat/combat_manager.gd", "res://scripts/domain/spell_system/spell_evaluator.gd"]: - var s := FileAccess.get_file_as_string(p) - if s.contains("PlayerStats.armor") or s.contains("PlayerStats.resistance"): fails += 1; hits.append(p) - if s.contains("var armor") or s.contains("var resistance"): fails += 1; hits.append(p + ":decl") - if s.contains("MOVE_SPEED"): fails += 1; hits.append(p + ":MOVE_SPEED") -# 存档不再含派生字段 -var ps := FileAccess.get_file_as_string("res://scripts/autoloads/player_stats.gd") -if ps.contains('"hp_max": hp_max') or ps.contains('"cpu_limit": cpu_limit'): fails += 1; hits.append("save_derived") -_mcp_print("FAILS=%d %s" % [fails, str(hits)]) ``` -Expected: `FAILS=0 []` -> 另跑全项目 grep 复核 `armor` / `resistance` 在 `scripts/` 下只剩 `EnemyManager` 的敌人侧用法(敌人 armor 与元素抗性是另一套,**不受本次影响**)。 - -- [ ] **Step 7: 订正权威文档 + 路线图** - -- `docs/design/numerical_design.md` §1.1:`move_speed` 基准 **300 → 200**,并在该行「说明」列注明:`⚠️ 2026-07-31 订正:原写 300 从未被任何代码读取;实际实现自 S0 起为 200,20 波内容与 Boss 弹幕密度均按此值调校,故以既成事实为准。见 docs_dev/specs/2026-07-31-player-attributes-design.md §2.2。` -- 同表 `cpu_limit` 行:删去或更新那条 `⚠️实现现状(2026-07-20):代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit(待修,本设计更优)` —— 改为已修复,并注明生效值 = 玩家(基准 0) + 法杖(`cores.json` 3–8)。 -- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`:E3 切分列表第 1 项「属性词条系统」勾除并标注**实际范围**(只做框架 + 三条死线,`attunement_×4`/`luck`/`recharge_speed_mod` 另行立项),格式对齐 E1 已完成项。同时订正 E3「现状」里「`player_stats.gd` 有 `resistance` 等占位属性(4/5 stats 未接线)」这句 —— 它与本次查明的事实不符(权威属性表是 11 个,`armor`/`resistance` 根本不在表内)。 - -```bash -git add docs/design/numerical_design.md docs_dev/plans/2026-07-23-missing-features-roadmap.md -git commit -F- <<'EOF' -docs: 订正 move_speed 权威值 300→200 与 cpu_limit 已修复;路线图 E3-① 勾除 - -move_speed 300 从未被任何代码读取,200 自 S0 沿用并已围绕它调校 20 波内容, -以既成事实为准。cpu_limit 的「从不读」告警随本次接线解除。 -路线图 E3 现状原写「4/5 stats 未接线」与事实不符——权威属性表是 11 个, -armor/resistance 根本不在表内,一并订正。 - -Co-Authored-By: Claude Opus 5 -EOF +非法加成后 _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` 会把两条一起重算)。 -- [ ] **Step 7b: 守卫零误报 + `cast_delay_mod` 饱和点(Task 3 复审转来)** +- [x] **Step 6: 孤儿字段零残留 + 存档往返(验收 13)** -**① 开一局,确认日志里零条 `cpu_limit=0` 报错。** -Task 3 把守卫从施法处搬到了 `PlayerManager.equip_wand`。复审静态核对了全部六条进入路径(`start_game` 58→76、`resume_game` 68→76、`switch_core` 139→140、`_apply_padded` 184→185、`apply_wand_save_data` 299 无 `equip_wand`、`combat_test.gd` 18-19→20),确认 `_rebuild_wand`/显式注入总在 `equip_wand` 之前。但**六条路径靠眼读弱于跑一局** —— 若守卫误报,症状看起来和它要报的故障一模一样,是自伤式回归。 +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 []` -Run: `play_scene` 开一局,`get_editor_errors` / `get_output_log` 确认**零条** `cpu_limit=0`。换一次杖(商店「法杖」按钮)后再确认一次。 +全项目 grep 复核:`armor` / `resistance` 在 `scripts/` 下只剩敌人侧用法 —— `damage_context.calc_damage(armor, resistance)` 的形参、`enemy_manager` 的 `_ARMOR` / `resistances`、设计器 `enemy_tab`。**玩家侧零残留**,敌人 armor 与元素抗性是另一套,不受本次影响。 -**② `cast_delay_mod` 的 `hard: 0.01` 承诺了循环交付不了的东西 —— 这是设计决策,需上报而非在代码里解决。** +- [x] **Step 7: 订正权威文档 + 路线图** -`_handle_auto_cast` 用 `if` 而非 `while`,故**每物理帧至多一次施法**,硬顶约 60 次/秒。于是该属性存在饱和点: -- `wand_basic`(0.5s 基准):`cast_delay_mod ≈ 0.033` 即饱和,从那里到 `hard` 0.01 之间**买不到任何东西**。 -- `wand_fast`(0.25s):饱和点约 0.067。 -- 且 `:81` 是**赋值**而非累积溢出,故实际速率按整帧量化 —— 60 / 30 / 20 / 15 次/秒,快端是台阶而非平滑。 +- `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 现状里同源的「玩家侧抗性仍占位」一句一并订正。 -设计区间(`soft` 0.1 → 0.05s = 3 帧)是诚实的,问题只在 soft→hard 那段。**两个选项,需人定**: -- (a) 保持 `hard: 0.01`,在 `numerical_design.md` 与 `attributes.json` 注释里写明「0.033 以下为饱和区,不再提升实际射速」; -- (b) 把 `hard` 提到 ~0.05,让 JSON 承诺的是真能交付的东西。 +提交:`1aeae19` -**不要在代码里加下限钳制** —— 下限已由 `attributes.json` + `AttributeFormula` 的 `maxf(hard, ...)` 强制,在 `player_manager` 再加一个会形成重复权威,设计师调 `hard` 时立刻脱同步,违反「纯数据驱动,代码不含硬编码副本或回退」。 +- [x] **Step 7b: 守卫零误报 + `cast_delay_mod` 饱和点(Task 3 复审转来)** -**③ 顺带收两条 Task 3 复审的 Minor**(可并入本步任一提交): -- `player_manager.gd:96` 注释里的 `player_stats.gd:173/191` 行号引用会腐烂,改为函数名:「已分别由 `PlayerStats._load_attr_definitions` 与 `add_modifier` 报出」。 -- `scenes/main/combat_test.gd:17-19` 的注入只有不变式没有理由,补一句 `# 顺序理由见 combat_manager._rebuild_wand`。 +**① 守卫零误报 —— 实测结论:零误报,且通道已证明是活的。** -- [ ] **Step 8: 回填本计划为实际执行版** +先决条件是 Step 1b(否则观测为空)。观测结果: -实现过程中若因评审而改动了代码或断言,**本计划里对应的代码块与脚本必须回填为实际落地的版本**,并勾上各任务的复选框。否则任何人按这份计划复跑,得到的是过时代码和可能已失效的断言 —— 归航那期就踩过这个坑。 +| 入口路径 | 观测 | 结果 | +| :-- | :-- | :-- | +| `start_game`(`play_scene` 开局) | Debugger 错误页 | 0 条 `cpu_limit=0`,`cpu_limit=5` | +| 商店「法杖」按钮 ×4(轮换覆盖全部 5 把杖) | 同上 | 0 条;抽样点 `wand_basic`=5、`wand_fast`=3、`matrix_board`=8,`_modifiers` 恒 1 条(`wand_memory`/`circuit_fork` 两次未逐个抽样 `cpu_limit` 值,但同样 0 条报错——守卫若被触发会留痕) | +| `combat_test.tscn`(独立会话) | 同上 | 0 条,`cpu_limit=5` | +| **正对照**:手动 `remove_modifiers_from("core")` 后调 `equip_wand` | 同上 | ✅ **报了 1 条** `player_manager.gd:98 @ equip_wand(): PlayerManager: 装备「小木法杯」后 cpu_limit=0…` | -已知必须回填的(Task 1 评审后代码有变): -- Task 1 Step 2 的实现代码块 → 加上 `pct` 越界守卫、`_COMBINE_NAMES`→`_COMBINE_BY_NAME`、`ri`→`floored`、`Dictionary[String, Combine]` 与 `for m: Dictionary` 的静态类型、以及 `hard` 语义差异的注释。 -- Task 1 Step 3 的夹具 → 改用工具坑 ② 的 `ResourceLoader.load(..., CACHE_MODE_IGNORE)`;`cases` 补上 ⑦/⑦b 两条越界断言(共 12 条);注意工具坑 ⑤(`var r: float =` 而非 `var r :=`)。 +正对照是这一步的关键:它证明守卫**是活的**,因此"其余路径 0 条"才有信息量。整场会话(约 13 分钟)的运行时报错逐条清点后**全部**能归因到本次的故意探针,无一条意外。 -其余任务同理:**以实际跑通的为准**,并在每处回填旁注明「原写法为何不可用」。 +**② `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 | +| | **0.05** | 0.0125 s | 1 帧 | **60 /s ← 饱和点** | +| | **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`,在 `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: 合并决策** @@ -760,19 +1049,23 @@ Run: `play_scene` 开一局,`get_editor_errors` / `get_output_log` 确认**零 ## 验收标准回溯(spec §4) -| # | 验收标准 | 覆盖 | 观测方式 | -| :- | :-- | :-- | :-- | -| 1 | `hybrid` 连乘 ×1.728 非 ×1.6 | Task 1 Step 3 ① | 纯函数断言 | -| 2 | `hybrid` 混合 (200+50)×1.2=300 | Task 1 Step 3 ② | 纯函数断言 | -| 3 | `inverse` 0.512 + 下限钳制 | Task 1 Step 3 ③③b | 纯函数断言 | -| 4 | `add_int` 拒 `pct`,`flat` 仍累加 | Task 1 Step 3 ④ | 纯函数断言 | -| 5 | `hard` 两方向 + `hard=0` 不钳制 | Task 1 Step 3 ⑤⑤b⑥c | 纯函数断言 | -| 6 | 空列表 → base;未知 `combine` 回退 | Task 1 Step 3 ⑥⑥b | 纯函数断言 | -| 7 | `cores.json` `cpu_limit` 生效 | Task 5 Step 2 | 运行时 | -| 8 | 现有平衡零改动(小木仍 200 步) | Task 5 Step 2 | 运行时 | -| 9 | 换杖不累积(5→8 得 8 非 13) | Task 5 Step 2 | 运行时 | -| 10 | `move_speed` 数据驱动 + 加成生效 | Task 5 Step 3 | 运行时 | -| 11 | `cast_delay_mod` 使间隔减半 | Task 5 Step 4 | 运行时 | -| 12 | `remove_modifiers_from` 不误删他源 | Task 5 Step 5 | 运行时 | -| 13 | 孤儿字段零残留 + 存档不含派生值 | Task 5 Step 6 | 源码断言 | -| 14 | 设计器往返一致 + `combine` 存 key | Task 4 Step 4 | 绕缓存实例化 | +> 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`」仅经代码审读,未在运行时构造缺文件场景** | ⚠️ 部分 |