docs: 计划回填实际执行的验收脚本 + 订正权威文档自相矛盾处 + 性能数字改区间

【计划 Task 4】原稿四个脚本有缺陷,照它复跑会得到假失败与空通过,现已替换为
实际执行并通过的版本,每处旁注「原脚本为何有缺陷」,并勾上完成的复选框:
- Step 2 敌人摆位 (400,200) 距原点 447px 已出 homing_range=400,实测 FAILS=1;
  这是测试摆位错误而非实现缺陷(精确距离过滤行为正确)。改到 (300,150)。
- Step 6 内联复刻了折叠逻辑与商店谓词——在测自己;改调真实 _apply_modifier
  与 ShopManager._available_pool(),另补 compile_wand→execute_compiled 端到端。
- Step 7 无墙钟起搏,40 帧全停在退避窗内、守卫沦为空断言;且几何让子弹直穿
  敌人簇,多计约 1.5ms 碰撞开销。改为 60fps 起搏 + spec §5.2 环形摆位,
  frame1 与稳态峰值分开报。
- Step 8 只对源码 grep 字符串,不是往返;改为真实实例化 spell_tab 对真实
  spells.json 做 8 条 type-1 的回读→写回比对。
回溯表补「观测方式」列,标出⑥是唯一一条状态推断而非直接观测。

【architecture_design.md】同一文档内自相矛盾:
- CastStats 块 homing_force(「归航强度,向最近敌人偏转」)与 §4.2 新写的
  「最大转向角速度 + 锁定式目标」冲突 → 订正为 homing_add(弧度/秒);
  顺带订正确证过的 pierce_add / bounce_add(原写 *_count)。该块其余字段
  仍有既有漂移,加 ⚠️ 注明未核对、以 cast_stats.gd 为准,不扩大改动范围。
- ProjectileDef 的 homing_force 删除——projectile_def.gd 无任何 homing 字段,
  归航冷数据由 _push_projectile 直接写入 BulletManager。
- §4.2 bounce 协同散文补上实际存在的 if cold.has("homing_strength") 前置守卫。
- get_nearest_pos 代码片段:_enemy_count → _active_count(前者不存在)、
  未命中返回值 Vector2.ZERO → origin、删除不存在的 _visible_flags 过滤。

【spec】
- §4 第⑥条措辞「不触发 query_circle」字面为假(_check_collision 每帧无条件
  发一次),改为「不因归航触发」,并写明这是唯一一条间接验证及其封闭性论证。
- §5.5 峰值改为区间表述(① 4~5ms / ② 5~7ms @1500),注明编辑器/调试构建、
  运行间离散(同场景三次得 5.61/5.09/7.29ms),判定回归看是否出现周期性复发
  尖峰与是否越过 16.67ms,勿拿单值比对;并记录几何对测量的影响。
- §5.4 勘误:「1493 个互异到期值」不可能成立(jitter 只能产出约 200 个整数值,
  同帧失败又共用同一 now),复测为跨度 223ms / 222 个互异值,结论不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:33:16 +08:00
