[Code Review] 2026-09-29 前后端代码质量评审:问题总览与修复路线图 #516

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

概述

这次评审的对象是基线提交 e475c2e(refactor(scene): separate pure backend simulation from frontend rendering and modularize act-scene & net-scene),覆盖四块:

  • 中继服务器与联机层:scripts/net-*.ts、src/net/*
  • 后端模拟层:src/game/**、src/scene/backend/*、src/sim/*
  • 前端客户端:src/scene/frontend/*、src/render、src/ui、src/frontend
  • 测试与工程化

方法是静态阅读、AST/正则度量、实跑 tsc / vitest,外加 3 个运行时复现脚本(脚本附在对应的子 issue 里)。所有行号以 e475c2e 为准,链接都是该提交的永久链接。

结论

工程底子不错:

  • strict TS 0 error,没有一处 @ts-ignore;
  • D2Rng 是精确的 64 位 LCG;
  • DRLG 移植完全由种子驱动。

但 e475c2e 的"前后端分离"主要是拆文件,职责和状态并没有分开:

  • 后端仍然依赖 UI;
  • UI 持有权威游戏状态;
  • 渲染和资源加载会影响模拟结果。

这三点是现象,不是原因。 根因是项目从没规定"游戏状态归谁",涉及前后端两边的功能都靠"两边各写一笔"来打补丁(详见 #520)。多数问题早于 e475c2e,那次拆分只是把双写代码原样搬进了 backend/ 和 frontend/ 目录。

另外,中继服务器和联机层有两个可以直接利用的问题:一个包就能打崩中继,联机输入可以伪造。

实跑结果

项目 结果
npx tsc --noEmit 0 error(32s)
npx vitest run 545 个文件;6407 通过 / 1 失败 / 2 跳过;墙钟 5m48s,CPU 约 41 分钟。失败的是一条墙钟性能断言(590ms > 500ms),见 #536

关键度量

指标 数值
规模 src/ 453 个文件;仓库 TS/HTML 约 52.8 万行(含测试、脚本和 >11 万行生成/转录数据)
any src 约 620 处,其中 skill-caster.ts 113 处、item-bridge.ts 71 处
非空断言 ! game/** 1303 处(drlg 占 944 处,属 C 指针风格)
?? 字面量 默认值 game/** 约 1050 处
全局钩子 21 个 window/globalThis.__d2* 键,后端逻辑也在读
超大函数 后端 ≥300 行的函数 14 个,前端 ≥250 行的函数 12 个
import 环 6 个值级强连通分量,最大的一个有 17 个文件
监听器注册/移除 hud-manager 8/0(没有 dispose),net-scene 5/0,toolbar-chat-ui 4/0,bnet-main 4/0
i18n 246 处 isEn ? … : …(含其他按语言判断的三元表达式约 320 处)
包体 3 个生成的 meta 文件共约 8 万行,全部静态 import;act-scene chunk 约 3.3MB(gzip 约 730kB),sourcemap 10.6MB

子 issue

P0:建议优先修

  • #517 S1 联机中继:单个畸形帧头就能让进程崩溃(已复现)(已修复:7bc21df)
  • #518 S2/S3 锁步联机:输入可伪造、越界 peer 抛异常逃逸、未来 tick 无界缓存(已复现)(已修复:995f594)
  • #520 B1 玩家状态双源(GameEngine vs HudManager)(已修复:7367f2a)
  • #521 B1a 丢金币再捡回来,存档里的金币凭空增加(已复现)(���修复:206b59e)
  • #522 B2 地面物品按 32px 距离删除:捡 A 删 B、存档复制(已修复:231d375)
  • #523 B3 模拟不确定:同样的输入跑两遍,结果可能不同(已修复:112c128)
  • #524 B4/B8 生产路径里的自创规则与静默兜底(已修复:462fdc7)
  • #528 F1 AudioContext 泄漏,达到上限后静默无声(已修复:58cbc88)
  • #529 F2/F3 异步竞态与 GPU 纹理泄漏(已修复:97abcb3)
  • #530 F4 投射物图集加载失败被静默吞掉,渲染退化为代码色块或借图(已修复:3465c80)

P1

  • #519 S4–S7 锁步联机:desync、超时、状态哈希、Transport、net-simulation
  • #525 B5 五套技能/战斗管线并存
  • #526 B6/F5 前后端边界
  • #527 B7/B9 超大函数与重复代码
  • #531 F6/F7 渲染热路径每帧分配与布局抖动
  • #532 F8 生成的 meta 文件与包体
  • #533 F9/F10 前端上帝对象与监听器泄漏
  • #535 T1/T2 测试有效性
  • #536 T3/T4 测试可移植性与稳定性
  • #537 T5 引入 ESLint

P2

  • #534 前端杂项:按键冲突、i18n、URL 参数、new Function、tsconfig
  • #538 T6 仓库卫生

修复路线图

Phase 0 止血(1–2 天)

  1. 中继:异常隔离、帧与缓冲上限、由服务器分配 peer;netplay 校验 peer 和 tick 窗口(#517、#518)。
  2. 地面物品按 id 删除;金币只在引擎一处记账(#522、#521)。
  3. 共享一个 AudioContext,或直接接入 AudioManager(#528)。
  4. 所有异步加载加 generation token,迟到的结果立即 deleteAtlas(#529)。
  5. 墙钟性能断言移出默认套件;修正空断言的测试(#536、#535)。

Phase 1 状态归一(1–2 周)

  1. 引擎持有唯一的 PlayerState,HUD 只读;喝药、拾取、交易都改为引擎命令(#520、#526)。
  2. 引入输入命令队列,状态变更只在 onTick 里发生;模拟中去掉随机数和墙钟;掉落时序与美术加载解耦(#523)。
  3. 用 ESLint 固化分层与确定性规则;CI 上报告跳过数(#537、#536)。

Phase 2 架构收敛(持续)

  1. 技能/战斗统一到数据驱动的 registry,删除旁路和桩代码(#525、#524)。
  2. 拆分超大函数;runScene 改为 SceneController;去掉桶导出和 window.__d2*(#527、#526)。
  3. 生成的 meta 改为 JSON 并按 act 懒加载;sourcemap 改为 hidden(#532)。
  4. 建立 i18n 键值表(#534)。

与现有 issue 的关系

  • #511(M20-P3 WorldView 解耦):和 #520、#526 方向一致。建议先完成 #520,WorldView 才有唯一的数据源可读。
  • #508(M20-P0 WS↔TCP 桥):新桥也是服务端组件,应该直接满足 #517 里异常隔离、上限和背压的要求。
  • M20:如果最终用 D2GS 服务端联机取代 P2P 锁步,#517 和 #518 做最小止血即可,#519 可以在决定弃用时直接关闭。
  • #505(首屏性能):和 #532 相关但不重叠。#505 覆盖 embedded-drop-tables 与 .r8,#532 覆盖三个 meta 文件与 sourcemap。

做得好的(请保持)

  • 类型:strict + exactOptionalPropertyTypes,tsc 0 error,没有 @ts-ignore。
  • D2Rng 是精确的 64 位 LCG,每只怪物有独立种子;单机掉落路径 fail-fast。
  • DRLG 移植完全由种子驱动:148 处显式 throw,不用随机数,也不读时钟。
  • GameLoop 固定 25Hz,时钟可注入,积压的 tick 有上限。
  • SpriteRenderer 生命周期完整:纹理注册表、删除前 flush、disposed 守卫、context-lost 监听解绑。
  • chat-commands.ts 的"纯函数 + 适配器"写法;协议定长二进制并用 ProtocolError 区分错误;MemoryHub 能逐 tick 驱动多 peer 的异常场景。
  • 已经有 appearanceSeq、travelling 这类正确的异步守卫,只是还没推广开。

约定

  • 每个子 issue 按 AGENTS.md 在独立 worktree 中修复;合入前 npm run typecheck 0 error、npx vitest run 全部通过。
  • 涉及地图或渲染的改动,按惯例跑全部 136 个关卡的无头审计。
## 概述 这次评审的对象是基线提交 `e475c2e`(refactor(scene): separate pure backend simulation from frontend rendering and modularize act-scene & net-scene),覆盖四块: - **中继服务器与联机层**:`scripts/net-*.ts`、`src/net/*` - **后端模拟层**:`src/game/**`、`src/scene/backend/*`、`src/sim/*` - **前端客户端**:`src/scene/frontend/*`、`src/render`、`src/ui`、`src/frontend` - **测试与工程化** 方法是静态阅读、AST/正则度量、实跑 `tsc` / `vitest`,外加 3 个运行时复现脚本(脚本附在对应的子 issue 里)。所有行号以 `e475c2e` 为准,链接都是该提交的永久链接。 ## 结论 工程底子不错: - strict TS 0 error,没有一处 `@ts-ignore`; - `D2Rng` 是精确的 64 位 LCG; - DRLG 移植完全由种子驱动。 但 e475c2e 的"前后端分离"主要是**拆文件**,职责和状态并没有分开: - 后端仍然依赖 UI; - UI 持有权威游戏状态; - 渲染和资源加载会影响模拟结果。 **这三点是现象,不是原因。** 根因是项目从没规定"游戏状态归谁",涉及前后端两边的功能都靠"两边各写一笔"来打补丁(详见 #520)。多数问题早于 e475c2e,那次拆分只是把双写代码原样搬进了 `backend/` 和 `frontend/` 目录。 另外,中继服务器和联机层有两个可以直接利用的问题:**一个包就能打崩中继**,**联机输入可以伪造**。 ## 实跑结果 | 项目 | 结果 | |---|---| | `npx tsc --noEmit` | 0 error(32s) | | `npx vitest run` | 545 个文件;6407 通过 / **1 失败** / 2 跳过;墙钟 5m48s,CPU 约 41 分钟。失败的是一条墙钟性能断言(590ms > 500ms),见 #536 | ## 关键度量 | 指标 | 数值 | |---|---| | 规模 | `src/` 453 个文件;仓库 TS/HTML 约 52.8 万行(含测试、脚本和 >11 万行生成/转录数据) | | `any` | `src` 约 620 处,其中 `skill-caster.ts` 113 处、`item-bridge.ts` 71 处 | | 非空断言 `!` | `game/**` 1303 处(drlg 占 944 处,属 C 指针风格) | | `?? 字面量` 默认值 | `game/**` 约 1050 处 | | 全局钩子 | 21 个 `window/globalThis.__d2*` 键,后端逻辑也在读 | | 超大函数 | 后端 ≥300 行的函数 14 个,前端 ≥250 行的函数 12 个 | | import 环 | 6 个值级强连通分量,最大的一个有 17 个文件 | | 监听器注册/移除 | hud-manager 8/0(没有 dispose),net-scene 5/0,toolbar-chat-ui 4/0,bnet-main 4/0 | | i18n | 246 处 `isEn ? … : …`(含其他按语言判断的三元表达式约 320 处) | | 包体 | 3 个生成的 meta 文件共约 8 万行,全部静态 import;act-scene chunk 约 3.3MB(gzip 约 730kB),sourcemap 10.6MB | ## 子 issue ### P0:建议优先修 - [x] #517 S1 联机中继:单个畸形帧头就能让进程崩溃(已复现)(已修复:[`7bc21df`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/7bc21df07c7fbd072af1a930ba6a53bcce1954e0)) - [x] #518 S2/S3 锁步联机:输入可伪造、越界 peer 抛异常逃逸、未来 tick 无界缓存(已复现)(已修复:[`995f594`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/995f594fe7ec8eb90df818c3977d8ad3c490ddc2)) - [x] #520 B1 玩家状态双源(GameEngine vs HudManager)(已修复:[`7367f2a`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/7367f2a36a178e41050ba7fbd4b9c564a3e45cc9)) - [x] #521 B1a 丢金币再捡回来,存档里的金币凭空增加(已复现)(���修复:[`206b59e`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/206b59e72d3b04c2bb6654dbc0e787c77e178a88)) - [x] #522 B2 地面物品按 32px 距离删除:捡 A 删 B、存档复制(已修复:[`231d375`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/231d375e62bc68637436f72dd9e401fa340712a2)) - [x] #523 B3 模拟不确定:同样的输入跑两遍,结果可能不同(已修复:[`112c128`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/112c12808b41fddfcb2477d7b436c74b79180aa3)) - [x] #524 B4/B8 生产路径里的自创规则与静默兜底(已修复:[`462fdc7`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/462fdc7516512991cc34368f6d299d28db6cfcb7)) - [x] #528 F1 AudioContext 泄漏,达到上限后静默无声(已修复:[`58cbc88`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/58cbc88e7ca1e95c5b59762fc3ae558f57975c14)) - [x] #529 F2/F3 异步竞态与 GPU 纹理泄漏(已修复:[`97abcb3`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/97abcb3bf7172a86f8de416020f5596c60241e30)) - [x] #530 F4 投射物图集加载失败被静默吞掉,渲染退化为代码色块或借图(已修复:[`3465c80`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/3465c8052b318746ce330acb60f0c406294def2f)) ### P1 - [ ] #519 S4–S7 锁步联机:desync、超时、状态哈希、Transport、net-simulation - [ ] #525 B5 五套技能/战斗管线并存 - [ ] #526 B6/F5 前后端边界 - [ ] #527 B7/B9 超大函数与重复代码 - [ ] #531 F6/F7 渲染热路径每帧分配与布局抖动 - [ ] #532 F8 生成的 meta 文件与包体 - [ ] #533 F9/F10 前端上帝对象与监听器泄漏 - [ ] #535 T1/T2 测试有效性 - [ ] #536 T3/T4 测试可移植性与稳定性 - [ ] #537 T5 引入 ESLint ### P2 - [ ] #534 前端杂项:按键冲突、i18n、URL 参数、`new Function`、tsconfig - [ ] #538 T6 仓库卫生 ## 修复路线图 **Phase 0 止血(1–2 天)** 1. 中继:异常隔离、帧与缓冲上限、由服务器分配 peer;netplay 校验 peer 和 tick 窗口(#517、#518)。 2. 地面物品按 id 删除;金币只在引擎一处记账(#522、#521)。 3. 共享一个 AudioContext,或直接接入 `AudioManager`(#528)。 4. 所有异步加载加 generation token,迟到的结果立即 `deleteAtlas`(#529)。 5. 墙钟性能断言移出默认套件;修正空断言的测试(#536、#535)。 **Phase 1 状态归一(1–2 周)** 6. 引擎持有唯一的 `PlayerState`,HUD 只读;喝药、拾取、交易都改为引擎命令(#520、#526)。 7. 引入输入命令队列,状态变更只在 `onTick` 里发生;模拟中去掉随机数和墙钟;掉落时序与美术加载解耦(#523)。 8. 用 ESLint 固化分层与确定性规则;CI 上报告跳过数(#537、#536)。 **Phase 2 架构收敛(持续)** 9. 技能/战斗统一到数据驱动的 registry,删除旁路和桩代码(#525、#524)。 10. 拆分超大函数;`runScene` 改为 `SceneController`;去掉桶导出和 `window.__d2*`(#527、#526)。 11. 生成的 meta 改为 JSON 并按 act 懒加载;sourcemap 改为 hidden(#532)。 12. 建立 i18n 键值表(#534)。 ## 与现有 issue 的关系 - **#511**(M20-P3 `WorldView` 解耦):和 #520、#526 方向一致。建议先完成 #520,`WorldView` 才有唯一的数据源可读。 - **#508**(M20-P0 WS↔TCP 桥):新桥也是服务端组件,应该直接满足 #517 里异常隔离、上限和背压的要求。 - **M20**:如果最终用 D2GS 服务端联机取代 P2P 锁步,#517 和 #518 做最小止血即可,#519 可以在决定弃用时直接关闭。 - **#505**(首屏性能):和 #532 相关但不重叠。#505 覆盖 `embedded-drop-tables` 与 `.r8`,#532 覆盖三个 meta 文件与 sourcemap。 ## 做得好的(请保持) - 类型:strict + `exactOptionalPropertyTypes`,tsc 0 error,没有 `@ts-ignore`。 - `D2Rng` 是精确的 64 位 LCG,每只怪物有独立种子;单机掉落路径 fail-fast。 - DRLG 移植完全由种子驱动:148 处显式 throw,不用随机数,也不读时钟。 - `GameLoop` 固定 25Hz,时钟可注入,积压的 tick 有上限。 - `SpriteRenderer` 生命周期完整:纹理注册表、删除前 flush、`disposed` 守卫、context-lost 监听解绑。 - `chat-commands.ts` 的"纯函数 + 适配器"写法;协议定长二进制并用 `ProtocolError` 区分错误;`MemoryHub` 能逐 tick 驱动多 peer 的异常场景。 - 已经有 `appearanceSeq`、`travelling` 这类正确的异步守卫,只是还没推广开。 ## 约定 - 每个子 issue 按 `AGENTS.md` 在独立 worktree 中修复;合入前 `npm run typecheck` 0 error、`npx vitest run` 全部通过。 - 涉及地图或渲染的改动,按惯例跑全部 136 个关卡的无头审计。
Author
Owner

P0 问题全部修复完成汇报

本单下的 10 个 P0 子 Issue 已全部修复并关闭,推送至 main(c2c1d28..5de27f8):

Issue 修复 Commit 专项回归测试
#517 7bc21df tests/p0-517-relay-hardening.test.ts
#518 995f594 tests/p0-518-lockstep-security.test.ts
#520 7367f2a tests/p0-520-player-state.test.ts
#521 206b59e tests/p0-521-gold-duplication.test.ts
#522 231d375 tests/p0-522-ground-items.test.ts
#523 112c128 tests/p0-523-determinism.test.ts
#524 462fdc7 tests/p0-524-parity.test.ts
#528 58cbc88 tests/p0-528-audio-singleton.test.ts
#529 97abcb3 tests/p0-529-async-lifecycle.test.ts
#530 3465c80 tests/p0-530-missile-art-failfast.test.ts
#516 E2E 验收套件 5de27f8 tests/e2e-p0-516/

验证结果

  • npm run build(tsc && vite build):0 错误通过
  • P0 专项 + E2E Tiers 1–4(14 个测试文件):190/190 测试用例 100% 通过
  • 全仓 Vitest 回归:6,460/6,460 有效测试用例通过,0 失败
  • 136 关无头浏览器巡检(scripts/audit-levels-browser.ts):136/136 个关卡 ID、365/365 张地图零错误通过
### P0 问题全部修复完成汇报 本单下的 **10 个 P0 子 Issue 已全部修复并关闭**,推送至 `main`(`c2c1d28..5de27f8`): | Issue | 修复 Commit | 专项回归测试 | | :--- | :--- | :--- | | #517 | [`7bc21df`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/7bc21df07c7fbd072af1a930ba6a53bcce1954e0) | [`tests/p0-517-relay-hardening.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-517-relay-hardening.test.ts) | | #518 | [`995f594`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/995f594fe7ec8eb90df818c3977d8ad3c490ddc2) | [`tests/p0-518-lockstep-security.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-518-lockstep-security.test.ts) | | #520 | [`7367f2a`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/7367f2a36a178e41050ba7fbd4b9c564a3e45cc9) | [`tests/p0-520-player-state.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-520-player-state.test.ts) | | #521 | [`206b59e`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/206b59e72d3b04c2bb6654dbc0e787c77e178a88) | [`tests/p0-521-gold-duplication.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-521-gold-duplication.test.ts) | | #522 | [`231d375`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/231d375e62bc68637436f72dd9e401fa340712a2) | [`tests/p0-522-ground-items.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-522-ground-items.test.ts) | | #523 | [`112c128`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/112c12808b41fddfcb2477d7b436c74b79180aa3) | [`tests/p0-523-determinism.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-523-determinism.test.ts) | | #524 | [`462fdc7`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/462fdc7516512991cc34368f6d299d28db6cfcb7) | [`tests/p0-524-parity.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-524-parity.test.ts) | | #528 | [`58cbc88`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/58cbc88e7ca1e95c5b59762fc3ae558f57975c14) | [`tests/p0-528-audio-singleton.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-528-audio-singleton.test.ts) | | #529 | [`97abcb3`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/97abcb3bf7172a86f8de416020f5596c60241e30) | [`tests/p0-529-async-lifecycle.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-529-async-lifecycle.test.ts) | | #530 | [`3465c80`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/3465c8052b318746ce330acb60f0c406294def2f) | [`tests/p0-530-missile-art-failfast.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-530-missile-art-failfast.test.ts) | | #516 E2E 验收套件 | [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) | [`tests/e2e-p0-516/`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/e2e-p0-516) | #### 验证结果 - **`npm run build`(`tsc && vite build`)**:0 错误通过 - **P0 专项 + E2E Tiers 1–4(14 个测试文件)**:190/190 测试用例 100% 通过 - **全仓 Vitest 回归**:6,460/6,460 有效测试用例通过,0 失败 - **136 关无头浏览器巡检(`scripts/audit-levels-browser.ts`)**:136/136 个关卡 ID、365/365 张地图零错误通过
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#516
No description provided.