[P0][CR-B3] 模拟非确定:Math.random、墙钟、DOM 事件直改状态、美术加载决定掉落时序 #523

Closed
opened 2026-09-29 06:43:56 +00:00 by troytt · 1 comment
Owner

来源:#516|优先级 P0|评审编号 B3|基线 e475c2e

问题描述

回放、锁步联机,以及"同种子同结果"的测试,都以确定性模拟为前提。但单机模拟里有多处非确定来源:

  • 随机数;
  • 墙钟;
  • 在 tick 之外修改状态的 DOM 事件;
  • 美术资源加载进度决定掉落时机。

同样的输入跑两遍,结果可能不同。

证据

  • 随机数:

  • DOM 事件直接改模拟:onPointerDown(mouse-controller.ts:570)直接调用 this.castSkill(...)(mouse-controller.ts:599)。既不经过 onTick,也不记录为输入帧。

  • 渲染决定模拟时序:

    1. stepPlayerAndMonsterAnimations 在 onTick 里运行(act-scene.ts:571)。
    2. 怪物是否 deathGated,取决于美术是否加载了死亡动画(world-renderer.ts:236)。
    3. 被 gate 的怪物,要等渲染回调触发后才进入 RNG 掉落管线(engine.ts:1417)。

    结果:掉落时机和 RNG 的调用顺序取决于资源加载进度。慢网络、缺图时的结果与正常情况不同。

  • 墙钟进入 tick:

  • 状态不在快照里:

  • 跨浏览器:规范只要求 Math.hypot、Math.atan2 等函数"实现近似",Chrome 和 Firefox 的结果可能在最低位不同。锁步对局会因此逐渐漂移。

根因

模拟没有边界。模拟代码可以直接读墙钟、调随机数、被 DOM 事件调用、被渲染器的回调驱动。这是 #526 的分层问题在时间维度上的表现。