co-authored by Claude Opus 5
parent 4b09cd1064
commit 3e45ef0059
3 changed files with 399 additions and 139 deletions
@@ -171,7 +171,16 @@ MODIFIER`type 1`)的 meta schema 加:
3. **速率守恒**:转向前后 `Vector2(vx,vy).length()` 不变。
4. **最短转向**:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证 `wrapf`)。
5. **目标失效重选**:锁定目标死亡后,下一帧 `homing_target_id` 变为另一存活敌人。
6. **空场直行**:场上无敌人时子弹保持直线,且触发 `query_circle`
6. **空场直行**:场上无敌人时子弹保持直线,且**不因归航**触发 `query_circle`
> **措辞修正(2026-07-30 验收期)**:原写「不触发 `query_circle`」,字面为假 —— `_check_collision``bullet_manager.gd:69`)每帧对每颗子弹**无条件**发一次 `query_circle(pos, radius+16)`,与归航无关。被验证的命题是「`_apply_homing` 这条路径不发查询」。
>
> **⚠️ 本条是全部 10 条验收里唯一的间接验证**,值得留痕。无法给 autoload 的方法挂调用计数器,故改用状态判据:空场跑 6 帧后断言 `homing_target_id` 未变且冷数据无 `homing_retry_at` 键。其封闭性论证 ——
> 1. `_apply_homing` 内通往 `SpatialGrid` 的路径**有且仅有**一处 `_find_nearest_unvisited`,无其它 `SpatialGrid` 访问;
> 2. 该调用之后必然二选一:成功则写 `homing_target_id`(变为 ≥0 的新值),失败则写 `homing_retry_at`
> 3. 两者皆未发生 ⟹ 该调用未被执行 ⟹ 归航未发查询;
> 4. 零敌人时 `_check_collision` 在 `:71``hits.is_empty()`)即返回,bounce 路径不可达,不会与之混淆。
>
> 论证对被限定后的命题成立,但**它是状态推断而非对调用次数的直接观测** —— 如实标注,不宣称为直接测量。
7. **bounce 协同**`homing + bounce` 子弹弹跳后 `homing_target_id == ` bounce 选中的 `next_id`
8. **修饰器折叠**:两层 `modifier_homing``ctx.stats.homing_add == 6.0`
9. **商店与设计器**`modifier_homing` 可在商店买到;设计器 `homing`/`bounce` 字段回读往返一致(对真实 `data/spells.json`)。
@@ -239,19 +248,25 @@ MODIFIER`type 1`)的 meta schema 加:
到期时刻均匀铺满 `[200, 400)` ms ≈ 恰好一个退避周期 ≈ 12 帧。**用「`bullet_idx` 在活跃弹数中的占比」而非 `bullet_idx % HOMING_RETRY_MS`**:后者在弹数少于窗口宽度时只能铺开 `_active_count` 毫秒(20 颗弹 → 20 ms ≈ 1.2 帧,等于没打散),占比式则不论弹数多少都铺满整窗。`HOMING_RETRY_MS` 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。
实测打散质量:1500 颗同帧集体失败 → 到期偏移 200~415 ms、1493 个互异值、覆盖 14 帧、单帧最多 120 颗(理想 125)。
实测打散质量2026-07-30 验收期复测):1500 颗同帧集体失败 → 到期时刻**跨度 223 ms**(≈ 恰好一个退避周期,即 13~14 帧)、**222 个互异毫秒值**,平均每毫秒约 6.8 颗、每帧约 110 颗(理想 500/12 = 125)。
> **勘误**:本节初稿写「1493 个互异值」,该数**不可能成立** —— `jitter = (bullet_idx * 200) / _active_count` 对 1500 颗弹只能产出 0~199 共约 200 个整数值,同帧失败的子弹又共用同一个 `now`,故互异到期时刻上限就是 ~200 个。复测得 222,多出的 22 来自「遍历这 1500 颗本身耗时约 23 ms,期间 `now` 在推进」(与 §5.6 记录的首帧 23 ms 吻合)。**打散结论不变**(跨度铺满一个完整退避周期即消峰所需性质),只是原先那个数字是错的。
### 5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧)
| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值 | 稳态超 16.67ms 帧数 |
| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值(区间) | 稳态超 16.67ms 帧数 |
| :-- | --: | --: | --: |
| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4.40 ms** | 0/39 |
| ① 全射程外 · 300 | 3.2 ms | **0.92 ms** | 0/39 |
| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5.61 ms** | 0/39 |
| ② 全 visited · 300 | 4.6 ms | **1.15 ms** | 0/39 |
| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4 ~ 5 ms** | 0/39 |
| ① 全射程外 · 300 | 3.2 ms | **约 1 ms** | 0/39 |
| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5 ~ 7 ms** | 0/39 |
| ② 全 visited · 300 | 4.6 ms | **1 ~ 2.5 ms** | 0/39 |
复发尖峰彻底消除,稳态全部回到帧预算内。
> **⚠️ 数字按区间读,不要按单值读**:以上均为**编辑器/调试构建**实测,运行间存在明显离散。同一场景 ②·1500 三次独立测量分别得 **5.61 / 5.09 / 7.29 ms**240 帧长跑收敛在 5–7 ms 带内、无发散。写成单值会显得比数据本身更精确 —— 结论(稳态远低于 16.67 ms 帧预算、无复发尖峰)不受此离散影响,但**不要引用单个数字做后续推算**,也不要因复跑得到带内的不同值就判定为回归。判定回归请看:是否出现周期性复发尖峰、是否有帧越过 16.67 ms。
>
> 另注:稳态峰值对**测试几何**敏感。若子弹航线穿过敌人簇,`_check_collision` 的 `query_circle` + `visited_targets` 线性扫描会额外贡献 1~1.5 ms,那部分**不是归航成本**(验收期曾因此误读一次)。上表摆位为「敌人置于环上、子弹簇于原点」,刻意让子弹不穿过敌人簇。
### 5.6 残余:初次锁定帧 —— **已决策:接受,不处理**
抖动后仍有**一个**高耗时帧 —— 第 1 帧,1500 弹 `far` 16.2 ms / `visited` 22.0 ms。它**不是**退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 `query_circle` 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。