修复指南

  1. 输入命令队列:DOM 事件只把命令推进队列,例如 {kind:'cast', skillId, target}、{kind:'pickup', id}。onTick 开头统一出队并应用。队列里的内容,就是回放和锁步要传输的输入帧。
  2. 随机数:
    • 模拟里只用引擎持有的 D2Rng 实例,按用途分流(loot、castRng 等),全部纳入快照。
    • 心灵传动的伤害和击退改用 castRng,数值来自 Skills.txt(见 #524)。
  3. 时间:
    • 模拟只用 sim time(tick,或 tick × 40ms)。
    • performance.now() 只允许出现在渲染插值里。
    • 地面物品 id 改用引擎计数器(#522)。
  4. 掉落与美术解耦:
    • 死亡到掉落的时机只能由模拟决定,并以 1.13c 为准。
    • 如果原版依赖死亡动画的帧数,帧数也要来自数据表(如 animdata),而不是"渲染器有没有加载到图"。
    • 渲染器只负责决定何时把已经存在的掉落显示出来。
  5. 计数器挂到引擎实例上:hydraGroupSeqCounter 这类模块级可变状态移到引擎上,并纳入快照。
  6. 跨浏览器(长期):
    • 坐标改为定点子格(与原版 D2 一致),三角函数改为查表。
    • 在此之前,至少不要把 Math.hypot / Math.atan2 的结果直接写进权威状态。
  7. 用工具固化:用 ESLint 的 no-restricted-properties / no-restricted-globals,在 src/game/**、src/scene/backend/**、src/net/** 中禁用 Math.random、Date.now 和 performance.now(#537)。
  8. 确定性测试:
    • 同种子、同输入序列跑两遍,其中一遍模拟"美术未加载",逐 tick 比较完整状态哈希。
    • 哈希要包含 groundItems、projectiles 和 castRng,可参考 #519 的哈希改进。
    • 现有唯一的确定性测试走的是键盘/DEMO 路径,覆盖不到上面这些(#535)。

验收标准

  • src/game/**、src/scene/backend/**、src/net/** 中没有 Math.random、Date.now、performance.now;对应的 ESLint 规则已开启并通过。
  • 新增确定性测试:同种子、同输入,在"美术已加载"和"美术未加载"两种情况下,1000 tick 后状态哈希一致。
  • 鼠标施法和拾取都经输入队列、在 onTick 中生效;回放一段录制好的输入序列,能得到相同的结果。
  • 快照包含 castRng 和所有计数器。"存档 → 读档 → 继续运行"的结果,与不读档直接运行的结果一致。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #522:确定性 id。
  • #520:引擎命令。
  • #518、#519:锁步联机。
  • #537:ESLint 规则。
  • #535:确定性测试的覆盖。
> 来源:#516|优先级 P0|评审编号 B3|基线 `e475c2e` ## 问题描述 回放、锁步联机,以及"同种子同结果"的测试,都以**确定性模拟**为前提。但单机模拟里有多处非确定来源: - 随机数; - 墙钟; - 在 tick 之外修改状态的 DOM 事件; - 美术资源加载进度决定掉落时机。 同样的输入跑两遍,结果可能不同。 ## 证据 - **随机数**: - 心灵传动的伤害与击退用的是 `Math.random`([skill-caster.ts:342](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L342)、[skill-caster.ts:347](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L347))。 - 其余的 `Math.random` 在 arena-only 路径或兜底默认参数里(如 [drop-pipeline.ts:1012](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/drop-pipeline.ts#L1012)、[drop-pipeline.ts:1465](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/drop-pipeline.ts#L1465)),同样应该清理。 - **DOM 事件直接改模拟**:`onPointerDown`([mouse-controller.ts:570](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L570))直接调用 `this.castSkill(...)`([mouse-controller.ts:599](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L599))。既不经过 `onTick`,也不记录为输入帧。 - **渲染决定模拟时序**: 1. `stepPlayerAndMonsterAnimations` 在 `onTick` 里运行([act-scene.ts:571](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L571))。 2. 怪物是否 `deathGated`,取决于美术是否加载了死亡动画([world-renderer.ts:236](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/world-renderer.ts#L236))。 3. 被 gate 的怪物,要等渲染回调触发后才进入 RNG 掉落管线([engine.ts:1417](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1417))。 结果:掉落时机和 RNG 的调用顺序取决于资源加载进度。慢网络、缺图时的结果与正常情况不同。 - **墙钟进入 tick**: - `updateBounces(performance.now(), …)`([world-renderer.ts:293](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/world-renderer.ts#L293)); - 地面物品 id 用 `Date.now()` 拼接([ground-items.ts:1372](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/ground-items.ts#L1372))。 - **状态不在快照里**: - 模块级计数器 `let hydraGroupSeqCounter = 0`([skill-caster.ts:42](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L42))跨引擎实例存活,也不进存档。 - `castRng` 不在快照里:[engine.ts:1178-1182](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1178-L1182) 只保存了 `loot`。 - **跨浏览器**:规范只要求 `Math.hypot`、`Math.atan2` 等函数"实现近似",Chrome 和 Firefox 的结果可能在最低位不同。锁步对局会因此逐渐漂移。 ## 根因 模拟没有边界。模拟代码可以直接读墙钟、调随机数、被 DOM 事件调用、被渲染器的回调驱动。这是 #526 的分层问题在时间维度上的表现。 ## 修复指南 1. **输入命令队列**:DOM 事件只把命令推进队列,例如 `{kind:'cast', skillId, target}`、`{kind:'pickup', id}`。`onTick` 开头统一出队并应用。队列里的内容,就是回放和锁步要传输的输入帧。 2. **随机数**: - 模拟里只用引擎持有的 `D2Rng` 实例,按用途分流(`loot`、`castRng` 等),全部纳入快照。 - 心灵传动的伤害和击退改用 `castRng`,数值来自 Skills.txt(见 #524)。 3. **时间**: - 模拟只用 sim time(`tick`,或 `tick × 40ms`)。 - `performance.now()` 只允许出现在渲染插值里。 - 地面物品 id 改用引擎计数器(#522)。 4. **掉落与美术解耦**: - 死亡到掉落的时机只能由模拟决定,并以 1.13c 为准。 - 如果原版依赖死亡动画的帧数,帧数也要来自数据表(如 animdata),而不是"渲染器有没有加载到图"。 - 渲染器只负责决定何时把已经存在的掉落显示出来。 5. **计数器挂到引擎实例上**:`hydraGroupSeqCounter` 这类模块级可变状态移到引擎上,并纳入快照。 6. **跨浏览器**(长期): - 坐标改为定点子格(与原版 D2 一致),三角函数改为查表。 - 在此之前,至少不要把 `Math.hypot` / `Math.atan2` 的结果直接写进权威状态。 7. **用工具固化**:用 ESLint 的 `no-restricted-properties` / `no-restricted-globals`,在 `src/game/**`、`src/scene/backend/**`、`src/net/**` 中禁用 `Math.random`、`Date.now` 和 `performance.now`(#537)。 8. **确定性测试**: - 同种子、同输入序列跑两遍,其中一遍模拟"美术未加载",逐 tick 比较完整状态哈希。 - 哈希要包含 groundItems、projectiles 和 `castRng`,可参考 #519 的哈希改进。 - 现有唯一的确定性测试走的是键盘/DEMO 路径,覆盖不到上面这些(#535)。 ## 验收标准 - [ ] `src/game/**`、`src/scene/backend/**`、`src/net/**` 中没有 `Math.random`、`Date.now`、`performance.now`;对应的 ESLint 规则已开启并通过。 - [ ] 新增确定性测试:同种子、同输入,在"美术已加载"和"美术未加载"两种情况下,1000 tick 后状态哈希一致。 - [ ] 鼠标施法和拾取都经输入队列、在 `onTick` 中生效;回放一段录制好的输入序列,能得到相同的结果。 - [ ] 快照包含 `castRng` 和所有计数器。"存档 → 读档 → 继续运行"的结果,与不读档直接运行的结果一致。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #522:确定性 id。 - #520:引擎命令。 - #518、#519:锁步联机。 - #537:ESLint 规则。 - #535:确定性测试的覆盖。
Author
Owner

已在提交 112c128(fix(sim): enforce deterministic seeded RNG and tick-aligned simulation (#523))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:清除模拟/战斗/技能/物品路径中的所有 Math.random()、Date.now() 与 performance.now(),统一使用实例级种子 D2Rng/Rng 与 tick 时钟(随 GameSnapshot 完整序列化),将 DOM 指针输入入队到 onTick 边界执行,并将怪物死亡掉落 RNG 与异步美术加载解耦,确保相同种子与输入序列逐 tick computeStateHash() 完全一致。
  • 专项回归测试:tests/p0-523-determinism.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`112c128`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/112c12808b41fddfcb2477d7b436c74b79180aa3)(`fix(sim): enforce deterministic seeded RNG and tick-aligned simulation (#523)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:清除模拟/战斗/技能/物品路径中的所有 `Math.random()`、`Date.now()` 与 `performance.now()`,统一使用实例级种子 `D2Rng`/`Rng` 与 tick 时钟(随 `GameSnapshot` 完整序列化),将 DOM 指针输入入队到 `onTick` 边界执行,并将怪物死亡掉落 RNG 与异步美术加载解耦,确保相同种子与输入序列逐 tick `computeStateHash()` 完全一致。 - **专项回归测试**:[`tests/p0-523-determinism.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-523-determinism.test.ts) - **总体验收门禁**:`npm run build` 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#523
No description provided.