From c7f891068390d1ac9ab3b2604a444be4eb3bbf82 Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Fri, 8 May 2026 00:18:24 +0800 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20=E4=BF=AE=E8=AE=A2=20blockData=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=8C=E8=81=9A=E7=84=A6=20runtime=20?= =?UTF-8?q?=E5=86=85=E5=AD=98=E6=9D=83=E5=A8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - 删除 MemoryWorldStore,以 WorldBlockDataStore 作为唯一世界级 blockData 权威 - Chunk.blockData 降级为 chunk 运行时视图,不再是第二权威 - 推迟 IndexedDB / 导入导出 / 冷存储到后续阶段 - 新增写入口清单(Section 8)和 clone 边界约束(Section 9) - 新增测试迁移矩阵(Section 11) - 计划新增 Task 6 收紧 clone/序列化边界,Task 7 推迟冷存储 Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 447 ++++++++--------- ...-blockdata-authority-unification-design.md | 450 +++++++++++------- 2 files changed, 476 insertions(+), 421 deletions(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index 436a566..8901f99 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -2,11 +2,11 @@ > **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. -**Goal:** 将运行时逻辑真相统一收敛到 `blockData` 语义,保留当前所有功能,同时去掉 `WorldRuntime` / `PersistenceService` 周围的 blockData 快照链,让 `MemoryWorldStore` 只承担未加载 chunk 的 `blockData` 容器职责,`IndexedDB` 降级为冷存储层。 +**Goal:** 将 runtime 中的逻辑真相统一收敛到 world-level `blockData`,去掉 `WorldRuntime` / `PersistenceService` 周围的 blockData 快照链,彻底删除 `MemoryWorldStore`,并把 `Chunk.blockData` 收敛为 chunk 运行时视图而不是第二权威。 -**Architecture:** 已加载 chunk 的唯一逻辑权威由 `Chunk.blockData` 持有;未加载 chunk 的逻辑权威由 `MemoryWorldStore.chunks[].blockData` 持有。`visibleKeys`、`solidBlocks`、`blockDataArray`、`meshData`、AO mirror 等全部保留,但统一视为派生索引或渲染载荷。持久化层退出 runtime 正确性主链路,只负责导入、导出和异步冷落盘。 +**Architecture:** world-level `blockData` 是唯一权威。`Chunk.blockData` 是该权威在 chunk 实例中的局部运行时视图。`visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds`、AO mirror、meshData 等全部保留,但统一视为派生索引、碰撞索引或高速查询结构。本阶段不以 IndexedDB、手动存档、手动读档为交付门槛。 -**Tech Stack:** JavaScript, Three.js, Web Workers, IndexedDB, ESLint, 浏览器内测试页面 `src/tests/index.html` +**Tech Stack:** JavaScript, Three.js, Web Workers, ESLint, 浏览器内测试页面 `src/tests/index.html` --- @@ -14,33 +14,40 @@ ### 新增文件 -- Create: `src/world/ColdPersistenceCoordinator.js` - - 负责替代 `WorldRuntime.flushChunk()` 的冷存储标脏与后台落盘调度 +- Create: `src/world/WorldBlockDataStore.js` + - world-level `blockData` 唯一权威容器 + - 负责 chunk slice 读写、生成结果注入、未来导入导出接入点 + +### 删除文件 + +- Delete: `src/world/MemoryWorldStore.js` + - 其旧有职责全部并入 `WorldBlockDataStore` ### 重点修改文件 - Modify: `src/world/Chunk.js` - - 明确 `blockData` 为 loaded chunk 唯一逻辑权威 - - 保持 `visibleKeys` / `solidBlocks` / `blockDataArray` / AO / renderDelta 功能不变 -- Modify: `src/world/MemoryWorldStore.js` - - 语义收窄为 unloaded chunk 的 `blockData` 容器 + 世界级索引 - - 增加从 loaded chunk 同步记录的辅助接口 + - 明确 `blockData` 为 chunk 运行时视图 + - 收敛所有 blockData 写入口 + - 保持 `visibleKeys` / `solidBlocks` / `blockDataArray` / `solidBlockIds` / AO / renderDelta 功能不变 - Modify: `src/world/World.js` - - chunk 加载 / 卸载前后的 `blockData` 接班逻辑 - - 保存前全量同步 loaded chunks 到 `MemoryWorldStore` + - chunk 加载 / 卸载前后的 world-level authority 接入 + - 统一通过 `WorldBlockDataStore` 接收生成结果 +- Modify: `src/world/WorldGenerationService.js` + - 区域 / chunk 生成结果直接写入 `WorldBlockDataStore` - Modify: `src/world/WorldRuntime.js` - 收缩 `blockDataSnapshot`、`pendingUnloadFlushQueue`、`flushChunk()` 热路径职责 - Modify: `src/world/ChunkPersistence.js` - - `saveDebounced()` 改为调用冷存储协调器,而不是 `WorldRuntime.flushChunk()` + - 去掉运行中对 `flushChunk()` 的依赖 +- Modify: `src/world/WorldAccessLayer.js` + - 明确编辑入口只驱动 runtime authority,不再关心持久化 - Modify: `src/core/Game.js` - - `collectSnapshot()` 改读 `MemoryWorldStore` - - `applySaveData()` 改写 `MemoryWorldStore + loaded chunks` + - 若存在导入导出路径,仅标记 deferred 或改为 future hook - Modify: `src/services/PersistenceService.js` - - 删除运行时 cache 职责,只保留兼容与冷存储能力 + - 标记 deferred / deprecated runtime 职责 - Modify: `src/world/WorldStore.js` - - 明确为冷存储门面 + - 标记 deferred 冷存储门面 - Modify: `src/workers/PersistenceWorker.js` - - 第二阶段后再评估收缩 `regionCache` + - 标记 deferred ### 重点测试文件 @@ -51,200 +58,192 @@ --- -### Task 1: 先把运行时权威语义收敛到 `Chunk.blockData` 与 `MemoryWorldStore` +### Task 1: 固定新的权威模型并删除 `MemoryWorldStore` **Files:** -- Modify: `src/world/Chunk.js` -- Modify: `src/world/MemoryWorldStore.js` +- Create: `src/world/WorldBlockDataStore.js` +- Delete: `src/world/MemoryWorldStore.js` - Modify: `src/world/World.js` -- Test: `src/tests/test-chunk.js` - Test: `src/tests/test-world.js` -- [ ] **Step 1: 写失败测试,固定“loaded / unloaded 两种 blockData 持有语义”** +- [ ] **Step 1: 写失败测试,固定 world-level `blockData` 唯一权威语义** -在 `src/tests/test-world.js` 和 `src/tests/test-chunk.js` 增加测试,覆盖: - -- 已加载 chunk 时逻辑真相以 `Chunk.blockData` 为准 -- chunk unload 前同步后,`MemoryWorldStore` 中对应 chunkRecord 持有同样的 `blockData` -- reload 后,重新加载的 chunk 恢复出相同的 `blockData` - -测试重点: +新增测试覆盖: -- 不能只测“有数据” -- 要测 `blockData` 内容完全一致 +- loaded chunk 修改后,world-level `blockData` 立即反映变更 +- chunk unload 后,reload 仍能从 world-level `blockData` 恢复 +- 运行时不再依赖 `MemoryWorldStore` - [ ] **Step 2: 运行测试,确认当前语义未被显式保证** Run: -- 启动 `npm run start` +- `npm run start` - 打开 `http://localhost:8080/src/tests/index.html` - 运行相关测试 Expected: -- 新增语义测试至少有一部分失败,暴露现有职责边界不清晰 - -- [ ] **Step 3: 修改 `Chunk.blockData` 注释和加载路径语义** - -在 `src/world/Chunk.js`: - -- 更新 `blockData` 注释为“完整逻辑块集合” -- 明确 `loadFromRecord()` / `_injectBlockData()` 在 loaded chunk 接管逻辑权威 +- 新增语义测试至少部分失败 -不要改: +- [ ] **Step 3: 实现 `WorldBlockDataStore` 最小骨架** -- `visibleKeys` -- `solidBlocks` -- `blockDataArray` -- AO / tombstone 功能 +至少包含: -- [ ] **Step 4: 修改 `MemoryWorldStore` 注释和最小辅助接口** +- `getChunkSlice(cx, cz)` +- `setBlockEntry(cx, cz, code, entry)` +- `replaceChunkSlice(cx, cz, blockData)` +- `hasChunkSlice(cx, cz)` -在 `src/world/MemoryWorldStore.js`: - -- 把类注释改成“未加载 chunk 的 blockData 容器 + 世界级索引” -- 新增一个最小辅助接口,例如: - - `syncChunkRecord(cx, cz, record)` - - 或 `syncLoadedChunkToStore(cx, cz, chunkLikeRecord)` +- [ ] **Step 4: 从 `World` 中移除 `MemoryWorldStore` 初始化与引用** 要求: -- 不要引入新的运行时快照层 -- 只做数据接班同步 - -- [ ] **Step 5: 在 `World` 中补上 unload 前同步入口** - -在 `src/world/World.js` 增加一个集中入口,例如: +- `World` 不再创建 `MemoryWorldStore` +- `World` 改为持有 `WorldBlockDataStore` +- 任何“卸载前同步到内存仓库”的语义全部删除 -- `beforeChunkUnloadSync(chunk)` - -职责: - -- 用当前 chunk 的 `blockData` -- 加上 `runtimeEntities/staticEntities/runtimeSeedData` -- 覆盖 `MemoryWorldStore` 中对应记录 - -- [ ] **Step 6: 运行相关测试确认通过** +- [ ] **Step 5: 运行相关测试确认通过** Run: - 测试页面中运行 `test-world.js` -- 运行 `test-chunk.js` Expected: -- 新增 loaded/unloaded 权威语义测试通过 +- 新增唯一权威语义测试通过 -- [ ] **Step 7: 运行 lint** +- [ ] **Step 6: 运行 lint** Run: `npm run lint` Expected: PASS --- -### Task 2: 去掉 `WorldRuntime` 的 blockData 快照主链路 +### Task 2: 将 `Chunk.blockData` 收敛为 world-level authority 的 chunk 视图 **Files:** -- Modify: `src/world/WorldRuntime.js` -- Modify: `src/world/ChunkPersistence.js` - Modify: `src/world/Chunk.js` -- Create: `src/world/ColdPersistenceCoordinator.js` -- Test: `src/tests/test-world-runtime.js` -- Test: `src/tests/test-runtime-session-persistence.js` +- Modify: `src/world/World.js` +- Test: `src/tests/test-chunk.js` +- Test: `src/tests/test-world.js` -- [ ] **Step 1: 写失败测试,固定“runtime 正确性不依赖 blockDataSnapshot”** +- [ ] **Step 1: 写失败测试,固定 `Chunk.blockData` 不是第二权威** -在 `src/tests/test-world-runtime.js` 和 `src/tests/test-runtime-session-persistence.js` 增加测试: +新增测试覆盖: -- 单块修改后,即便不构造 `dirtyEntry.blockDataSnapshot`,chunk unload/reload 仍不丢数据 -- `saveDebounced()` 不需要 `flushChunk()` 成功才能保证运行时正确性 +- 修改 `Chunk.blockData` 后,world-level `blockData` 同步可见 +- reload 后新 chunk 视图看到相同数据 +- chunk dispose 后 world-level `blockData` 仍保留 -- [ ] **Step 2: 运行测试,确认当前实现仍依赖 snapshot 语义** +- [ ] **Step 2: 运行测试,确认当前实现仍保留双 holder 语义** Run: -- 浏览器测试页运行新增测试 +- 测试页面运行 `test-chunk.js` +- 运行 `test-world.js` Expected: -- 至少有测试因 `_dirtyChunks[].blockDataSnapshot` 或 `flushChunk()` 假设而失败 +- 至少暴露一部分仍依赖 chunk-local 独立持有的行为 -- [ ] **Step 3: 新增冷存储协调器最小骨架** +- [ ] **Step 3: 调整 `Chunk` 构造与 hydrate 语义** -在 `src/world/ColdPersistenceCoordinator.js` 实现最小版本: +要求: -- `schedulePersistChunk(cx, cz)` -- `flushDirtyChunks()` +- `Chunk.blockData` 明确写成 world-level authority 的 chunk slice 视图 +- `loadFromRecord()` / `_injectBlockData()` 的文档与行为同步调整 +- 不再把 `Chunk.blockData` 视作未来 unload 时要转移出去的数据 -第一版只要求: +- [ ] **Step 4: 运行相关测试确认通过** -- 记录哪些 chunk 需要冷落盘 -- 提供未来接入 `WorldStore` 的位置 +Run: +- 测试页面运行 `test-chunk.js` +- 运行 `test-world.js` -不要第一步就做复杂批处理。 +Expected: +- `Chunk.blockData` 视图语义成立 -- [ ] **Step 4: 修改 `ChunkPersistence.saveDebounced()`** +- [ ] **Step 5: 运行 lint** -在 `src/world/ChunkPersistence.js`: +Run: `npm run lint` +Expected: PASS -- 不再直接调用 `runtime.flushChunk()` -- 改为调用新的冷存储协调器 `schedulePersistChunk()` +--- -目标: +### Task 3: 穷尽所有 blockData 写入口并统一顺序 -- runtime 正确性退出 `flushChunk()` 依赖 +**Files:** +- Modify: `src/world/Chunk.js` +- Modify: `src/world/WorldGenerationService.js` +- Modify: `src/world/WorldAccessLayer.js` +- Modify: `src/world/WorldBlockDataStore.js` +- Test: `src/tests/test-chunk.js` +- Test: `src/tests/test-world.js` -- [ ] **Step 5: 收缩 `WorldRuntime.recordBlockMutation()`** +- [ ] **Step 1: 写失败测试,覆盖所有主要写入口** -在 `src/world/WorldRuntime.js`: +至少覆盖: -- 停止首次脏化时创建完整 `blockDataSnapshot` -- 将其职责收缩为“记录 chunk 脏状态”或直接由新协调器接管 +- `addBlockDynamic()` +- `addBlocksBatchFast()` +- `removeBlocksBatch()` +- `acceptScatteredBlocks()` +- `appendScatteredBlocks()` +- 世界生成结果注入路径 -要求: +- [ ] **Step 2: 运行测试,确认当前覆盖存在缺口** -- 保持 `markChunkDirty()` 语义兼容 -- 不要破坏现有其他测试 +Run: +- 测试页面运行 `test-chunk.js` +- 运行 `test-world.js` -- [ ] **Step 6: 将 `flushChunk()` 明确降级为冷存储工具接口** +Expected: +- 至少暴露一部分写入口没有被新 invariant 约束 + +- [ ] **Step 3: 收敛写路径顺序** -在 `src/world/WorldRuntime.js`: +统一规则: -- 标注 `flushChunk()` 不再参与 runtime 正确性 -- 停止让 `saveDebounced()` 依赖它 +- 先写 world-level `blockData` +- 通过 `Chunk.blockData` 视图反映到当前 chunk +- 再更新 `visibleKeys` / `solidBlocks` / `blockDataArray` / `solidBlockIds` +- 再更新 AO / render / tombstone 等异步派生层 +- 禁止任何索引层反向决定逻辑真相 -第一阶段允许保留: +- [ ] **Step 4: 让世界生成直接产出权威 blockData** -- `flushAllDirty()` -- `_commitChunkRecord()` +要求: -仅作为冷写工具。 +- 生成器结果先进入 `WorldBlockDataStore` +- 当前 chunk 已加载时,再 hydrate 到 `Chunk.blockData` +- 不再为持久化缓存额外铺第二条热路径 -- [ ] **Step 7: 运行运行时相关测试** +- [ ] **Step 5: 运行相关测试确认通过** Run: -- 测试页面中运行 `test-world-runtime.js` -- 运行 `test-runtime-session-persistence.js` +- 测试页面运行 `test-chunk.js` +- 运行 `test-world.js` Expected: -- 卸载/重载正确性不再依赖 `blockDataSnapshot` +- 所有主要写入口都满足统一顺序 -- [ ] **Step 8: 运行 lint** +- [ ] **Step 6: 运行 lint** Run: `npm run lint` Expected: PASS --- -### Task 3: 保持渲染索引与查询索引原样工作 +### Task 4: 明确保留 chunk 层索引结构 **Files:** - Modify: `src/world/Chunk.js` - Test: `src/tests/test-chunk.js` - Test: `src/tests/test-world.js` -- [ ] **Step 1: 写失败测试,固定“索引层保留原用途”** +- [ ] **Step 1: 写失败测试,固定索引层职责** -在 `src/tests/test-chunk.js` 增加测试,覆盖: +明确覆盖: -- `visibleKeys` 仍服务可见性判断,不因权威语义收敛被合并或清空 -- `solidBlocks` 仍服务碰撞判断 -- `blockDataArray + solidBlockIds` 仍保持 chunk 内快路径可用 +- `Chunk.visibleKeys` 仍服务可见性判断 +- `Chunk.solidBlocks` 仍服务碰撞判断 +- `Chunk.blockDataArray` 仍作为 chunk 内紧凑快路径 +- `Chunk.solidBlockIds` 仍配合数组路径做 O(1) 实心判断 - [ ] **Step 2: 运行测试,确认索引层行为当前有覆盖缺口** @@ -254,153 +253,143 @@ Run: Expected: - 至少需要补一个或多个更明确的断言 -- [ ] **Step 3: 修正 `Chunk` 中可能依赖旧持久化假设的逻辑** - -在 `src/world/Chunk.js`: - -- 检查 `_updateBlockState()` -- 检查 `acceptScatteredBlocks()` -- 检查 `appendScatteredBlocks()` +- [ ] **Step 3: 检查 AO mirror / renderDelta / tombstone 相关逻辑** 要求: -- 只允许 `blockData` 驱动索引层更新 -- 禁止让索引层去反向决定逻辑真相 - -- [ ] **Step 4: 明确保留 tombstone / AO mirror / renderDelta** - -在 `src/world/Chunk.js` 添加或修正文档注释: - -- `deletedBlockTombstones` 继续保留 -- `AOBridge` 镜像仍从 `blockData` 派生 +- 明确保留 `deletedBlockTombstones` +- AO mirror 继续从 `blockData` 派生 - `renderDelta` 仍供全局实例系统消费 -- [ ] **Step 5: 运行 chunk/world 相关测试** +- [ ] **Step 4: 运行 chunk/world 相关测试** Run: - 测试页面运行 `test-chunk.js` - 运行 `test-world.js` Expected: -- 索引层与渲染补丁相关测试通过 +- 派生索引与渲染补丁相关测试通过 -- [ ] **Step 6: 运行 lint** +- [ ] **Step 5: 运行 lint** Run: `npm run lint` Expected: PASS --- -### Task 4: 手动保存改为直接从 `MemoryWorldStore` 导出 +### Task 5: 去掉 runtime blockData 快照主链路 **Files:** -- Modify: `src/core/Game.js` -- Modify: `src/world/World.js` +- Modify: `src/world/WorldRuntime.js` +- Modify: `src/world/ChunkPersistence.js` +- Modify: `src/world/Chunk.js` - Test: `src/tests/test-world-runtime.js` +- Test: `src/tests/test-runtime-session-persistence.js` -- [ ] **Step 1: 写失败测试,固定 `collectSnapshot()` 的新来源** +- [ ] **Step 1: 写失败测试,固定“runtime 正确性不依赖 blockDataSnapshot”** -在相关测试中新增断言: +新增测试覆盖: -- `collectSnapshot()` 不再调用 `worldStore.getChunkRecord()` -- 保存前先同步所有 loaded chunks 到 `MemoryWorldStore` -- 导出结果来自 `MemoryWorldStore` +- 单块修改后,即便不构造 `dirtyEntry.blockDataSnapshot`,unload/reload 仍不丢数据 +- `saveDebounced()` 失败或被旁路时,不影响 runtime 正确性 -- [ ] **Step 2: 运行测试确认当前实现仍依赖 `worldStore.getChunkRecord()`** +- [ ] **Step 2: 运行测试,确认当前实现仍依赖 snapshot 语义** Run: - 浏览器测试页运行新增测试 Expected: -- 新增测试失败,暴露当前逐 chunk 冷读取路径 - -- [ ] **Step 3: 在 `World` 中新增保存前同步入口** +- 至少有测试因 `_dirtyChunks[].blockDataSnapshot` 或 `flushChunk()` 假设而失败 -在 `src/world/World.js` 增加: +- [ ] **Step 3: 收缩 `WorldRuntime.recordBlockMutation()`** -- `syncAllLoadedChunksToMemoryStore()` +要求: -职责: +- 停止首次脏化时创建完整 `blockDataSnapshot` +- 收缩为 runtime dirty 标记,或直接由 chunk/local state 接管 -- 遍历 `this.chunks` -- 将每个 loaded chunk 当前 `blockData` 和实体记录同步回 `MemoryWorldStore` +- [ ] **Step 4: 修改 `ChunkPersistence.saveDebounced()`** -- [ ] **Step 4: 改写 `Game.collectSnapshot()`** +要求: -在 `src/core/Game.js`: +- 不再直接依赖 `runtime.flushChunk()` +- 本阶段允许只保留防抖 dirty 标记,或直接让它成为 no-op -- 调用 `world.syncAllLoadedChunksToMemoryStore()` -- 直接从 `world.memoryWorldStore` 读取 chunk 记录 -- 生成 `worldDeltas` +- [ ] **Step 5: 将 `flushChunk()` 和 `pendingUnloadFlushQueue` 明确降级** 要求: -- 不再逐 chunk `await world.worldStore.getChunkRecord()` +- 注释标明不再参与 runtime 正确性 +- 若暂时保留,仅作为未来冷存储恢复时的兼容工具 -- [ ] **Step 5: 运行相关测试** +- [ ] **Step 6: 运行运行时相关测试** Run: -- 浏览器测试页中运行 `test-world-runtime.js` +- 测试页面中运行 `test-world-runtime.js` +- 运行 `test-runtime-session-persistence.js` Expected: -- `collectSnapshot()` 路径测试通过 +- 卸载/重载正确性不再依赖 `blockDataSnapshot` -- [ ] **Step 6: 运行 lint** +- [ ] **Step 7: 运行 lint** Run: `npm run lint` Expected: PASS --- -### Task 5: 手动导入改为先写 `MemoryWorldStore`,再同步已加载 chunk +### Task 6: 收紧 clone / snapshot / serialize 边界 **Files:** -- Modify: `src/core/Game.js` +- Modify: `src/world/WorldBlockDataStore.js` - Modify: `src/world/World.js` -- Modify: `src/services/PersistenceService.js` +- Modify: `src/world/Chunk.js` - Test: `src/tests/test-world-runtime.js` -- [ ] **Step 1: 写失败测试,固定 `applySaveData()` 新链路** +- [ ] **Step 1: 写失败测试,固定“热路径不允许全量 clone”约束** -新增测试覆盖: +重点覆盖: + +- 单块修改 +- 批量改单块 +- 普通运行中的 chunk unload -- `applySaveData()` 不再依赖 `persistenceService.injectSaveData()` -- 存档中的 `worldDeltas` 先写入 `MemoryWorldStore` -- 如果目标 chunk 已加载,需同步刷新对应 `Chunk.blockData` +测试不要求精确测时间,但要能证明: -- [ ] **Step 2: 运行测试确认当前仍依赖 `injectSaveData()`** +- 不再依赖整份 `blockDataSnapshot` +- 不再用持久化理由对整 chunk 做热路径全量复制 + +- [ ] **Step 2: 运行测试确认当前实现仍存在快照式依赖** Run: - 浏览器测试页运行新增测试 Expected: -- 当前实现因调用 `injectSaveData()` 而失败 +- 至少有测试或断言暴露旧 clone 假设 -- [ ] **Step 3: 修改 `Game.applySaveData()`** +- [ ] **Step 3: 调整 world-level authority API** -在 `src/core/Game.js`: +要求: -- 将 `saveData.worldDeltas` 转写到 `world.memoryWorldStore` -- 对已加载 chunk: - - 覆盖其 `blockData` - - 重建 `blockDataArray` - - 重建 `solidBlocks` - - 触发可见性 / mesh 重新装配 +- 提供 chunk slice 视图读取接口 +- 提供局部 block entry 更新接口 +- 避免所有路径都走整份 chunk record 替换 -- [ ] **Step 4: 将 `PersistenceService.injectSaveData()` 降级为 deprecated** +- [ ] **Step 4: 明确允许 clone 的边界** -在 `src/services/PersistenceService.js`: +代码和注释中只允许在以下边界做全量复制: -- 保留旧接口以兼容旧代码 -- 注释标明不再是 runtime 主链路 +- Worker 消息边界 +- 测试快照 +- 未来导出存档 -- [ ] **Step 5: 运行导入相关测试** +- [ ] **Step 5: 运行相关测试** Run: -- 浏览器测试页运行 `test-world-runtime.js` +- 测试页面运行 `test-world-runtime.js` Expected: -- 存档导入、loaded chunk 覆盖、reload 恢复测试通过 +- 热路径中的整包快照依赖显著收缩 - [ ] **Step 6: 运行 lint** @@ -409,22 +398,22 @@ Expected: PASS --- -### Task 6: 收缩旧持久化缓存层,只保留冷存储职责 +### Task 7: 推迟存档与冷存储能力,并把代码边界标记清楚 **Files:** -- Modify: `src/world/WorldRuntime.js` +- Modify: `src/core/Game.js` - Modify: `src/services/PersistenceService.js` - Modify: `src/world/WorldStore.js` - Modify: `src/workers/PersistenceWorker.js` - Test: `src/tests/test-world-runtime.js` -- [ ] **Step 1: 写失败测试,固定“运行时不再依赖旧 cache 层”** +- [ ] **Step 1: 写失败测试,固定“runtime 不再依赖旧持久化层”** 测试覆盖: - `PersistenceService.cache` 不再承载 runtime blockData 权威 -- `WorldRuntime._regionCache` 只服务冷读取/预取 -- `pendingUnloadFlushQueue` 不再参与运行时正确性 +- `WorldStore.getChunkRecord()` 不是 runtime 主链路的必需前提 +- `collectSnapshot()` / `applySaveData()` 若继续保留,应显式标记 deferred 或兼容路径 - [ ] **Step 2: 运行测试,确认当前实现仍保留旧缓存职责** @@ -434,51 +423,30 @@ Run: Expected: - 测试失败,暴露旧缓存职责仍在 -- [ ] **Step 3: 收缩 `PersistenceService` 的运行时接口** - -在 `src/services/PersistenceService.js`: - -- 标记以下方法 deprecated: - - `snapshotChunkBlocks` - - `hydrateChunkBlocks` - - `replaceChunkBlocks` - - `injectSaveData` - -- [ ] **Step 4: 收缩 `WorldRuntime` 的旧 flush 队列职责** - -在 `src/world/WorldRuntime.js`: +- [ ] **Step 3: 标记 deferred / deprecated 边界** -- 将 `pendingUnloadFlushQueue` 标注为冷写兼容工具 -- 检查是否仍有调用路径依赖它保证 runtime 正确性 -- 若有,改接到 `MemoryWorldStore` - -- [ ] **Step 5: 暂不删除 `PersistenceWorker.regionCache`,只改注释和边界** - -在 `src/workers/PersistenceWorker.js`: - -- 注释说明其仅服务冷启动/冷读取 -- 不把它作为运行时主链路优化项 - -原因: +要求: -- 避免过早删除导致旧档冷启动性能回退 +- `PersistenceService.injectSaveData()`、`snapshotChunkBlocks()`、`hydrateChunkBlocks()`、`replaceChunkBlocks()` 标记 deprecated +- `WorldStore`、`PersistenceWorker` 标记为冷存储 deferred +- `Game.collectSnapshot()`、`Game.applySaveData()` 若不立即重写,至少明确为非本阶段门槛 -- [ ] **Step 6: 运行运行时测试** +- [ ] **Step 4: 运行运行时测试** Run: - 浏览器测试页运行 `test-world-runtime.js` Expected: -- 无回归 +- runtime 路径不再把持久化层当成正确性前提 -- [ ] **Step 7: 运行 lint** +- [ ] **Step 5: 运行 lint** Run: `npm run lint` Expected: PASS --- -### Task 7: 完整回归与性能验证 +### Task 8: 完整回归与性能验证 **Files:** - Test: `src/tests/test-chunk.js` @@ -494,7 +462,7 @@ Run: - 点击“运行所有测试” Expected: -- 所有现有测试通过 +- 所有与 runtime authority 相关的现有测试通过 - [ ] **Step 2: 手动验证单块修改与 reload** @@ -508,16 +476,16 @@ Expected: Expected: - 方块状态正确恢复 -- [ ] **Step 3: 手动验证导入导出** +- [ ] **Step 3: 手动验证世界生成结果直接进入 blockData** 手动步骤: -1. 导出当前存档 -2. 刷新页面 -3. 导入刚导出的存档 +1. 进入未生成区域 +2. 观察新区块生成 +3. 触发 unload / reload Expected: -- 世界块数据、实体数据、玩家状态正确恢复 +- 生成结果经 unload / reload 后仍一致 - [ ] **Step 4: 手动验证渲染与碰撞功能未丢失** @@ -525,19 +493,20 @@ Expected: - `visibleKeys` 驱动的上屏/补面仍正常 - `solidBlocks` 驱动的碰撞仍正常 +- `blockDataArray + solidBlockIds` 快路径仍正常 - AO 无明显错乱 -- [ ] **Step 5: 记录 Chrome Trace 基线与结果** +- [ ] **Step 5: 记录性能对比** 对比项: - consolidation 后长帧 - `WorldRuntime.flushChunk()` 是否已退出热路径 -- 是否仍有大块 `postMessage` / `applyRegionPatch` 热点 +- 是否仍有大块 `blockDataSnapshot` / region patch / 结构化复制热点 Expected: - 热路径显著收缩 -- 若仍有长帧,应归因为下一阶段“持久化粒度优化”而不是旧 snapshot 链 +- 剩余长帧若存在,应归因于后续渲染或数据结构优化,而不是旧快照链 - [ ] **Step 6: 运行 lint 作为最终门禁** diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index 020a48b..c90a8b7 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -2,72 +2,97 @@ > **Problem:** 当前 runtime 期间的逻辑方块真相被拆散在 `Chunk.blockData`、`MemoryWorldStore.chunks[].blockData`、`WorldRuntime` 的快照链、`PersistenceService.cache` 与 `PersistenceWorker.regionCache` 周围。结果是运行时既存在双权威语义,也存在多条全量 clone / region patch / flush queue 链路,导致数据层次复杂、拷贝次数多、`postMessage` 载荷过大,且后续优化难以落点。 -## 1. Goal +## 1. Revised Scope + +结合当前目标,本次设计做三条重要收敛: + +1. **runtime 只解决内存中的权威数据正确流转** +2. **世界级 `blockData` 是唯一权威数据源** +3. **彻底删除 `MemoryWorldStore`,其职责并入 world-level `blockData`** + +这意味着: + +- `IndexedDB` 在本阶段不是必须能力 +- 手动存档 / 读档延后到 runtime 架构稳定后再恢复 +- chunk unload 不再承担“权威转移”职责 +- 所有设计优先服务“运行中的正确性、清晰性、可优化性” + +## 2. Goal 本设计的目标是把运行时的逻辑真相统一收敛到 `blockData` 语义上,并在此基础上保留当前所有关键功能: -1. 已加载 chunk 的逻辑真相由 `Chunk.blockData` 唯一承载 -2. 未加载 chunk 的逻辑真相由 `MemoryWorldStore` 持有对应 chunk 的 `blockData` -3. `visibleKeys`、`solidBlocks`、`blockDataArray`、`meshData` 等结构继续保留,但明确降级为派生索引或渲染载荷 -4. `IndexedDB` 降级为冷存储 / 导出 / 刷新恢复来源,不再参与 runtime 权威判断 -5. 消灭运行时的 blockData 全量快照链,减少 clone、序列化和跨线程传输次数,为后续 `postMessage` / 持久化粒度优化打基础 +1. `blockData` 升级为**世界级唯一权威数据源** +2. `Chunk.blockData` 不再是独立第二份真相,而是 world-level `blockData` 在 chunk 实例中的局部运行时视图 +3. 世界生成直接产出并写入 world-level `blockData` +4. 彻底删除 `MemoryWorldStore` +5. `Chunk.visibleKeys`、`Chunk.solidBlocks`、`Chunk.blockDataArray`、`Chunk.solidBlockIds` 继续保留,作为 chunk 层派生索引或高速查询结构 +6. 消灭运行时的 blockData 全量快照链,减少 clone、序列化和跨线程传输次数,为后续性能优化打基础 -## 2. Non-Goals +## 3. Non-Goals 本次设计明确不做以下事情: -1. 不重写 `visibleKeys`、`solidBlocks`、`blockDataArray` 的用途与算法 +1. 不重写 `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 的用途与算法 2. 不改变 AO / Face Culling / Global Instanced Mesh 的核心渲染策略 3. 不把特殊实体逻辑硬塞进普通 `blockData` 语义 -4. 不在第一阶段改成 chunk 级独立 `IndexedDB` store -5. 不在第一阶段推进 TypedArray 化的权威存储 +4. 不在本阶段实现新的 IndexedDB 持久化方案 +5. 不在本阶段推进 TypedArray 化的权威存储 6. 不要求本次一并解决所有渲染性能热点 -## 3. Current System Reality +## 4. Current System Reality -### 3.1 当前并非单一运行时权威 +### 4.1 当前真正的问题不是“有没有 blockData”,而是“权威边界不干净” -当前代码中,逻辑真相至少被拆在两处: +当前代码里: -- 已加载 chunk 的真相主要在 `Chunk.blockData` -- 未加载 chunk 的真相主要在 `MemoryWorldStore.chunks[].blockData` +- `Chunk.blockData` 已经是 loaded chunk 的完整逻辑块集合 +- `MemoryWorldStore.chunks[].blockData` 实际承担 unload 后恢复职责 +- `WorldRuntime._dirtyChunks[].blockDataSnapshot`、`pendingUnloadFlushQueue`、`PersistenceService.cache` 又构成了一套持久化过渡快照链 -这本身是合理的冷热分层,但问题在于系统没有把它们表达为“同一语义在 loaded / unloaded 两种形态下的持有者”,而是让它们看起来像两套并行权威。 +所以问题不是没有权威数据源,而是: -### 3.2 运行时仍然保留旧持久化快照链 +1. 同一个逻辑真相被多层持有 +2. 不同层对“自己是不是权威”语义不一致 +3. 写路径为照顾旧持久化链路发生了明显写放大 -尽管若干注释已将 `IndexedDB` 描述为冷存储,但当前运行时仍保留下列热路径: +### 4.2 当前渲染与查询索引层本身不是主要矛盾 -- `Chunk.saveDebounced()` -- `WorldRuntime.recordBlockMutation()` -- `WorldRuntime._dirtyChunks[].blockDataSnapshot` -- `WorldRuntime.flushChunk()` -- `pendingUnloadFlushQueue` -- `WorldStore.commitChunkRecord()` -- `PersistenceWorker.applyRegionPatch()` +以下结构虽有重复信息,但职责合理,应保留: -这意味着: +- `Chunk.visibleKeys` +- `Chunk.solidBlocks` +- `Chunk.blockDataArray` +- `Chunk.solidBlockIds` +- `Chunk.blockPalette` / `Chunk.blockPaletteReverse` +- `meshData` +- `instanceIndexMap` +- `renderDelta` +- `lightSourceCoords` +- `dirtyAOPositions` +- `deletedBlockTombstones` + +本次设计不是“去掉所有重复数据”,而是“去掉重复权威和重复快照链”。 -- runtime 期间仍在构造稳定 blockData 快照 -- 仍在维护 region 级 patch / flush 队列 -- 仍有完整对象跨线程传输和 `IndexedDB.put()` 的结构化复制 +### 4.3 新设计中,`blockData` 不能再等同于“某个 Chunk 实例上的 Map” -### 3.3 当前渲染与查询索引本身并不是问题 +本次改造有一个核心前提: -以下结构虽然重复了部分 block 信息,但其职责是合理的: +- `Chunk.blockData` 只是 chunk 运行时对象上的访问入口 +- 真正的 `blockData` 是**独立于 chunk 生命周期存在的世界级权威数据** -- `visibleKeys`:显示可见性索引 -- `solidBlocks`:碰撞索引 -- `blockDataArray` + `solidBlockIds` + palette:chunk 内高频查询索引 -- `meshData` / `instanceIndexMap` / `renderDelta`:渲染载荷与增量补丁 -- `lightSourceCoords` / `dirtyAOPositions`:光照与 AO 派生索引 -- `deletedBlockTombstones`:异步一致性保护层 +这意味着: -因此本设计不是“去掉所有重复数据”,而是“去掉所有重复权威和重复快照链”。 +- chunk unload 不是“把真相写回另一个内存层” +- 而是“销毁一个运行时实例,但不销毁 world-level `blockData`” -## 4. Proposed Authority Model +所以目标是: -### 4.1 统一语义:`blockData` 是唯一逻辑真相 +- 让 `Chunk.blockData` 从世界级权威数据中取用自己的 chunk slice +- 卸载时只释放派生层与渲染层,不再做“权威转移” + +## 5. Proposed Authority Model + +### 5.1 统一语义:`blockData` 是唯一逻辑真相 新模型里,`blockData` 作为概念上的唯一逻辑真相,覆盖: @@ -85,61 +110,78 @@ 这些都属于派生层。 -### 4.2 Loaded / Unloaded 两种持有形态 +### 5.2 统一权威持有模型 -`blockData` 在运行时有两种持有形态: +runtime 中只存在一份 world-level `blockData authority`: -1. **Loaded 形态** - - 由 `Chunk.blockData` 持有 - - 当 chunk 已加载时,它是该 chunk 的唯一活跃逻辑权威 +1. **世界级权威层** + - 持有者:`World.blockData` 或等价世界级 authority 容器 + - 允许写:是 + - 允许读:是 + - 生命周期:独立于 chunk 实例 -2. **Unloaded 形态** - - 由 `MemoryWorldStore.chunks[].blockData` 持有 - - 当 chunk 被卸载后,它接管该 chunk 的逻辑真相 +2. **Chunk 运行时视图层** + - 持有者:`Chunk.blockData` + - 语义:指向 world-level `blockData` 中当前 chunk slice 的运行时访问入口 + - 允许写:可以,但写入必须直接命中 world-level authority + - 生命周期:随 chunk 加载/卸载而出现/消失 -这意味着: +状态切换规则: + +- `load`:创建 chunk 实例视图 +- `unload`:销毁 chunk 实例视图 +- world-level `blockData` 全程不发生“转移持有者” -- `MemoryWorldStore` 不能删除 -- 但它不再被视为“另一套并行权威模型” -- 它应被重新定义为“未加载 chunk 的 `blockData` 容器 + 世界级索引” +### 5.3 删除 `MemoryWorldStore` -### 4.3 冷存储退出运行时权威判断 +本设计的目标态中: -`IndexedDB` 在目标模型中只负责: +- 不保留 `MemoryWorldStore` +- 不保留“loaded / unloaded 双 holder”模型 +- 不保留任何“卸载前把权威同步到另一个内存层”的语义 -- 启动时读入旧档 -- 手动保存或后台异步落盘 -- 刷新页面后的恢复来源 +需要留下来的能力只有: -运行时正确性不再依赖: +- world-level `blockData` +- world-level chunk record 索引 +- chunk 视图的加载和释放 + +### 5.4 冷存储完全退出本阶段主链路 + +本阶段 runtime 正确性不再依赖: - `WorldRuntime.blockDataSnapshot` - `PersistenceService.cache` - `pendingUnloadFlushQueue` - `PersistenceWorker.regionCache` +- `WorldStore.commitChunkRecord()` -## 5. Target Layering +`IndexedDB`、`PersistenceService`、`WorldStore`、`PersistenceWorker` 在本阶段统一视为: -### 5.1 权威逻辑层 +- 延后恢复的冷存储能力 +- 暂不作为 runtime 改造的阻塞条件 -- `Chunk.blockData` -- `MemoryWorldStore.chunks[].blockData` +## 6. Target Layering -语义:世界逻辑真相,只在 loaded / unloaded 两种状态下切换持有者。 +### 6.1 权威逻辑层 -### 5.2 渲染索引层 +- world-level `blockData authority` + +语义:世界逻辑真相的唯一持有者,独立于 chunk 生命周期。 + +### 6.2 渲染索引层 - `Chunk.visibleKeys` 语义:当前是否应显示、是否参与补面与可见块更新。 -### 5.3 碰撞索引层 +### 6.3 碰撞索引层 - `Chunk.solidBlocks` 语义:当前是否是实心块、是否参与碰撞和物理查询。 -### 5.4 高频查询索引层 +### 6.4 高频查询索引层 - `Chunk.blockDataArray` - `Chunk.solidBlockIds` @@ -148,7 +190,23 @@ 语义:服务 chunk 内高频 `isSolid` / `resolveBlockOwner` / 局部块访问,不承担权威职责。 -### 5.5 渲染载荷层 +需要特别确认保留: + +- `Chunk.visibleKeys` +- `Chunk.solidBlocks` +- `Chunk.blockDataArray` +- `Chunk.solidBlockIds` + +这四类结构在新设计中仍然有明确价值: + +- `visibleKeys`:可见性与补面判断 +- `solidBlocks`:碰撞与实心判定 +- `blockDataArray`:chunk 内紧凑数组快路径 +- `solidBlockIds`:配合数组路径做 O(1) 实心判断 + +它们都应继续存在,只是语义上必须严格降级为**派生索引 / 高速查询缓存**。 + +### 6.5 渲染载荷层 - Worker `meshData` - `Chunk.instanceIndexMap` @@ -157,7 +215,7 @@ 语义:面向渲染提速和 GPU 输出。 -### 5.6 光照与一致性保护层 +### 6.6 光照与一致性保护层 - `Chunk.lightSourceCoords` - `Chunk.dirtyAOPositions` @@ -166,18 +224,36 @@ 语义:AO / 光照增量刷新与晚到回包保护。 -### 5.7 实体与冷存储层 +### 6.7 实体层 - `runtimeEntities` - `staticEntities` - `entityCollisionIndex` -- `IndexedDB` +- `specialEntitiesShadowStore` -语义:实体域权威与冷存储。 +语义:实体域权威与碰撞域,不混入普通块逻辑真相。 -## 6. Target Data Flow +## 7. Target Data Flow -### 6.1 单块修改 +### 7.1 世界生成 + +目标链路: + +```text +WorldGenerationService / WorldWorker +-> 产出 chunkRecord.blockData +-> 直接写入 world-level blockData authority +-> 若 chunk 当前已加载,则 hydrate 到 Chunk.blockData +-> 从 blockData 派生可见性 / mesh / AO +``` + +关键约束: + +- 生成结果首先是 `blockData` +- 不通过 `PersistenceService.cache` +- 不为“未来存档”额外维护第二条热路径 + +### 7.2 单块修改 目标链路: @@ -185,190 +261,200 @@ WorldAccessLayer.setBlock/removeBlock -> Chunk._updateBlockState() -> 修改 Chunk.blockData +-> 同步写入 world-level blockData authority -> 增量更新 visibleKeys / solidBlocks / blockDataArray / lightSourceCoords / dirtyAOPositions / renderDelta -> AOBridge / tombstones 同步 --> MemoryWorldStore.applyBlockMutation() --> 标记冷存储落盘脏块(仅标脏,不构造快照) +-> 仅标记 chunk runtime dirty ``` 关键约束: -- 真相先改 `Chunk.blockData` -- `MemoryWorldStore` 只做 unloaded 接班准备 +- 真相先改 `blockData` +- `Chunk.blockData` 只是世界级权威的 chunk 访问入口 - 不再构造完整 `blockDataSnapshot` +- 不再为了持久化链路即时 clone 整个 chunkRecord -### 6.2 Chunk 加载 +### 7.3 Chunk 加载 目标链路: ```text World 请求 chunk --> 先查 MemoryWorldStore.getChunkRecord() --> miss 时回源冷存储或生成器 --> 回源结果先写入 MemoryWorldStore --> Chunk.loadFromRecord() --> Chunk.blockData 接管为 loaded 权威 +-> 先查 world-level blockData authority / chunk record 索引 +-> miss 时回源生成器 +-> 回源结果直接写入 world-level blockData +-> Chunk.loadFromRecord() / createChunkView() -> 从 blockData 重建 blockDataArray / solidBlocks / solidBlockIds / lightSourceCoords --> Worker 计算 visibleKeys / meshData / AO --> Worker 回包只更新派生层 +-> Worker 只计算派生层 ``` 关键约束: - Worker 不能直接改写逻辑真相 -- `Chunk.blockData` 在加载完成后成为唯一活跃逻辑权威 +- `Chunk.blockData` 不是第二权威,而是权威数据的 chunk 视图 -### 6.3 Chunk 卸载 +### 7.4 Chunk 卸载 目标链路: ```text -World.beforeChunkUnloadSync() --> 用当前 Chunk.blockData / runtimeEntities / staticEntities 覆盖 MemoryWorldStore 中对应记录 +World.unloadChunk() -> 释放 visibleKeys / solidBlocks / blockDataArray / meshData / instanceIndexMap / dynamicMeshes --> 标记冷存储落盘脏块 +-> 销毁 chunk 实例视图 ``` 关键约束: -- 卸载后仍能 reload 恢复 -- 不再依赖 `pendingUnloadFlushQueue` +- unload 不再承担权威同步动作 +- unload 后 reload 必须仍从 world-level `blockData` 恢复 +- 本阶段不要求同步写盘 -### 6.4 Reload / 刷新恢复 +### 7.5 JSON 导入 / 导出 -目标链路: +本阶段结论: -```text -启动 --> 从 IndexedDB 读取 chunkRecord / regionRecord --> 导入 MemoryWorldStore --> runtime streaming 时从 MemoryWorldStore 读取 --> Chunk.loadFromRecord() 接管 loaded 权威 -``` +- **推迟实现** +- 文档只保留约束,不把它放进第一阶段交付门槛 -关键约束: +未来恢复时应满足: -- 先建立世界级内存视图,再进入 runtime streaming -- 导入 JSON 存档时走 MemoryWorldStore,而不是先走 PersistenceService.cache +- 导入先写 world-level `blockData` +- 已加载 chunk 再从 world-level `blockData` 覆盖刷新 +- 导出直接从 world-level `blockData` 统一读取 -### 6.5 手动保存 +## 8. 写入口清单 -目标链路: +以下所有路径都必须纳入统一改造,不允许遗漏: -```text -保存前先把所有 loaded chunk 同步回 MemoryWorldStore --> collectSnapshot() 直接从 MemoryWorldStore 读取 --> 生成 saveData --> 可选异步落盘到 IndexedDB -``` +1. `addBlockDynamic()` +2. `addBlocksBatchFast()` +3. `removeBlocksBatch()` +4. `Chunk._updateBlockState()` +5. `Chunk.acceptScatteredBlocks()` +6. `Chunk.appendScatteredBlocks()` +7. `Chunk.loadFromRecord()` / `_injectBlockData()` +8. `WorldGenerationService` 写入生成结果的路径 +9. 未来 `applySaveData()` / import 路径 -关键约束: +统一原则: -- 导出文件不依赖当前 `IndexedDB` 状态 -- `collectSnapshot()` 不再逐 chunk 回查 `worldStore.getChunkRecord()` +- 所有逻辑修改必须先触达 `blockData` +- 索引层只能被 `blockData` 驱动更新 +- 不允许索引层反向决定逻辑真相 -## 7. Component Responsibilities After Migration +## 9. 复制与序列化边界 -### 7.1 `Chunk` +这次改造必须把“什么时候允许 clone”写成硬约束,否则只会把性能问题换位置。 -保留职责: +### 9.1 允许 clone 的边界 -- 持有 loaded chunk 的 `blockData` -- 维护所有查询 / 渲染派生层 -- 在单块编辑时驱动派生层增量更新 -- 对 AO / Face Culling / render delta 负责 +- Worker `postMessage` +- 测试断言快照 +- 未来真正导出存档时 -调整职责: +### 9.2 不允许全量 clone 的热路径 -- `blockData` 注释更新为“完整逻辑块集合” -- 不再承担持久化快照链的协调逻辑 +- 单块修改 +- 批量改单块 +- `markChunkDirty()` +- `saveDebounced()` +- 普通运行中的 AO / FaceCulling 派生刷新 +- chunk unload -### 7.2 `MemoryWorldStore` +### 9.3 API 边界约束 -保留职责: +- world-level `blockData` 的读取接口默认应返回只读视图或约定不可变结果 +- 需要高性能时,应优先提供“取 chunk slice 视图”“同步指定字段”“直接挂载 chunk view”的 API +- `Chunk.loadFromRecord()` 必须明确输入是“权威视图”还是“保护性快照”,不能混用 -- 世界级 chunk 索引 -- 已知世界块数据的内存持有 -- chunk unload 后的逻辑状态承接 -- 手动保存与刷新恢复的统一读取入口 +## 10. Bootstrap / Import / Export Strategy -调整职责: +### 10.1 本阶段 bootstrap -- 语义从“并行运行时权威”收窄为“未加载 chunk 的 `blockData` 容器” -- `dirtyChunks` 只为冷存储落盘服务 +本阶段推荐最小闭环: -### 7.3 `WorldRuntime` +1. 启动游戏 +2. 需要 chunk 时先查 world-level `blockData` +3. 没有则走生成器 +4. 生成结果直接写 world-level `blockData` +5. loaded chunk 从 world-level `blockData` hydrate -保留职责: +### 10.2 本阶段不恢复的能力 -- 冷读取协调 -- 旧档导入辅助 -- region 预取辅助 -- 冷写入调度 +- 不要求冷启动从 IndexedDB 批量导入 +- 不要求手动导入 JSON +- 不要求手动导出 JSON -删除或退出主链路的职责: +这些能力等 runtime 架构稳定后再恢复,成本更低,也更不容易反复返工。 -- runtime 期间完整 `blockDataSnapshot` 维护 -- `flushChunk()` 参与运行时正确性 -- `pendingUnloadFlushQueue` +### 10.3 将来恢复持久化时的接入点 -### 7.4 `PersistenceService` / `WorldStore` / `PersistenceWorker` +- world-level `blockData` 是唯一导入入口 +- world-level `blockData` 是唯一导出来源 +- 持久化层永远不得再次插入 runtime 正确性主链路 -保留职责: +## 11. 测试迁移矩阵 -- 冷存储读写 -- 旧档兼容转换 -- 手动保存或后台落盘 +### 11.1 必须新增的 invariant -退出职责: +1. loaded chunk 修改后,world-level `blockData` 是唯一立即可见真相 +2. chunk unload 后,reload 恢复的数据与 unload 前一致 +3. 世界生成结果直接进入 world-level `blockData` +4. 不构造 `blockDataSnapshot` 仍能保持 runtime 正确性 +5. `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 保持原职责 -- runtime 主链路权威读取 -- 运行时 blockData snapshot cache +### 11.2 需要改写的旧测试 -## 8. Migration Strategy +- 任何把 `flushChunk()`、`blockDataSnapshot`、`PersistenceService.cache` 当作 runtime 正确性前提的测试 +- 任何把 `MemoryWorldStore`、parked holder 或 unload 接班同步当成目标语义的测试 -### Phase 1: 语义收敛 +### 11.3 仍应保留的测试 -- 更新 `Chunk.blockData` 与 `MemoryWorldStore` 的职责定义 -- 不改现有功能行为 +- 旧档迁移测试可暂时保留但降级为 deferred +- AO / tombstone / late worker result 防护测试必须保留 +- `acceptScatteredBlocks` / `appendScatteredBlocks` 对隐藏块和跨 chunk 块的正确性测试必须保留 -### Phase 2: 去掉 runtime blockData 快照链 +## 12. Migration Strategy -- 收缩 `WorldRuntime.recordBlockMutation()` -- 停止构建完整 `blockDataSnapshot` -- `saveDebounced()` 只标记冷存储脏块 +### Phase 1: 语义收敛 -### Phase 3: 保存与导入改读内存权威 +- 把文档和注释全部收敛到“world-level `blockData` 唯一权威”语义 +- 删除 `MemoryWorldStore` 的目标地位 +- 建立 `Chunk.blockData` 作为 chunk 视图的语义 -- `Game.collectSnapshot()` 改从 `MemoryWorldStore` 读取 -- `Game.applySaveData()` 改直接写 `MemoryWorldStore` +### Phase 2: 统一所有 blockData 写入口 -### Phase 4: 收缩旧持久化缓存层 +- 穷尽写入口清单 +- 统一修改顺序为:`blockData -> 派生索引 -> 异步派生系统` +- 移除任何从索引层反推权威层的路径 -- deprecated `PersistenceService.cache` -- deprecated `pendingUnloadFlushQueue` -- 将 `WorldRuntime` 明确降级为冷存储协调器 +### Phase 3: 去掉 runtime blockData 快照链 + +- 收缩 `WorldRuntime.recordBlockMutation()` +- 停止构建完整 `blockDataSnapshot` +- `saveDebounced()` 只保留 runtime dirty 标记或直接去除其持久化职责 -### Phase 5: 真正评估消息粒度 / 持久化粒度优化 +### Phase 4: 推迟存档层实现 -- 区分 `postMessage` 消息体成本与 `IndexedDB.put(regionRecord)` 结构化复制成本 -- 再决定是否推进 chunk 级持久化 +- `PersistenceService` / `WorldStore` / `PersistenceWorker` 标记为 deferred +- 等 runtime 架构稳定后,再基于 world-level `blockData` 恢复导入导出 -## 9. Risks +## 13. Risks -1. 若过早删除 `PersistenceWorker.regionCache`,旧档冷启动可能退化 -2. 若 `collectSnapshot()` 改读内存后未先同步 loaded chunk,手动保存会漏最新修改 -3. 若错误地删除 `deletedBlockTombstones`,会重新引入晚到 Worker 回包脏写 -4. 若把特殊实体占位强塞进普通 `blockData`,会污染块语义与索引层 +1. 若 world-level `blockData` 实际上仍只是 `Chunk.blockData` 的拷贝集合,而不是唯一权威,则会重新引入双写与同步歧义 +2. 若只改单块修改路径,漏掉 scatter / batch / generation 等写入口,最终仍会存在双语义 +3. 若未明确 clone 边界,只会把深拷贝热点从 `WorldRuntime` 转移到新的 authority API +4. 若错误地删除 `deletedBlockTombstones`,会重新引入晚到 Worker 回包脏写 5. 若误把 `blockDataArray` / `solidBlockIds` 当成多余权威副本删除,会导致高频查询性能回退 -## 10. Success Criteria +## 14. Success Criteria 改造完成后,应满足以下判断: -1. 已加载 chunk 的逻辑真相只在 `Chunk.blockData` -2. 未加载 chunk 的逻辑真相只在 `MemoryWorldStore.chunks[].blockData` -3. `visibleKeys` / `solidBlocks` / `blockDataArray` 只作为派生索引存在 -4. runtime 正确性不再依赖任何 `blockDataSnapshot` / unload flush queue / PersistenceService.cache -5. `collectSnapshot()` 与 `applySaveData()` 不再依赖 `worldStore.getChunkRecord()` 或 `PersistenceService.injectSaveData()` -6. `IndexedDB` 只承担冷存储职责 +1. runtime 中逻辑真相只认 world-level `blockData` +2. `Chunk.blockData` 只是 chunk 视图,不再是第二权威 +3. 世界生成结果直接写入 world-level `blockData` +4. chunk unload 不再承担权威同步动作 +5. runtime 正确性不再依赖任何 `blockDataSnapshot` / unload flush queue / `PersistenceService.cache` +6. `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 继续存在,但只作为派生索引或高速查询缓存 +7. 持久化层即使暂时缺席,也不影响 runtime 正确性 From 6e1f73dfd1c6220b4266f20470c941d66883fe30 Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Fri, 8 May 2026 00:29:31 +0800 Subject: [PATCH 02/17] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=20blockData=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80=E8=AE=BE=E8=AE=A1=EF=BC=8C?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E9=9B=B6=E6=8B=B7=E8=B4=9D=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E4=B8=8E=E5=86=99=E5=85=A5=E5=8F=A3=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确 Chunk.blockData 与 WorldBlockDataStore 共享同一个 Map 实例 - 新增 deleteBlockEntry API,约束 replaceChunkSlice 仅用于低频整块装载 - 新增 Scatter/Cross-Chunk Patch 写入流程(7.2.1) - 热路径禁止双写、禁止 staging 副本、禁止 replaceChunkSlice 滥用 - 扩展保留清单覆盖 blockPalette/lightSourceCoords/dirtyAOPositions 等 - 新增 entityCollisionIndex 独立保留约束 Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 46 ++++++++++- ...-blockdata-authority-unification-design.md | 80 +++++++++++++++++-- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index 8901f99..d4d2dd9 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -90,9 +90,17 @@ Expected: - `getChunkSlice(cx, cz)` - `setBlockEntry(cx, cz, code, entry)` +- `deleteBlockEntry(cx, cz, code)` - `replaceChunkSlice(cx, cz, blockData)` - `hasChunkSlice(cx, cz)` +边界要求: + +- `WorldBlockDataStore` 的 runtime 内部主存储格式必须是 `Map` +- 每个 chunk slice 的主存储格式也必须是 `Map`,不能继续以普通对象作为 runtime 主存储 +- `replaceChunkSlice()` 只允许用于世界生成注入、未来导入、测试夹具、冷边界恢复 +- runtime 热路径禁止把 `replaceChunkSlice()` 当成单块修改或 scatter patch 的通用入口 + - [ ] **Step 4: 从 `World` 中移除 `MemoryWorldStore` 初始化与引用** 要求: @@ -131,6 +139,7 @@ Expected: PASS - 修改 `Chunk.blockData` 后,world-level `blockData` 同步可见 - reload 后新 chunk 视图看到相同数据 - chunk dispose 后 world-level `blockData` 仍保留 +- `Chunk.blockData` 与 `WorldBlockDataStore.getChunkSlice()` 返回同一个 `Map` 实例 - [ ] **Step 2: 运行测试,确认当前实现仍保留双 holder 语义** @@ -146,8 +155,11 @@ Expected: 要求: - `Chunk.blockData` 明确写成 world-level authority 的 chunk slice 视图 +- `Chunk.blockData` 必须直接引用 `WorldBlockDataStore` 内对应 chunk slice 的同一个 `Map` 实例 - `loadFromRecord()` / `_injectBlockData()` 的文档与行为同步调整 - 不再把 `Chunk.blockData` 视作未来 unload 时要转移出去的数据 +- 禁止继续保留“先写 chunk-local `blockData`,再同步 world-level authority”的实现方式 +- 禁止把“共享同一个 `Map` 实例”误实现成“任意调用方都可直接 `chunk.blockData.set/delete` 而不经过派生层更新” - [ ] **Step 4: 运行相关测试确认通过** @@ -185,6 +197,8 @@ Expected: PASS - `acceptScatteredBlocks()` - `appendScatteredBlocks()` - 世界生成结果注入路径 +- authority 写入后不得再对同一逻辑修改额外执行一次 `Chunk.blockData.set/delete` +- cross-chunk patch 时,每个目标 chunk 必须命中各自的 authority slice - [ ] **Step 2: 运行测试,确认当前覆盖存在缺口** @@ -199,11 +213,15 @@ Expected: 统一规则: -- 先写 world-level `blockData` +- 先写 `WorldBlockDataStore` - 通过 `Chunk.blockData` 视图反映到当前 chunk - 再更新 `visibleKeys` / `solidBlocks` / `blockDataArray` / `solidBlockIds` - 再更新 AO / render / tombstone 等异步派生层 - 禁止任何索引层反向决定逻辑真相 +- 禁止任何“先改 `Chunk.blockData`,再同步回 authority”的旧双写顺序 +- 禁止任何“写 authority 一次后,再对共享 `Chunk.blockData` map 补一次 `set/delete`”的重复 mutation +- `acceptScatteredBlocks()` / `appendScatteredBlocks()` 若涉及多个 chunk,必须对每个目标 chunk 分别写入对应 authority slice +- 禁止为 scatter / deferred patch 构造额外的 staging `blockData` 副本,等待后续再同步到 world-level authority - [ ] **Step 4: 让世界生成直接产出权威 blockData** @@ -238,12 +256,22 @@ Expected: PASS - [ ] **Step 1: 写失败测试,固定索引层职责** +说明: + +- 这里特别点名的结构,是热路径上最需要优先锁死的保留项 +- 但它们不是本次改造的完整保留清单 +- `Section 6` 中其他已列出的派生结构,也默认属于保留对象,只是测试优先级和强调程度不同 + 明确覆盖: - `Chunk.visibleKeys` 仍服务可见性判断 - `Chunk.solidBlocks` 仍服务碰撞判断 - `Chunk.blockDataArray` 仍作为 chunk 内紧凑快路径 - `Chunk.solidBlockIds` 仍配合数组路径做 O(1) 实心判断 +- `Chunk.blockPalette` / `Chunk.blockPaletteReverse` 仍支撑 `blockDataArray` 紧凑映射 +- `Chunk.lightSourceCoords` 仍服务光源索引 +- `Chunk.dirtyAOPositions` 仍服务 AO 增量刷新 +- `Chunk.instanceIndexMap` / `meshData` / `renderDelta` 仍服务渲染派生层 - [ ] **Step 2: 运行测试,确认索引层行为当前有覆盖缺口** @@ -259,8 +287,20 @@ Expected: - 明确保留 `deletedBlockTombstones` - AO mirror 继续从 `blockData` 派生 +- `lightSourceCoords` 继续从 `blockData` 派生,不退回全量扫描 +- `blockPalette` / `blockPaletteReverse` 继续作为 `blockDataArray` 配套结构存在 +- `instanceIndexMap` / `meshData` 继续服务局部渲染更新 - `renderDelta` 仍供全局实例系统消费 +- [ ] **Step 3.5: 明确保留范围不止四个热路径结构** + +要求: + +- 在实现说明、注释或测试命名中明确:`visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 是“重点锁定的热路径保留项”,不是完整保留清单 +- `blockPalette` / `blockPaletteReverse`、`lightSourceCoords`、`dirtyAOPositions`、`instanceIndexMap`、`meshData`、`renderDelta`、`deletedBlockTombstones` 仍默认保留 +- `entityCollisionIndex` 继续保持独立的特殊实体碰撞语义,不并入 `blockData` / `solidBlocks` +- 除非后续专项设计明确替代方案,本次 authority 重构不得顺手删除这些结构 + - [ ] **Step 4: 运行 chunk/world 相关测试** Run: @@ -358,6 +398,7 @@ Expected: PASS - 不再依赖整份 `blockDataSnapshot` - 不再用持久化理由对整 chunk 做热路径全量复制 +- `WorldBlockDataStore` / `Chunk.blockData` 共享同一个 `Map` 实例,而不是通过额外复制维持一致 - [ ] **Step 2: 运行测试确认当前实现仍存在快照式依赖** @@ -374,6 +415,9 @@ Expected: - 提供 chunk slice 视图读取接口 - 提供局部 block entry 更新接口 - 避免所有路径都走整份 chunk record 替换 +- 将 `replaceChunkSlice()` 的调用点限制在生成、导入、测试夹具、冷边界恢复 +- 为热路径保留局部 patch API,禁止单块修改/批量修改回退到整块替换 +- 通过测试或断言确保不会出现“authority 写一次 + chunk map 再写一次”的重复 mutation 流程 - [ ] **Step 4: 明确允许 clone 的边界** diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index c90a8b7..0ec9287 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -35,9 +35,10 @@ 1. 不重写 `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 的用途与算法 2. 不改变 AO / Face Culling / Global Instanced Mesh 的核心渲染策略 3. 不把特殊实体逻辑硬塞进普通 `blockData` 语义 -4. 不在本阶段实现新的 IndexedDB 持久化方案 -5. 不在本阶段推进 TypedArray 化的权威存储 -6. 不要求本次一并解决所有渲染性能热点 +4. 不把 `entityCollisionIndex` 并入 `blockData` 或 `solidBlocks` +5. 不在本阶段实现新的 IndexedDB 持久化方案 +6. 不在本阶段推进 TypedArray 化的权威存储 +7. 不要求本次一并解决所有渲染性能热点 ## 4. Current System Reality @@ -123,6 +124,7 @@ runtime 中只存在一份 world-level `blockData authority`: 2. **Chunk 运行时视图层** - 持有者:`Chunk.blockData` - 语义:指向 world-level `blockData` 中当前 chunk slice 的运行时访问入口 + - 存储身份:与 `WorldBlockDataStore` 内部该 chunk slice 共享同一个 `Map` 实例 - 允许写:可以,但写入必须直接命中 world-level authority - 生命周期:随 chunk 加载/卸载而出现/消失 @@ -132,6 +134,12 @@ runtime 中只存在一份 world-level `blockData authority`: - `unload`:销毁 chunk 实例视图 - world-level `blockData` 全程不发生“转移持有者” +补充约束: + +- “共享同一个 `Map` 实例”意味着不存在“先写 world-level authority,再把结果同步写回另一份 `Chunk.blockData` 副本”的第二次写入 +- `Chunk.blockData` 的共享身份只解决零拷贝视图问题,不意味着业务代码可以绕过受控写路径任意 `set/delete` +- runtime mutation 仍必须统一经过 `WorldAccessLayer`、`Chunk` 的受控 mutation 方法或 store 约定的单一写入口,以保证 `visibleKeys`、`solidBlocks`、`blockDataArray`、AO、tombstone、renderDelta` 等派生层一起更新 + ### 5.3 删除 `MemoryWorldStore` 本设计的目标态中: @@ -190,7 +198,7 @@ runtime 中只存在一份 world-level `blockData authority`: 语义:服务 chunk 内高频 `isSolid` / `resolveBlockOwner` / 局部块访问,不承担权威职责。 -需要特别确认保留: +以下四项是**热路径上需要特别强调的保留项**,但它们**不是完整保留清单**: - `Chunk.visibleKeys` - `Chunk.solidBlocks` @@ -206,6 +214,12 @@ runtime 中只存在一份 world-level `blockData authority`: 它们都应继续存在,只是语义上必须严格降级为**派生索引 / 高速查询缓存**。 +补充说明: + +- `Section 6` 各子节列出的结构默认都属于本次重构的保留对象 +- `6.4` 这里特别点名,是因为这四项直接位于最高频的碰撞 / `isSolid` / 局部查询热路径上 +- `blockPalette` / `blockPaletteReverse`、`lightSourceCoords`、`dirtyAOPositions`、`instanceIndexMap`、`meshData`、`renderDelta`、`deletedBlockTombstones` 等并不是可删项,只是它们的保留理由已在各自层级小节中单独说明 + ### 6.5 渲染载荷层 - Worker `meshData` @@ -233,6 +247,20 @@ runtime 中只存在一份 world-level `blockData authority`: 语义:实体域权威与碰撞域,不混入普通块逻辑真相。 +补充约束: + +- `entityCollisionIndex` 继续保持特殊实体碰撞占位的独立语义 +- 本次 authority 重构不把 `entityCollisionIndex` 合并进 `blockData` 或 `solidBlocks` +- 对 `entityCollisionIndex` 的修改不应被误纳入普通方块写入口改造 + +### 6.8 Section 6 保留范围总结 + +除非后续有单独专项设计明确替代方案,`Section 6` 中列出的各层结构默认都应保留: + +- 保留它们当前承担的查询、碰撞、渲染、AO、一致性保护职责 +- 只重新定义其权威边界和数据来源 +- 不在本次 runtime authority 重构中顺手删除或合并掉这些结构 + ## 7. Target Data Flow ### 7.1 世界生成 @@ -259,9 +287,9 @@ WorldGenerationService / WorldWorker ```text WorldAccessLayer.setBlock/removeBlock +-> WorldBlockDataStore.setBlockEntry() -> Chunk._updateBlockState() --> 修改 Chunk.blockData --> 同步写入 world-level blockData authority +-> Chunk.blockData 视图立即观察到 world-level authority 的变更 -> 增量更新 visibleKeys / solidBlocks / blockDataArray / lightSourceCoords / dirtyAOPositions / renderDelta -> AOBridge / tombstones 同步 -> 仅标记 chunk runtime dirty @@ -269,11 +297,33 @@ WorldAccessLayer.setBlock/removeBlock 关键约束: -- 真相先改 `blockData` -- `Chunk.blockData` 只是世界级权威的 chunk 访问入口 +- 真相先改 world-level `blockData authority` +- `Chunk.blockData` 只是世界级权威的 chunk 访问入口,与 authority chunk slice 共享同一个 `Map` 实例 +- 不得先写 chunk-local 副本再“同步回权威层”,也不得执行“authority 写一次 + `Chunk.blockData` 再写一次”的双写流程 +- 共享 `Map` 身份不放开任意直写;合法 mutation 仍必须走受控入口,确保派生索引同步 - 不再构造完整 `blockDataSnapshot` - 不再为了持久化链路即时 clone 整个 chunkRecord +### 7.2.1 Scatter / Cross-Chunk Patch 写入 + +目标链路: + +```text +BlockScatterManager / deferred cross-chunk patch +-> 将 patch 按目标 chunk 分组 +-> 对每个目标 chunk 命中各自的 WorldBlockDataStore chunk slice +-> Chunk.acceptScatteredBlocks() / appendScatteredBlocks() +-> 共享 Chunk.blockData 视图立即观察到对应 slice 变更 +-> 更新 visibleKeys / solidBlocks / blockDataArray / tombstone / meshData 等派生层 +``` + +关键约束: + +- `acceptScatteredBlocks()` / `appendScatteredBlocks()` 对逻辑方块的写入必须直接命中目标 chunk 的 authority slice +- 若 patch 涉及多个 chunk,必须分别写入各自 chunk slice,不得先落入某个临时 `blockData` 副本后再统一同步 +- 共享 `Map` 方案下,不允许对同一 patch 再执行一次额外的 `Chunk.blockData.set/delete` 补写 +- tombstone、hidden block、late worker result 过滤等现有一致性保护逻辑继续保留 + ### 7.3 Chunk 加载 目标链路: @@ -341,6 +391,7 @@ World.unloadChunk() - 所有逻辑修改必须先触达 `blockData` - 索引层只能被 `blockData` 驱动更新 - 不允许索引层反向决定逻辑真相 +- 不允许沿用“先改 `Chunk.blockData` 再同步第二处 holder”的旧双写模式 ## 9. 复制与序列化边界 @@ -363,9 +414,16 @@ World.unloadChunk() ### 9.3 API 边界约束 +- `WorldBlockDataStore` 在 runtime 内部的权威存储格式必须是 `Map` +- chunk slice 的主存储格式也必须是 `Map`,不得以普通对象作为 runtime 主存储格式 +- `Chunk.blockData` 必须直接引用 `WorldBlockDataStore` 内该 chunk slice 的同一个 `Map` 实例 - world-level `blockData` 的读取接口默认应返回只读视图或约定不可变结果 - 需要高性能时,应优先提供“取 chunk slice 视图”“同步指定字段”“直接挂载 chunk view”的 API - `Chunk.loadFromRecord()` 必须明确输入是“权威视图”还是“保护性快照”,不能混用 +- `replaceChunkSlice(cx, cz, blockData)` 只能用于生成器注入、未来导入、测试夹具、冷边界恢复等低频整块装载场景 +- 单块修改、批量改单块、scatter patch、普通 chunk unload / reload 等热路径禁止调用 `replaceChunkSlice()` +- `setBlockEntry()`、`deleteBlockEntry()`、批量局部 patch API 才是 runtime 热路径的合法写入口 +- 普通对象序列化形态只允许出现在 Worker 消息、测试快照、未来导出存档等边界,不得回流为 runtime 主存储 ## 10. Bootstrap / Import / Export Strategy @@ -402,6 +460,12 @@ World.unloadChunk() 3. 世界生成结果直接进入 world-level `blockData` 4. 不构造 `blockDataSnapshot` 仍能保持 runtime 正确性 5. `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 保持原职责 +6. `blockPalette` / `blockPaletteReverse` 继续支撑 `blockDataArray` 紧凑快路径 +7. `lightSourceCoords` / `dirtyAOPositions` 继续支撑光照与 AO 增量刷新 +8. `instanceIndexMap` / `meshData` / `renderDelta` 继续服务渲染派生层 +9. `deletedBlockTombstones` 继续保护晚到 Worker 回包一致性 +10. `Chunk.blockData` 与 `WorldBlockDataStore` chunk slice 共享同一个 `Map` 实例 +11. 热路径中不允许出现“authority 写一次 + chunk map 再写一次”的重复 mutation ### 11.2 需要改写的旧测试 From 1ed3544cc5bb23f4e66693539686df2a0f21b949 Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Fri, 8 May 2026 01:00:06 +0800 Subject: [PATCH 03/17] =?UTF-8?q?docs:=20=E5=85=A8=E9=9D=A2=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20blockData=20=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=AE=A1=E5=88=92=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=9B=BA=E5=8C=96=E9=98=B6=E6=AE=B5=E8=BE=B9=E7=95=8C=E3=80=81?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=20Map=20=E5=8D=8F=E8=AE=AE=E4=B8=8E=E8=A3=85?= =?UTF-8?q?=E9=85=8D=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增内容包括: - 阶段边界与 runtime-only 验收前提 - WorldBlockDataStore 完整存储契约(peek/ensure/replace 语义与 attached slice 引用稳定性) - 共享 Map 兼容性规则与 clear/replace 硬约束 - loadFromRecord/_injectBlockData 迁移矩阵 - RegionCache 降级方案 - AO Worker mirror 派生副本契约 - WorldGenerationService 到 WorldBlockDataStore 的单写入路径 - bootstrap 预生成链路(Task 3.5) - attach/hydrate/rebuild 装配协议(Task 2.5) - 统一 mutation 原语与写机制切换 - snapshot consumer 去留矩阵 - Worker 回包边界与读路径契约 - 新增 invariant 与竞态/生命周期测试清单 Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 387 ++++++++++- ...-blockdata-authority-unification-design.md | 614 ++++++++++++++++++ 2 files changed, 1000 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index d4d2dd9..74a396e 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -12,6 +12,18 @@ ## 文件结构与职责 +## 本阶段边界与验收前提 + +- 本阶段只交付 runtime 内存权威闭环,不交付 `IndexedDB`、手动存档、手动读档、JSON 导入导出。 +- 允许保留空接口、future hook、deprecated 壳层,但不允许让这些冷路径重新成为 runtime 正确性的依赖。 +- 本阶段的最低验收闭环是: + - 世界生成直接写入 world-level authority + - 玩家编辑直接命中 world-level authority + - chunk unload 只释放视图与派生层 + - chunk reload 从 world-level authority 恢复 + - AO / tombstone / renderDelta / scatter patch 语义不回退 +- 若实现过程中发现某一步必须重新依赖 `PersistenceService.cache`、`WorldStore.commitChunkRecord()`、`blockDataSnapshot` 或 `pendingUnloadFlushQueue` 才能保证 runtime 正确性,则应停止推进并回到设计层修正,而不是临时接回旧链路。 + ### 新增文件 - Create: `src/world/WorldBlockDataStore.js` @@ -36,6 +48,7 @@ - 区域 / chunk 生成结果直接写入 `WorldBlockDataStore` - Modify: `src/world/WorldRuntime.js` - 收缩 `blockDataSnapshot`、`pendingUnloadFlushQueue`、`flushChunk()` 热路径职责 + - 明确 `RegionCache` 降级为冷边界 / region 管理辅助层 - Modify: `src/world/ChunkPersistence.js` - 去掉运行中对 `flushChunk()` 的依赖 - Modify: `src/world/WorldAccessLayer.js` @@ -58,6 +71,46 @@ --- +### Task 0: 锁定 runtime-only 边界、数据模型与验收矩阵 + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md` +- Modify: `docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md` + +- [ ] **Step 1: 固定阶段边界** + +要求: + +- 明确本阶段只解决 runtime 内存权威闭环 +- 明确 `IndexedDB`、手动存档、手动读档、导入导出均非本阶段门槛 +- 明确允许保留空接口 / deprecated 壳层,但不允许回挂 runtime 正确性 + +- [ ] **Step 2: 固定 authority 数据模型** + +要求: + +- 明确 `WorldBlockDataStore` 只负责 world-level `blockData` slice +- 明确 `runtimeSeedData`、`staticEntities`、`runtimeEntities` 属于 chunk-level non-block payload +- 明确这些 non-block payload 不再以 `Chunk` 生命周期作为唯一持有语义 + +- [ ] **Step 3: 固定 runtime 最小闭环** + +要求: + +- 世界生成 -> authority -> loaded chunk attach / rebuild +- 玩家编辑 -> authority -> 派生层 +- unload -> 释放视图与派生层 +- reload -> 从 authority 恢复 + +- [ ] **Step 4: 固定验收矩阵** + +要求: + +- 文档中明确写出 runtime-only 成功标准 +- 明确哪些旧功能降级为 deferred + +--- + ### Task 1: 固定新的权威模型并删除 `MemoryWorldStore` **Files:** @@ -88,7 +141,8 @@ Expected: 至少包含: -- `getChunkSlice(cx, cz)` +- `peekChunkSlice(cx, cz)` 或语义等价接口 +- `ensureChunkSlice(cx, cz)` 或语义等价接口 - `setBlockEntry(cx, cz, code, entry)` - `deleteBlockEntry(cx, cz, code)` - `replaceChunkSlice(cx, cz, blockData)` @@ -96,10 +150,15 @@ Expected: 边界要求: +- `WorldBlockDataStore` 的外层结构必须明确固定为“chunkKey -> chunk slice”的映射,不允许留成实现者自由发挥 +- 推荐固定为 `Map>`,其中 `chunkKey = "${cx},${cz}"` - `WorldBlockDataStore` 的 runtime 内部主存储格式必须是 `Map` - 每个 chunk slice 的主存储格式也必须是 `Map`,不能继续以普通对象作为 runtime 主存储 +- `peekChunkSlice()` 不存在时返回 `null`,不得隐式创建 +- `ensureChunkSlice()` 不存在时创建空 slice 并返回共享实例 - `replaceChunkSlice()` 只允许用于世界生成注入、未来导入、测试夹具、冷边界恢复 - runtime 热路径禁止把 `replaceChunkSlice()` 当成单块修改或 scatter patch 的通用入口 +- 一旦某个 slice 已 attach 给 live chunk,禁止静默替换其 `Map` 实例;若必须整块替换,只能走 detach -> replace -> reattach 协议 - [ ] **Step 4: 从 `World` 中移除 `MemoryWorldStore` 初始化与引用** @@ -140,6 +199,7 @@ Expected: PASS - reload 后新 chunk 视图看到相同数据 - chunk dispose 后 world-level `blockData` 仍保留 - `Chunk.blockData` 与 `WorldBlockDataStore.getChunkSlice()` 返回同一个 `Map` 实例 +- attached slice 在 chunk 生命周期内保持引用稳定,不会因普通 rebuild 被静默替换成新的 `Map` - [ ] **Step 2: 运行测试,确认当前实现仍保留双 holder 语义** @@ -160,6 +220,16 @@ Expected: - 不再把 `Chunk.blockData` 视作未来 unload 时要转移出去的数据 - 禁止继续保留“先写 chunk-local `blockData`,再同步 world-level authority”的实现方式 - 禁止把“共享同一个 `Map` 实例”误实现成“任意调用方都可直接 `chunk.blockData.set/delete` 而不经过派生层更新” +- 明确区分: + - attach authority slice + - rebuild derived indexes + - restore non-block payload +- `loadFromRecord()` 若继续保留旧命名,必须在注释中写清输入是冷边界快照还是 authority attach 信息 +- `_injectBlockData()` / `_injectBlockDataBatch()` 若继续保留,只能承担“从边界数据创建或重建 authority/派生层”的职责,不能悄悄生成第二份 chunk-local 权威 +- 明确写出迁移矩阵: + - 哪些路径属于 cold input -> authority + - 哪些路径属于 authority -> attach + - 哪些路径属于 authority -> rebuild derived indexes - [ ] **Step 4: 运行相关测试确认通过** @@ -177,6 +247,94 @@ Expected: PASS --- +### Task 2.5: 明确 attach / hydrate / rebuild 的装配协议 + +**Files:** +- Modify: `src/world/Chunk.js` +- Modify: `src/world/World.js` +- Modify: `src/world/WorldRuntime.js` +- Test: `src/tests/test-chunk.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定装配协议** + +新增测试覆盖: + +- attach 只建立共享 slice,不复制逻辑真相 +- rebuild 派生索引时允许清空 `visibleKeys` / `solidBlocks` / `blockDataArray` 等,但不允许替换 authority slice +- scheduler 切片 hydrate 过程中,`Chunk.blockData` 始终指向 authority slice +- shared view 模式下,任何 `this.blockData.clear()` 都应被视为错误或被新实现彻底移除 +- `_clearForBlockInjection()`、`_injectBlockData()`、`_injectBlockDataBatch()` 不得再以“先清空 `this.blockData` 再重建”为实现前提 +- `peekChunkSlice()` 与 `ensureChunkSlice()` 的调用方语义不混用 +- `replaceChunkSlice()` 只允许在未 attach 或显式 detach 后的场景生效 + +- [ ] **Step 2: 调整装配 API 语义** + +要求: + +- 视情况拆分或重命名 `loadFromRecord()` / `_injectBlockData()` / `_injectBlockDataBatch()` +- 让代码层面能看出: + - 哪一步在 attach + - 哪一步在 rebuild + - 哪一步在恢复 `runtimeSeedData` / `staticEntities` / `runtimeEntities` +- 让代码层面能看出: + - 哪一步在处理 cold input -> authority +- 明确采用三段式协议: + - `WorldBlockDataStore.replaceChunkSlice()` / fill API + - `Chunk.attachAuthoritySlice()` 或等价挂载步骤 + - `Chunk.rebuildDerivedIndexesFromAuthority()` 或等价重建步骤 +- 若旧 helper 名称继续保留,必须通过注释和实现保证: + - 它不会对 shared `Chunk.blockData` 执行 `clear()` + - 它不会在 `Chunk` 内部偷偷新建第二份权威 `Map` + +- [ ] **Step 2.5: 拆除 shared Map 模式下的非法 clear 语义** + +要求: + +- 明确识别并移除以下旧模式: + - `_clearForBlockInjection()` 内直接 `this.blockData.clear()` + - `_injectBlockData()` / `_injectBlockDataBatch()` 先清空 authority 再回填 +- 若需要“整块替换 blockData”,必须改为 store 侧执行 `replaceChunkSlice()` +- `Chunk` 侧只允许: + - 重新 attach slice + - 清空并重建派生索引 +- 测试必须能证明:shared authority slice 在 chunk rebuild 过程中不会被清空 + +- [ ] **Step 2.75: 拆除 `loadFromRecord` / `_injectBlockData` 的旧复合职责** + +要求: + +- `loadFromRecord()` 不再被实现为“读取 plain object -> 写 `Chunk.blockData` -> 建索引”的单函数复合流程 +- `_injectBlockData()` 若保留,只能保留一种职责: + - cold input -> authority helper + - 或 authority -> derived indexes rebuild helper +- `_injectBlockDataBatch()` 若保留,必须明确是在分批 rebuild 派生索引,而不是分批写 chunk-local 逻辑真相 +- 禁止继续保留“一个 helper 同时负责写 `blockData` 和建索引”的旧实现 + +- [ ] **Step 2.8: 写失败测试,锁定迁移后的职责边界** + +新增测试覆盖: + +- `chunkRecord.blockData` 作为 plain object 输入时,先进入 authority,而不是直接写 chunk-local map +- authority slice 已存在时,`loadFromRecord()` 只做 attach / rebuild,不重复注入逻辑真相 +- rebuild 只影响派生索引,不影响 authority 内容 + +- [ ] **Step 3: 运行相关测试** + +Run: +- 测试页面运行 `test-chunk.js` +- 运行 `test-world-runtime.js` + +Expected: +- attach / hydrate / rebuild 语义稳定成立 + +- [ ] **Step 4: 运行 lint** + +Run: `npm run lint` +Expected: PASS + +--- + ### Task 3: 穷尽所有 blockData 写入口并统一顺序 **Files:** @@ -197,8 +355,12 @@ Expected: PASS - `acceptScatteredBlocks()` - `appendScatteredBlocks()` - 世界生成结果注入路径 +- `WorldGenerationService._generateRegion()` 完成后的 authority 接入路径 +- `World` / `Chunk` 接收 WorldWorker 结果后的 authority 接入路径 +- `acceptWorkerResult()` 一类直接装配 worker 元数据与派生层的路径 - authority 写入后不得再对同一逻辑修改额外执行一次 `Chunk.blockData.set/delete` - cross-chunk patch 时,每个目标 chunk 必须命中各自的 authority slice +- bootstrap 阶段生成出的未加载 chunk 数据也必须先存在于 authority,而不是等 chunk 加载后才补写 - [ ] **Step 2: 运行测试,确认当前覆盖存在缺口** @@ -223,6 +385,44 @@ Expected: - `acceptScatteredBlocks()` / `appendScatteredBlocks()` 若涉及多个 chunk,必须对每个目标 chunk 分别写入对应 authority slice - 禁止为 scatter / deferred patch 构造额外的 staging `blockData` 副本,等待后续再同步到 world-level authority +- [ ] **Step 3.2: 区分 authority 输入与 worker 派生层输入** + +要求: + +- 生成结果中的 `blockData` 必须先进入 authority +- worker 回包中的 `visibleKeys`、`solidBlocks`、`meshData`、`structureCenters` 等只允许作为派生层输入 +- `acceptWorkerResult()` 若继续直接装配派生层,必须建立在 authority 已写入且 chunk 已 attach 的前提下 +- 禁止把 worker 回包的派生层数据反向当成逻辑真相来源 + +- [ ] **Step 3.5: 建立统一 mutation 原语** + +要求: + +- 为 runtime 热路径提供唯一合法原语: + - `setBlockEntry()` + - `deleteBlockEntry()` + - 批量局部 patch API +- 除 attach / replace / 测试夹具外,业务代码禁止直接 `chunk.blockData.set/delete` +- 除 store 侧整块 replace 外,任何路径禁止对 shared `Chunk.blockData` 执行 `clear()` +- mutation 固定顺序必须可在代码中读出来: + - authority mutation + - 差异判定 + - 派生索引更新 + - AO / tombstone / renderDelta / render patch 更新 + - dirty 标记 / 异步派生调度 +- 测试或断言必须能发现“authority 写一次 + chunk map 再写一次”的重复 mutation + +- [ ] **Step 3.8: 完成双写机制切换** + +要求: + +- `_updateBlockState()` 一类 helper 中“写 `Chunk.blockData` + 同步写 `MemoryWorldStore`”的旧双写模式必须整体退出 +- 所有主要写路径都切换到: + - 写 `WorldBlockDataStore` + - 共享 `Chunk.blockData` 自动可见 + - 再更新派生层 +- 测试必须能证明:主线程不再需要 `memStore.applyBlockMutation()` 这类第二写入步骤 + - [ ] **Step 4: 让世界生成直接产出权威 blockData** 要求: @@ -230,6 +430,17 @@ Expected: - 生成器结果先进入 `WorldBlockDataStore` - 当前 chunk 已加载时,再 hydrate 到 `Chunk.blockData` - 不再为持久化缓存额外铺第二条热路径 +- 当前 chunk 未加载时,authority 仍应持有对应 slice,等待未来 chunk attach +- `runtimeSeedData`、`staticEntities`、`runtimeEntities` 若随生成阶段一起产出,也必须写入对应的 world-level / registry 层,而不是继续只暂存在 chunk 生命周期中 + +- [ ] **Step 4.5: 明确禁止 world generation 双写旧持久化层** + +要求: + +- 本阶段 `WorldGenerationService` 采用 single-write to runtime authority +- `_generateRegion()`、expand 路径、cross-region overflow 合并路径不得再把 `WorldStore.saveRegionRecord()`、`saveWorldMeta()`、`commitChunkRecord()` 当成 runtime 正确性的必要步骤 +- 若暂时保留这些接口调用,必须显式标记为 deferred / future hook,且失败不会影响当前会话 runtime 正确性 +- `region` 可以继续作为生成 / 路由 / 分批调度单位存在,但 `region persistence` 不再是生成主链路 - [ ] **Step 5: 运行相关测试确认通过** @@ -247,6 +458,67 @@ Expected: PASS --- +### Task 3.5: 打通 bootstrap 预生成到 WorldBlockDataStore 的主链路 + +**Files:** +- Modify: `src/world/WorldGenerationService.js` +- Modify: `src/world/World.js` +- Modify: `src/world/WorldRuntime.js` +- Modify: `src/world/WorldBlockDataStore.js` +- Test: `src/tests/test-world.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定 bootstrap generation authority 链路** + +新增测试覆盖: + +- `generateInitialWorld()` 完成后,尚未加载的目标 chunk 也已在 `WorldBlockDataStore` 中拥有对应 slice +- bootstrap chunk 后续首次加载时,走的是 attach / rebuild,而不是把旧 `WorldStore` 当成 live truth 回源 +- 不依赖 `saveRegionRecord()` 也能完成同会话内 bootstrap -> load -> unload -> reload 闭环 +- cross-region overflow 在 bootstrap 路径里仍能正确并入目标 authority slice + +- [ ] **Step 2: 重构 `generateInitialWorld()` / `_generateRegion()` 的 runtime 落点** + +要求: + +- 预生成结果的 runtime 主落点改为 `WorldBlockDataStore` +- `_writeRegionToMemoryStore` 一类旧内存权威写入路径必须退出 +- `RegionRecord` / chunk 结果整理后,先写 authority,再决定是否触发已加载 chunk 的 attach / rebuild +- `WorldStore.saveRegionRecord()`、`saveWorldMeta()` 若暂时保留,只能作为 deferred / future hook,不得作为 runtime 正确性的必要步骤 + +- [ ] **Step 3: 处理 bootstrap 路径中的 overflow 与未加载 chunk** + +要求: + +- cross-region overflow 合并后,目标 chunk 的 `blockData` 必须先进入 authority +- 目标 chunk 尚未加载时,不得因为没有 live chunk view 就跳过写入 +- bootstrap 期间 authority 必须能够独立持有未加载 chunk 的 slice,等待未来 attach + +- [ ] **Step 4: 对齐 bootstrap chunk 的后续加载来源** + +要求: + +- bootstrap 后续通过 `World.update()` 创建 chunk 时,应优先从 `WorldBlockDataStore` attach / rebuild +- 不允许重新退回“先从旧 `WorldStore` / region persist 读取,再把结果当真相注入 chunk”的旧路径 +- 若冷边界接口暂时保留,必须在代码注释中明确:bootstrap runtime 真相已由 authority 持有 + +- [ ] **Step 5: 运行相关测试** + +Run: +- 测试页面运行 `test-world.js` +- 运行 `test-world-runtime.js` + +Expected: +- bootstrap generation 到 authority 的主链路稳定成立 +- 之后的 chunk load / unload / reload 闭环不再依赖旧持久化路径 + +- [ ] **Step 6: 运行 lint** + +Run: `npm run lint` +Expected: PASS + +--- + ### Task 4: 明确保留 chunk 层索引结构 **Files:** @@ -348,6 +620,15 @@ Expected: - 停止首次脏化时创建完整 `blockDataSnapshot` - 收缩为 runtime dirty 标记,或直接由 chunk/local state 接管 +- [ ] **Step 3.5: 降级 `RegionCache` 中的 blockData 语义** + +要求: + +- 明确 `RegionCache` 不再承担 runtime live `blockData` 权威职责 +- `cachedChunkRecord.blockData` 只允许作为冷边界输入或测试夹具输入 +- authority 建立后,runtime 热路径不得继续依赖 `_resolveSerializedBlockData()` 一类逻辑从 `RegionCache.blockData` 取 live truth +- 若 `RegionCache` 仍保留 chunk record / entity / region 管理信息,必须在代码注释中明确其降级身份 + - [ ] **Step 4: 修改 `ChunkPersistence.saveDebounced()`** 要求: @@ -362,6 +643,21 @@ Expected: - 注释标明不再参与 runtime 正确性 - 若暂时保留,仅作为未来冷存储恢复时的兼容工具 +- [ ] **Step 5.5: 为 snapshot 消费者逐一定义命运** + +要求: + +- 对以下路径逐一标记为 delete / deferred shell / authority-based rewrite: + - `_resolveSerializedBlockData()` + - `flushChunk()` + - `flushBeforeUnload()` + - `flushAllDirty()` + - `_ensureDirtyChunkEntry()` 中 `blockDataSnapshot` 相关字段 +- 禁止留下“snapshot 已不再可靠,但消费者仍沿用旧回退链”的中间态 +- 若某方法继续保留,必须在注释中明确: + - 它是否仍参与 runtime 主链路 + - 它若取数据,新来源是什么 + - [ ] **Step 6: 运行运行时相关测试** Run: @@ -384,6 +680,7 @@ Expected: PASS - Modify: `src/world/WorldBlockDataStore.js` - Modify: `src/world/World.js` - Modify: `src/world/Chunk.js` +- Modify: `src/world/WorldRuntime.js` - Test: `src/tests/test-world-runtime.js` - [ ] **Step 1: 写失败测试,固定“热路径不允许全量 clone”约束** @@ -418,6 +715,7 @@ Expected: - 将 `replaceChunkSlice()` 的调用点限制在生成、导入、测试夹具、冷边界恢复 - 为热路径保留局部 patch API,禁止单块修改/批量修改回退到整块替换 - 通过测试或断言确保不会出现“authority 写一次 + chunk map 再写一次”的重复 mutation 流程 +- 明确 `replaceChunkSlice()` 的职责是 store 侧整块 authority 替换,而不是 `Chunk` 侧“clear 后重灌” - [ ] **Step 4: 明确允许 clone 的边界** @@ -426,6 +724,7 @@ Expected: - Worker 消息边界 - 测试快照 - 未来导出存档 +- 未来冷存储恢复前的显式序列化边界 - [ ] **Step 5: 运行相关测试** @@ -449,6 +748,7 @@ Expected: PASS - Modify: `src/services/PersistenceService.js` - Modify: `src/world/WorldStore.js` - Modify: `src/workers/PersistenceWorker.js` +- Modify: `src/world/WorldRuntime.js` - Test: `src/tests/test-world-runtime.js` - [ ] **Step 1: 写失败测试,固定“runtime 不再依赖旧持久化层”** @@ -458,6 +758,7 @@ Expected: PASS - `PersistenceService.cache` 不再承载 runtime blockData 权威 - `WorldStore.getChunkRecord()` 不是 runtime 主链路的必需前提 - `collectSnapshot()` / `applySaveData()` 若继续保留,应显式标记 deferred 或兼容路径 +- `RegionCache.blockData` 不再是 runtime 热路径的 live truth 来源 - [ ] **Step 2: 运行测试,确认当前实现仍保留旧缓存职责** @@ -474,6 +775,9 @@ Expected: - `PersistenceService.injectSaveData()`、`snapshotChunkBlocks()`、`hydrateChunkBlocks()`、`replaceChunkBlocks()` 标记 deprecated - `WorldStore`、`PersistenceWorker` 标记为冷存储 deferred - `Game.collectSnapshot()`、`Game.applySaveData()` 若不立即重写,至少明确为非本阶段门槛 +- `WorldRuntime.ensureChunkData()` 若暂时仍承接冷边界输入,必须在注释中明确:它不再是 runtime 正确性的权威来源,只是未来冷恢复接入点或过渡边界 +- `WorldGenerationService` 若暂时仍保留 `saveRegionRecord()` / `saveWorldMeta()` 一类调用,必须在注释中明确:这些调用不是本阶段生成正确性的组成部分 +- `RegionCache` 若继续缓存带 `blockData` 的 chunkRecord,也必须在注释中明确:这些 `blockData` 只用于冷边界输入,不得作为 live authority - [ ] **Step 4: 运行运行时测试** @@ -490,6 +794,48 @@ Expected: PASS --- +### Task 7.5: 明确 AO Worker mirror 的派生副本契约 + +**Files:** +- Modify: `src/core/AOBridge.js` +- Modify: `src/world/Chunk.js` +- Modify: `src/workers/AOWorker.js` +- Test: `src/tests/test-chunk.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定 AO mirror 不是第二权威** + +测试覆盖: + +- `aoBridge.enqueueSet/delete/batch` 继续作为 authority -> AO mirror 的单向同步路径 +- AO Worker mirror 延迟或重建不会改变 runtime `blockData` 真相 +- AO 结果回包不会反向覆写 authority + +- [ ] **Step 2: 明确代码边界** + +要求: + +- `AOWorker` 不直接访问 `WorldBlockDataStore` +- `AOBridge` 继续承担 authority 到 worker mirror 的单向同步 +- 注释中明确 AO mirror 是派生副本,不是第二权威 +- 若 AO mirror 需要全量重播或重新播种,来源必须是 authority 序列化边界,而不是旧 cache / snapshot + +- [ ] **Step 3: 运行相关测试** + +Run: +- 测试页面运行 `test-chunk.js` +- 运行 `test-world-runtime.js` + +Expected: +- AO mirror 契约成立,且不与唯一 authority 语义冲突 + +- [ ] **Step 4: 运行 lint** + +Run: `npm run lint` +Expected: PASS + +--- + ### Task 8: 完整回归与性能验证 **Files:** @@ -518,6 +864,45 @@ Expected: 4. 返回原区块触发 reload Expected: +- 方块状态与 unload 前一致 +- 不需要 `flushChunk()` / `PersistenceService.cache` / `WorldStore.commitChunkRecord()` 也能完成 runtime reload + +- [ ] **Step 2.5: 手动验证跨 chunk patch 与玩家修改不互相覆盖** + +手动步骤: + +1. 触发带跨 chunk 结构的生成 / 补片 +2. 在目标 chunk 对相关位置做玩家修改 +3. 离开再返回,或等待 deferred patch / finalize 路径完成 + +Expected: +- authority 仍以玩家最终修改为准 +- 不会出现旧 scatter / append 结果回写覆盖玩家修改 + +- [ ] **Step 2.75: 手动验证未加载 chunk 的生成结果已先进入 authority** + +手动步骤: + +1. 触发 bootstrap 预生成或 runtime 扩图 +2. 选择一个当下尚未加载、但已生成过的目标 chunk +3. 后续移动到该区域触发 chunk 加载 + +Expected: +- 该 chunk 加载前,对应 slice 已存在于 `WorldBlockDataStore` +- 加载时执行的是 attach / rebuild,而不是重新向旧持久化层回源作为真相来源 +- 不依赖 `saveRegionRecord()` 也能完成当前会话内的生成 -> 未来加载闭环 + +- [ ] **Step 3: 手动验证 runtime-only 模式下的降级边界** + +手动步骤: + +1. 不执行手动存档 / 读档 +2. 只验证当前会话内的生成、编辑、卸载、重载闭环 +3. 检查控制台无把旧持久化路径当成 runtime 必要前提的报错 + +Expected: +- runtime-only 闭环稳定 +- 冷存储路径缺席不会破坏当前会话内正确性 - 方块状态正确恢复 - [ ] **Step 3: 手动验证世界生成结果直接进入 blockData** diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index 0ec9287..d9719af 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -17,6 +17,35 @@ - chunk unload 不再承担“权威转移”职责 - 所有设计优先服务“运行中的正确性、清晰性、可优化性” +## 1.1 Phase Boundary + +这一阶段是一个**runtime-only 的内存权威重构阶段**。 + +本阶段**必须保证**: + +1. 运行中的内存权威语义清晰且唯一 +2. loaded chunk 的编辑、生成、卸载、重载都围绕 world-level `blockData` 闭环 +3. 后续性能优化可以围绕唯一 authority 落地,不再被双写、快照链、深拷贝牵制 + +本阶段**明确不作为交付门槛**: + +1. `IndexedDB` 持久化正确性 +2. 手动存档 +3. 手动读档 +4. JSON 导入 / 导出 +5. 旧存档兼容性完整恢复 + +允许的过渡形态: + +- 保留空接口、future hook、deprecated 壳层 +- 保留冷存储 API 的最小兼容签名 +- 保留用于测试夹具或下一阶段接入的序列化边界 + +不允许的回退: + +- 为了保住旧持久化路径,把 runtime 正确性重新挂回 `WorldStore` / `PersistenceService` / `PersistenceWorker` +- 为了兼容旧接口,在热路径重新引入 `blockDataSnapshot`、整块 clone、卸载前同步 holder + ## 2. Goal 本设计的目标是把运行时的逻辑真相统一收敛到 `blockData` 语义上,并在此基础上保留当前所有关键功能: @@ -28,6 +57,28 @@ 5. `Chunk.visibleKeys`、`Chunk.solidBlocks`、`Chunk.blockDataArray`、`Chunk.solidBlockIds` 继续保留,作为 chunk 层派生索引或高速查询结构 6. 消灭运行时的 blockData 全量快照链,减少 clone、序列化和跨线程传输次数,为后续性能优化打基础 +## 2.1 Runtime Minimum Closed Loop + +在不实现持久化的前提下,本阶段至少要形成以下 runtime 最小闭环: + +1. **世界生成** + - 生成结果直接进入 world-level authority + - 当前 chunk 若已加载,直接挂载对应 slice 并重建派生层 + +2. **运行时编辑** + - 玩家放置 / 删除 / 批量修改先命中 authority + - loaded chunk 通过共享 slice 立即观察到变更 + - 派生索引、AO、render patch 随后增量更新 + +3. **chunk unload / reload** + - unload 只销毁运行时视图与派生层 + - reload 从 world-level authority 重新 attach / hydrate + - 不再依赖 unload flush、session cache、`WorldStore.commitChunkRecord()` + +4. **跨 chunk patch** + - 所有 patch 直接命中目标 chunk 的 authority slice + - 不允许先堆积在某个 chunk-local staging blockData 中等待二次同步 + ## 3. Non-Goals 本次设计明确不做以下事情: @@ -140,6 +191,23 @@ runtime 中只存在一份 world-level `blockData authority`: - `Chunk.blockData` 的共享身份只解决零拷贝视图问题,不意味着业务代码可以绕过受控写路径任意 `set/delete` - runtime mutation 仍必须统一经过 `WorldAccessLayer`、`Chunk` 的受控 mutation 方法或 store 约定的单一写入口,以保证 `visibleKeys`、`solidBlocks`、`blockDataArray`、AO、tombstone、renderDelta` 等派生层一起更新 +### 5.2.1 Shared Map Compatibility Rules + +“共享同一个 `Map` 实例”是本次重构的性能核心,但它必须配套更严格的生命周期协议。 + +在 shared authority view 模式下: + +1. `Chunk.blockData` 不再是可随意清空、重建、替换的 chunk-local 容器 +2. `Chunk.blockData.clear()`、`Chunk.blockData = new Map()`、`for (...) delete + set` 这类直接改写整个容器身份或整体内容的行为,都必须被视为高风险操作 +3. 除非语义上就是“删除整个 authority slice”,否则 `clear()` 在 shared view 模式下一律视为非法 + +这意味着当前旧实现里这类模式必须被设计层显式废止: + +- `_clearForBlockInjection()` 通过 `this.blockData.clear()` 开始一次“重新注入” +- `_injectBlockData()` / `_injectBlockDataBatch()` 先清空 `this.blockData` 再逐条写入 + +这些做法在 chunk-local holder 模式下可行,但在 shared authority view 模式下会直接清空 world-level authority,因此不能带入新模型。 + ### 5.3 删除 `MemoryWorldStore` 本设计的目标态中: @@ -154,6 +222,117 @@ runtime 中只存在一份 world-level `blockData authority`: - world-level chunk record 索引 - chunk 视图的加载和释放 +### 5.3.1 Authority Data Model + +本阶段建议把 world-level authority 拆成两个明确层次: + +1. **`WorldBlockDataStore`** + - 只负责 chunk 级 `blockData` slice 的持有、替换、局部 mutation、读取视图挂载 + - runtime 主存储格式固定为 `Map` + +2. **chunk-level non-block payload registry** + - 负责与 `blockData` 同生命周期存在、但不属于普通块逻辑真相的 chunk 元数据 + - 至少包括: + - `runtimeSeedData` + - `staticEntities` + - `runtimeEntities` + - 这些数据可以暂时不并入 `WorldBlockDataStore` 同一个类,但必须在语义上明确:它们不再依赖 `Chunk` 生命周期作为唯一持有者 + +约束: + +- 本阶段重构的中心是 `blockData authority` +- 但设计文档必须显式指出:`blockData` 之外的 chunk payload 不能继续处于“谁最后碰到了谁就临时持有”的模糊状态 +- 若暂不统一实现容器,也必须在接口命名和注释中写明其未来接入点 + +补充说明: + +- `WorldBlockDataStore` 不是“仅服务已加载 chunk 的 cache” +- 它必须能在 chunk 尚未加载时就持有对应的 chunk slice +- 也就是说,world-level authority 是世界逻辑真相仓库,而不是 loaded chunk 的附属物 + +### 5.3.2 WorldBlockDataStore Storage Contract + +`WorldBlockDataStore` 不能只停留在“里面用 `Map`”这一层描述,还必须明确它的外层结构与 API 语义,否则 shared chunk view 的设计无法稳定落地。 + +本阶段建议固定为: + +1. **外层结构** + - `Map>` + - 外层 key 使用标准 `chunkKey = "${cx},${cz}"` + - 内层 value 是该 chunk 的 authority slice:`Map` + +2. **内层结构** + - 必须是 `Map` + - 不允许在 runtime 主存储中回退为普通对象 + +3. **读取接口应区分 peek 与 ensure** + - `peekChunkSlice(cx, cz)` + - 只查询 + - 不存在时返回 `null` + - 不得隐式创建新 slice + - `ensureChunkSlice(cx, cz)` + - 查询或创建 + - 不存在时创建空 `Map` 并返回 + - 返回值可作为未来 `Chunk.blockData` attach 的共享实例 + +4. **若继续保留 `getChunkSlice()` 命名** + - 必须在文档与注释中明确它究竟是 `peek` 还是 `ensure` + - 不允许让不同调用方对其语义做不同假设 + +推荐原因: + +- 这样可以避免“查询接口顺手创建新 slice”导致的隐式状态增长 +- 也可以避免“调用方以为一定会创建,结果拿到 `null`”造成后续 attach / patch / generation 流程分叉 + +### 5.3.3 Attached Slice Reference Stability + +一旦某个 chunk slice 已经 attach 给 live `Chunk.blockData`,其引用稳定性必须被视为设计契约: + +1. 在该 chunk 生命周期内,除非显式 detach / reattach,否则不应悄悄更换其背后的 `Map` 实例 +2. `Chunk.blockData` 的共享引用一旦建立,后续普通 rebuild 只能重建派生索引,不能替换 authority slice 引用 +3. 如果实现需要整块替换 authority slice,必须首先满足: + - 当前没有 live chunk view attach 到该 slice + - 或者先显式 detach 旧 view,再 replace,最后重新 attach + +换句话说: + +- `Chunk.blockData` 的共享身份不是“每次随便拿一个最新 Map” +- 而是“attach 后在当前生命周期内具有引用稳定性” + +### 5.3.4 replaceChunkSlice Contract + +`replaceChunkSlice(cx, cz, entries)` 必须被定义为**低频 authority lifecycle API**,而不是普通热路径 mutation API。 + +允许场景: + +- 世界生成注入 +- 未来导入 +- 测试夹具 +- 冷恢复 + +不允许场景: + +- 单块修改 +- 批量改单块 +- scatter patch +- deferred patch +- 普通 unload / reload 热路径 + +额外约束: + +1. 若目标 slice 尚未 attach 给 live chunk,可直接 replace 为新的 `Map` +2. 若目标 slice 已 attach 给 live chunk,禁止直接 replace 其 `Map` 实例 +3. 对已 attach slice 的“整块刷新”必须走显式协议: + - detach old view + - replace at store + - reattach chunk view + - rebuild derived indexes + +因此,`replaceChunkSlice()` 不应被实现为: + +- `Chunk` 侧 `clear + repopulate` +- 对 live attached slice 静默换引用 + ### 5.4 冷存储完全退出本阶段主链路 本阶段 runtime 正确性不再依赖: @@ -169,6 +348,33 @@ runtime 中只存在一份 world-level `blockData authority`: - 延后恢复的冷存储能力 - 暂不作为 runtime 改造的阻塞条件 +### 5.4.1 RegionCache Demotion + +`RegionCache` 在当前代码里承载了 region 管理、chunk record 缓存与部分 `blockData` 读取前提,这使它非常容易在 `MemoryWorldStore` 删除后被误用成新的“半权威层”。 + +本阶段必须明确把 `RegionCache` 降级: + +1. **允许保留的职责** + - region 级加载状态与并发控制 + - chunk 非 `blockData` payload 的冷边界缓存 + - 未来冷恢复 / 批量加载的过渡上下文 + +2. **不允许保留的职责** + - runtime live `blockData` 权威持有 + - loaded chunk 热路径上的逻辑真相读取来源 + - unload / reload 正确性的最终依据 + +3. **对 `cachedChunkRecord.blockData` 的约束** + - 只允许作为冷边界输入 + - 只允许作为测试夹具输入 + - 只允许作为一次性导入到 `WorldBlockDataStore` 的源对象 + - 一旦对应 chunk slice 已进入 authority,后续 runtime 热路径不得继续把 `cachedChunkRecord.blockData` 当成 live truth 读取 + +换句话说: + +- `RegionCache` 可以保留 +- 但它必须从“权威候选层”降级为“冷边界 / region 管理辅助层” + ## 6. Target Layering ### 6.1 权威逻辑层 @@ -238,6 +444,33 @@ runtime 中只存在一份 world-level `blockData authority`: 语义:AO / 光照增量刷新与晚到回包保护。 +### 6.6.1 AO Worker Mirror Contract + +AO Worker 的 `blockData` 镜像在新模型中仍然允许存在,但它必须被严格定义为: + +- **authority 派生出的计算副本** + +而不是: + +- 第二权威 +- runtime 逻辑真相来源 +- 可反向写回主线程 authority 的 holder + +本阶段明确采用: + +1. 保持当前 `aoBridge.enqueueSet()` / `enqueueDelete()` / `enqueueBatch()` 的单向同步模型 +2. AO Worker 不直接访问 `WorldBlockDataStore` +3. AO Worker 镜像只服务于 AO 增量计算与一致性保护 + +必须满足的约束: + +- AO mirror 的更新方向只能是 `authority -> AO mirror` +- AO Worker 计算结果不能反向改写 `blockData authority` +- AO mirror 丢失后可以从 authority 重新播种 +- AO mirror 延迟、失败或被重建,只影响 AO 新鲜度,不影响 runtime 逻辑正确性 + +因此,AO Worker mirror 虽然是数据副本,但它属于**派生副本**,不与“唯一权威”设计冲突 + ### 6.7 实体层 - `runtimeEntities` @@ -281,6 +514,65 @@ WorldGenerationService / WorldWorker - 不通过 `PersistenceService.cache` - 不为“未来存档”额外维护第二条热路径 +### 7.1.1 WorldGenerationService / WorldBlockDataStore Contract + +本阶段必须明确:`WorldGenerationService` 的运行时主产物是 `WorldBlockDataStore`,不是 `WorldStore`。 + +也就是说: + +1. bootstrap 预生成 +2. runtime 扩图 +3. cross-region overflow 合并 + +这三类路径都应先写入 world-level authority。 + +推荐链路: + +```text +WorldGenerationService._generateRegion() +-> 生成 chunk blockData / runtimeSeedData / staticEntities / routing overflow +-> blockData 直接写入 WorldBlockDataStore +-> non-block payload 写入 chunk-level non-block payload registry +-> 若对应 chunk 已加载,则 attach / rebuild +-> 若对应 chunk 未加载,则仅保留在 authority 中,等待未来 attach +``` + +本阶段明确采用: + +- **single-write to runtime authority** + +补充强调: + +- bootstrap 预生成路径是本阶段第一条必须稳定打通的大规模 authority 写入链路 +- 若这条链路仍停留在旧的 `WorldStore` / `MemoryWorldStore` 双落点模型,后续 chunk attach / rebuild / reload 语义将无法被正确验证 + +本阶段明确不采用: + +- `WorldBlockDataStore` + `WorldStore` 双写 +- 生成正确性依赖 `saveRegionRecord()` / `saveWorldMeta()` / `commitChunkRecord()` + +如果保留这些旧接口: + +- 它们只能是 deferred / future hook +- 不能是本阶段生成完成的必要步骤 + +### 7.1.2 Region Concept vs Region Persistence + +本阶段必须把两个概念拆开: + +1. **region as generation/routing unit** + - 可以保留 + - 用于批量生成、overflow 路由、分批调度、边界扩图 + +2. **region persistence as cold storage mechanism** + - 不再属于本阶段运行时主链路 + - 可以保留接口壳层,但只作为下一阶段冷存储恢复接入点 + +换句话说: + +- `region` 这个计算与调度概念可以继续存在 +- 但 `saveRegionRecord()` 一类持久化动作不能继续作为 world generation 的正确性前提 + ### 7.2 单块修改 目标链路: @@ -342,6 +634,164 @@ World 请求 chunk - Worker 不能直接改写逻辑真相 - `Chunk.blockData` 不是第二权威,而是权威数据的 chunk 视图 +- chunk 是否已加载,不影响 world-level authority 是否可以预先持有其 slice + +### 7.3.1 Hydrate / Attach Protocol + +本阶段必须把 `loadFromRecord()` 一类 API 的语义彻底改清楚。 + +目标不是“把 `chunkRecord.blockData` 再复制一份到 `Chunk.blockData`”,而是: + +1. 先确保 world-level authority 中存在目标 chunk slice +2. `Chunk` attach 到该 slice,令 `Chunk.blockData` 直接引用该 `Map` +3. 基于该 slice 重建: + - `visibleKeys` + - `solidBlocks` + - `blockDataArray` + - `solidBlockIds` + - `blockPalette` / `blockPaletteReverse` + - `lightSourceCoords` +4. Worker 只消费 authority 的序列化边界产物去生成 mesh / visibleKeys / AO 派生结果 + +因此需要区分两类动作: + +- **attach authority slice** + - 建立 `Chunk.blockData` 与 world-level slice 的共享引用 + - 不负责复制逻辑真相 + +- **rebuild derived indexes** + - 从共享 slice 重建当前 chunk 的派生结构 + - 允许清空索引层 + - 不允许替换 authority slice 本身 + +本阶段推荐固定为以下三段式协议: + +1. **store replace / fill** + - 由 `WorldBlockDataStore` 负责创建、填充、替换目标 chunk slice + - 推荐 API: + - `replaceChunkSlice(cx, cz, entries)` + - `applyChunkPatch(cx, cz, entries)` + - `Chunk` 不直接承担整块 `blockData` 注入责任 + +2. **chunk attach** + - `Chunk` 通过 `attachAuthoritySlice(cx, cz)` 或等价步骤挂接 store 内现有 slice + - `Chunk.blockData` 只在这一阶段获得共享引用 + - attach 本身不复制逻辑真相 + +3. **rebuild derived indexes** + - `Chunk` 从共享 slice 重建 `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds`、`blockPalette`、`lightSourceCoords` + - 这一步可以清空派生层 + - 但严禁清空或替换 authority slice + +这三步中,只有第 1 步允许改变整块逻辑真相;第 2、3 步都不允许改变 authority 内容。 + +额外约束: + +- `loadFromRecord()`、`_injectBlockData()`、`_injectBlockDataBatch()` 这类旧命名若继续保留,必须在注释中明确: + - 输入到底是“authority slice attach 信息”还是“冷边界对象快照” + - 哪些场景允许清空重建 + - 哪些场景严禁替换当前共享 `Map` 实例 +- 更进一步地说,若这些旧 API 名称持续暗示“Chunk 自己拥有并注入 blockData”,则应优先拆分或重命名为更准确的协议层名称,例如: + - `replaceAuthoritySliceFromRecord()` + - `attachAuthoritySlice()` + - `rebuildDerivedIndexesFromAuthority()` + +### 7.3.1.1 loadFromRecord / _injectBlockData Migration Matrix + +本阶段必须把 `loadFromRecord()`、`_injectBlockData()`、`_injectBlockDataBatch()` 的旧复合职责拆开,否则 shared authority view 设计会在实施时被旧 mental model 拉回去。 + +当前旧语义大致是: + +1. 从 `chunkRecord.blockData`(plain object)读取条目 +2. 逐个写入 `Chunk.blockData` +3. 同时填充 `blockPalette`、`blockDataArray`、`solidBlocks`、`solidBlockIds` + +新模型下,这三步必须分流为三类完全不同的职责: + +1. **cold input -> authority** + - 输入来源: + - `chunkRecord.blockData` plain object + - 世界生成结果 + - 测试夹具 + - 未来导入数据 + - 新语义: + - 不再直接写 `Chunk.blockData` + - 先进入 `WorldBlockDataStore.replaceChunkSlice()` 或等价 cold-fill API + +2. **authority -> chunk view attach** + - 输入来源: + - 已存在于 authority 的 chunk slice + - 新语义: + - `Chunk.blockData` 直接引用 authority slice + - 不复制逻辑真相 + +3. **authority -> derived indexes rebuild** + - 输入来源: + - attach 后首次装配 + - runtime rebuild / reload + - 派生层失效后的重建 + - 新语义: + - 清空并重建 `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds`、`blockPalette`、`lightSourceCoords` + - 严禁写 authority 内容 + +因此: + +- `loadFromRecord()` 不应再被理解为“把 record.blockData 注入 chunk.blockData” +- `_injectBlockData()` 不应再同时承担“写 authority + 建索引” +- `_injectBlockDataBatch()` 也不应继续以“分批写 chunk-local blockData”为前提 + +更准确的目标是: + +- `loadFromRecord()` 成为一个编排函数,负责协调: + - cold input 是否需要先写 authority + - chunk attach + - derived indexes rebuild + - non-block payload restore + +- `_injectBlockData()` 若保留,应只保留其中一种职责: + - 要么是 cold input -> authority 的边界 helper + - 要么是 authority -> derived indexes 的 rebuild helper + - 不能两者混合 + +- `_injectBlockDataBatch()` 若保留,应明确它是在“分批 rebuild 派生索引”,而不是“分批把逻辑真相写进 chunk.blockData” + +硬约束: + +- 禁止继续保留“一个 helper 同时负责写 `blockData` 和建索引”的旧复合职责 +- 禁止在 authority 已存在时,`loadFromRecord()` 仍重复执行“plain object -> chunk.blockData”注入流程 + +### 7.3.2 Clear / Replace Boundary + +为了避免 shared Map 方案在实施时被旧实现语义污染,必须把“谁可以 clear / replace 什么”写成硬约束: + +1. 允许 clear 的对象: + - `visibleKeys` + - `solidBlocks` + - `blockDataArray` + - `solidBlockIds` + - `blockPalette` + - `blockPaletteReverse` + - `lightSourceCoords` + - 其他 chunk-local 派生索引 + +2. 不允许 clear 的对象: + - `Chunk.blockData`(当其指向 shared authority slice 时) + - world-level authority 内已挂载的 chunk slice + +3. 允许 replace 的对象: + - `WorldBlockDataStore` 中尚未 attach 的 chunk slice + - 低频场景下由 store 统一替换的整块 authority slice + +4. 不允许 replace 的对象: + - 业务代码手里的 `Chunk.blockData` + - 通过 `Chunk` 内部 helper 悄悄重建的新 `Map` + +换句话说: + +- **整块逻辑真相替换是 store 的职责** +- **共享视图挂载是 chunk 的职责** +- **派生层清空与重建是 chunk 的职责** +- 三者不得混用 ### 7.4 Chunk 卸载 @@ -386,6 +836,17 @@ World.unloadChunk() 8. `WorldGenerationService` 写入生成结果的路径 9. 未来 `applySaveData()` / import 路径 +补充说明: + +- 不仅要覆盖“最终执行 `blockData.set/delete` 的位置” +- 还要覆盖“谁在上游决定这次逻辑真相应该写到哪里”的接入路径 + +因此在本阶段,至少还必须显式纳入: + +10. `WorldGenerationService._generateRegion()` 完成后的 authority 接入路径 +11. `World` / `Chunk` 接收 WorldWorker 结果后的 authority 接入路径 +12. `acceptWorkerResult()` 一类直接装配 worker 元数据与派生层的路径 + 统一原则: - 所有逻辑修改必须先触达 `blockData` @@ -393,6 +854,111 @@ World.unloadChunk() - 不允许索引层反向决定逻辑真相 - 不允许沿用“先改 `Chunk.blockData` 再同步第二处 holder”的旧双写模式 +### 8.0.1 Worker Callback Boundary + +本阶段必须明确区分两类 worker 回包处理: + +1. **authority input** + - 例如生成出的 `chunkRecord.blockData` + - 必须先写 `WorldBlockDataStore` + +2. **derived payload input** + - 例如 `visibleKeys` + - `solidBlocks` + - `meshData` + - `structureCenters` + - 其他 worker 回传的渲染 / AO / routing 元数据 + - 这些内容可以直接装配到 chunk 派生层,但前提是 authority 已经成立 + +硬约束: + +- worker 回包路径不得把 `visibleKeys` / `solidBlocks` / `meshData` / `structureCenters` 之类派生层数据反向当成逻辑真相来源 +- `acceptWorkerResult()` 或等价路径若继续直接写派生层,必须建立在: + - `blockData authority` 已正确写入 + - `Chunk.blockData` 已 attach 到对应 authority slice + +### 8.0.2 Write Mechanism Transition + +当前旧实现里,大量路径仍遵循: + +```text +写 Chunk.blockData +-> 再同步写 MemoryWorldStore +``` + +本阶段必须整体切换为: + +```text +写 WorldBlockDataStore +-> Chunk.blockData 作为共享视图自动观察到变更 +-> 再更新派生索引与异步派生层 +``` + +这意味着: + +- `_updateBlockState()` 一类 helper 中“`this.blockData.set/delete` + `memStore.applyBlockMutation()`”的旧双写模式必须整体退出 +- `acceptScatteredBlocks()` / `appendScatteredBlocks()` / world generation 接入路径 / batch edit 路径都必须迁移到同一机制 + +## 8.1 Unified Mutation Primitive + +仅仅“列出所有写入口”还不够,本阶段还必须建立**唯一合法 mutation 原语**。 + +建议约束为: + +1. 热路径合法原语: + - `setBlockEntry(cx, cz, code, entry)` + - `deleteBlockEntry(cx, cz, code)` + - `applyChunkPatch(cx, cz, entries)` 或等价批量局部 patch API + +2. 低频整块原语: + - `replaceChunkSlice(cx, cz, blockData)` + - 仅用于生成注入、未来导入、测试夹具、冷边界恢复 + +3. 业务层禁止事项: + - 除 attach / replace / 测试夹具外,禁止直接 `chunk.blockData.set/delete` + - 禁止在 authority 写入后,再对共享 `Chunk.blockData` 做第二次补写 + - 禁止为兼容旧路径额外构造 chunk-local `blockData` staging 副本 + +4. mutation 后续步骤必须是固定序列: + - authority mutation + - 旧值/新值差异判定 + - 派生索引更新 + - AO / tombstone / renderDelta / global instance patch 更新 + - dirty 标记或后续异步派生调度 + +本设计的关键不是“把 `Map` 共享出去”,而是“共享 `Map` 后依然只有受控 mutation 才是合法写路径”。 + +补充限制: + +- attach / hydrate / rebuild 流程不属于普通 mutation 热路径 +- 但即便在这些流程中,也不得让 `Chunk` 直接通过 `clear()` 接管 authority slice 生命周期 + +## 8.2 Read Path Contract + +authority 重构后,读路径也必须显式收敛,避免后续出现“写路径统一了,读路径还在各自猜真相”的状态。 + +本阶段至少需要写清以下契约: + +1. `WorldAccessLayer.getBlock()` + - 优先命中 loaded chunk view + - 未加载 chunk 在本阶段可以返回 miss,不要求回源冷存储 + +2. `isSolid` / `getCollisionAt` + - 优先使用 `blockDataArray` / `solidBlockIds` / `solidBlocks` + - authority 只提供逻辑真相,不替代高频碰撞索引 + +3. `resolveBlockOwner` + - loaded chunk 场景可以继续优先走 chunk 派生索引 / 视图 + - 若继续支持跨 chunk owner fallback,必须明确它是在 loaded chunk 集合内工作,还是允许直接查 world-level authority + +4. cross-chunk patch / deferred patch / neighbor sampling + - 必须明确是否允许读取未加载 authority slice + - 若不允许,调用方必须先确保目标 chunk 已 attach 或已有 world-level slice + +5. RegionCache 读取边界 + - runtime 热路径不得把 `RegionCache` 中的 `blockData` 当成 live truth + - 若某条路径仍读取 `cachedChunkRecord.blockData`,必须明确这是冷边界导入阶段,而不是运行中逻辑判定阶段 + ## 9. 复制与序列化边界 这次改造必须把“什么时候允许 clone”写成硬约束,否则只会把性能问题换位置。 @@ -466,6 +1032,8 @@ World.unloadChunk() 9. `deletedBlockTombstones` 继续保护晚到 Worker 回包一致性 10. `Chunk.blockData` 与 `WorldBlockDataStore` chunk slice 共享同一个 `Map` 实例 11. 热路径中不允许出现“authority 写一次 + chunk map 再写一次”的重复 mutation +12. `Chunk` attach / rebuild 过程中不会悄悄替换 authority slice 为新的 `Map` +13. runtime-only 模式下,即使 `WorldStore` / `PersistenceService` 不可用,loaded chunk 的编辑 / unload / reload 闭环仍然成立 ### 11.2 需要改写的旧测试 @@ -478,6 +1046,16 @@ World.unloadChunk() - AO / tombstone / late worker result 防护测试必须保留 - `acceptScatteredBlocks` / `appendScatteredBlocks` 对隐藏块和跨 chunk 块的正确性测试必须保留 +### 11.4 必须新增的竞态与生命周期测试 + +1. chunk unload 后,晚到 worker 回包不能污染新的 authority / 新 chunk view +2. reload 后,deferred cross-chunk patch 仍命中正确的 authority slice +3. 玩家先修改、后收到 scatter/append patch 时,旧生成结果不能覆盖玩家修改 +4. AO mirror / tombstone / renderDelta 在共享 `Map` 后不发生双重消费或漏更新 +5. `saveDebounced()`、`flushChunk()`、`PersistenceService.cache` 失效或旁路时,不影响 runtime authority 正确性 +6. `RegionCache` 中残留的 `blockData` 不会在 authority 建立后重新覆盖或污染 live truth +7. AO Worker mirror 延迟或重建时,不会把旧 AO 结果回写成逻辑真相 + ## 12. Migration Strategy ### Phase 1: 语义收敛 @@ -498,6 +1076,40 @@ World.unloadChunk() - 停止构建完整 `blockDataSnapshot` - `saveDebounced()` 只保留 runtime dirty 标记或直接去除其持久化职责 +### Phase 3.1: Snapshot Consumer Retirement + +“停止构造 `blockDataSnapshot`”本身并不够,还必须明确所有 snapshot consumer 的去留策略。 + +至少需要覆盖以下路径: + +1. `_resolveSerializedBlockData()` +2. `flushChunk()` +3. `flushBeforeUnload()` +4. `flushAllDirty()` +5. `_ensureDirtyChunkEntry()` 中与 `blockDataSnapshot` 相关的初始化字段 + +每条路径都必须明确属于以下哪一类: + +- **delete** + - 本阶段直接删除,不再保留 + +- **deferred shell / no-op** + - 保留函数签名,但不再参与 runtime 正确性 + +- **authority-based rewrite** + - 若未来仍需保留功能,则改为直接从 authority 或其显式序列化边界取数据 + +不允许出现的中间态: + +- snapshot 生产逻辑已被删弱 +- 但消费者仍沿用 `snapshot -> region-cache -> live-chunk` 的旧模糊回退链 + +如果某个旧方法暂时保留,其文档必须明确: + +- 它不再消费 `blockDataSnapshot` +- 它是否仍参与 runtime 主链路 +- 它若取数据,新的来源是什么 + ### Phase 4: 推迟存档层实现 - `PersistenceService` / `WorldStore` / `PersistenceWorker` 标记为 deferred @@ -522,3 +1134,5 @@ World.unloadChunk() 5. runtime 正确性不再依赖任何 `blockDataSnapshot` / unload flush queue / `PersistenceService.cache` 6. `visibleKeys`、`solidBlocks`、`blockDataArray`、`solidBlockIds` 继续存在,但只作为派生索引或高速查询缓存 7. 持久化层即使暂时缺席,也不影响 runtime 正确性 +8. `Chunk` 的 attach / hydrate / rebuild 语义明确,不再把“复制 blockData 到 chunk 本地”误当成权威接入 +9. 所有热路径写入都经过统一 mutation 原语,不再允许隐式直写共享 `Map` From d493c0cabb4fd4b566c492da590efb4f33ac5904 Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Fri, 8 May 2026 08:42:47 +0800 Subject: [PATCH 04/17] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=20blockData=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80=E6=96=B9=E6=A1=88=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=96=B0=E5=A2=9E=E4=B8=89=E5=B1=82=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E3=80=81=E8=A3=85=E9=85=8D=E5=8D=8F=E8=AE=AE=E3=80=81codec=20?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=E4=B8=AD=E6=AD=A2=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充了计划文档的大粒度缺口: - 新增 WorldChunkPayloadRegistry + WorldChunkRegistry 两个 world-level 容器, 明确 non-block payload(runtimeSeedData、staticEntities)的持有语义 - 新增 BlockScatterManager 和 ChunkAssemblyScheduler 的改造文件列表与约束 - 新增 Task 1.2(registry 语义固定)、Task 3.2(dirty 拆分为 runtime dirty 与 save dirty)、Task 3.6(authority codec 边界)、 Task 2.9(detach/dispose + epoch 协议) - 新增旧测试删除/重写策略与中止条件初稿 - 设计文档补充特殊实体兼容层边界、resolveBlockOwner 权威来源、 写入口收敛收束范围和序列化 codec 草案 - 验收矩阵增加特殊实体功能回退禁止项 Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 211 ++++++++++++- ...-blockdata-authority-unification-design.md | 283 +++++++++++++++++- 2 files changed, 490 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index 74a396e..272aaa7 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -22,6 +22,7 @@ - chunk unload 只释放视图与派生层 - chunk reload 从 world-level authority 恢复 - AO / tombstone / renderDelta / scatter patch 语义不回退 + - 特殊实体(矿车、丧尸巢穴、炮塔等)的既有渲染、互动、与主世界/玩家的交互能力不回退 - 若实现过程中发现某一步必须重新依赖 `PersistenceService.cache`、`WorldStore.commitChunkRecord()`、`blockDataSnapshot` 或 `pendingUnloadFlushQueue` 才能保证 runtime 正确性,则应停止推进并回到设计层修正,而不是临时接回旧链路。 ### 新增文件 @@ -29,6 +30,13 @@ - Create: `src/world/WorldBlockDataStore.js` - world-level `blockData` 唯一权威容器 - 负责 chunk slice 读写、生成结果注入、未来导入导出接入点 +- Create: `src/world/WorldChunkPayloadRegistry.js` + - world-level non-block payload authority + - 本阶段负责 `runtimeSeedData`、`staticEntities` 的持有与恢复 + - 为 `runtimeEntities` 预留兼容挂点,但不要求本阶段完成其 owner 重构 +- Create: `src/world/WorldChunkRegistry.js` + - world-level chunk presence / generation state registry + - 负责区分 missing chunk、known empty chunk、known non-empty chunk ### 删除文件 @@ -44,8 +52,16 @@ - Modify: `src/world/World.js` - chunk 加载 / 卸载前后的 world-level authority 接入 - 统一通过 `WorldBlockDataStore` 接收生成结果 +- Modify: `src/world/WorldChunkRegistry.js` + - 若实现为类文件,补充 chunk presence / generation state 查询 - Modify: `src/world/WorldGenerationService.js` - 区域 / chunk 生成结果直接写入 `WorldBlockDataStore` +- Modify: `src/world/BlockScatterManager.js` + - cross-chunk patch / scatter 编排必须对齐 authority slice 语义 + - 保持 tombstone、hidden block、late worker result 保护在 shared authority 模式下仍然正确 +- Modify: `src/world/ChunkAssemblyScheduler.js` + - hydrate stage 在 authority slice 已存在时只做 attach + rebuild + - 不再驱动旧的 clear + inject blockData 语义 - Modify: `src/world/WorldRuntime.js` - 收缩 `blockDataSnapshot`、`pendingUnloadFlushQueue`、`flushChunk()` 热路径职责 - 明确 `RegionCache` 降级为冷边界 / region 管理辅助层 @@ -68,6 +84,16 @@ - Test: `src/tests/test-world.js` - Test: `src/tests/test-world-runtime.js` - Test: `src/tests/test-runtime-session-persistence.js` +- Test: `src/tests/test-world-generation-cross-region.js` + +### 需要删除或重写的旧测试 + +- Delete or rewrite: `src/tests/test-memory-world-store.js` + - `MemoryWorldStore` 删除后,此测试不再成立 +- Rewrite: `src/tests/test-runtime-session-persistence.js` + - 从“cache 是会话权威”迁移为“authority 是会话权威,冷边界只是可选导出路径” +- Rewrite: `src/tests/test-world-runtime.js` + - 从 `flushChunk()/snapshot/commitChunkRecord()` 主语义迁移为 authority attach / reload / cold boundary 降级语义 --- @@ -90,7 +116,11 @@ 要求: - 明确 `WorldBlockDataStore` 只负责 world-level `blockData` slice -- 明确 `runtimeSeedData`、`staticEntities`、`runtimeEntities` 属于 chunk-level non-block payload +- 明确 `WorldChunkPayloadRegistry` 本阶段持有 `runtimeSeedData`、`staticEntities` +- 明确 `runtimeEntities` / 特殊实体系统本阶段作为兼容层保留,不作为 primary authority 重构对象 +- 明确特殊实体虽然不是 primary authority 重构对象,但其既有运行行为是本阶段硬性验收项,不允许功能回退 +- 明确 `WorldChunkRegistry` 持有 chunk presence / generation state,而不是靠空 slice 猜测 +- 明确 `runtimeSeedData`、`staticEntities` 属于本阶段要收口的 chunk-level non-block payload - 明确这些 non-block payload 不再以 `Chunk` 生命周期作为唯一持有语义 - [ ] **Step 3: 固定 runtime 最小闭环** @@ -108,6 +138,7 @@ - 文档中明确写出 runtime-only 成功标准 - 明确哪些旧功能降级为 deferred +- 明确哪些旧测试将删除、哪些将重写、哪些将在本阶段降级 --- @@ -183,6 +214,84 @@ Expected: PASS --- +### Task 1.2: 固定 chunk presence / generation state 与 non-block payload owner + +**Files:** +- Create: `src/world/WorldChunkPayloadRegistry.js` +- Create: `src/world/WorldChunkRegistry.js` +- Modify: `src/world/World.js` +- Modify: `src/world/WorldRuntime.js` +- Modify: `src/world/WorldGenerationService.js` +- Test: `src/tests/test-world.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定 registry 语义** + +新增测试覆盖: + +- missing chunk 与 known empty chunk 必须可区分 +- bootstrap 生成出的空 chunk 不得因 slice 为空而被判定为 missing +- `runtimeSeedData/staticEntities` 在 chunk unload 后仍有 world-level owner +- reload 时这两类 non-block payload 从 registry 恢复,而不是依赖 live chunk 残留 +- `runtimeEntities` / 特殊实体链路在本阶段保持兼容,不要求进入新的 registry owner 体系 + +- [ ] **Step 2: 实现 `WorldChunkRegistry` 最小骨架** + +至少包含: + +- `markChunkKnown(cx, cz, meta)` +- `getChunkState(cx, cz)` +- `hasKnownChunk(cx, cz)` +- `markChunkGenerated(cx, cz)` 或等价接口 + +边界要求: + +- 不允许通过 `ensureChunkSlice()` 是否被调用来隐式推断 chunk 是否已知存在 +- 必须能明确表达: + - missing chunk + - known empty chunk + - known non-empty chunk + +- [ ] **Step 3: 实现 `WorldChunkPayloadRegistry` 最小骨架** + +至少包含: + +- `getChunkPayload(cx, cz)` +- `setChunkPayload(cx, cz, payload)` +- `mergeChunkPayload(cx, cz, partialPayload)` +- `hasChunkPayload(cx, cz)` + +边界要求: + +- `runtimeSeedData/staticEntities` 的 world-level owner 必须固定 +- `Chunk` 不得继续作为这两类 payload 的唯一最终持有者 +- `runtimeEntities` 若预留接口,只允许作为兼容挂点,不得因此扩成本阶段特殊实体大重构 + +- [ ] **Step 4: 对齐生成与加载链路** + +要求: + +- 世界生成先写 `WorldChunkRegistry` 与 `WorldChunkPayloadRegistry` +- chunk load / reload 时,先判定 chunk presence,再 attach authority / restore payload +- `WorldRuntime.ensureChunkData()` 若仍保留冷边界职责,必须与 registry 语义对齐,而不是另起一套 missing 判定 +- `runtimeEntities` / 特殊实体恢复继续沿用现有兼容链路,只要求与新的 authority attach / reload 不冲突 + +- [ ] **Step 5: 运行相关测试** + +Run: +- 测试页面运行 `test-world.js` +- 运行 `test-world-runtime.js` + +Expected: +- chunk presence 与 payload owner 语义稳定成立 + +- [ ] **Step 6: 运行 lint** + +Run: `npm run lint` +Expected: PASS + +--- + ### Task 2: 将 `Chunk.blockData` 收敛为 world-level authority 的 chunk 视图 **Files:** @@ -253,6 +362,7 @@ Expected: PASS - Modify: `src/world/Chunk.js` - Modify: `src/world/World.js` - Modify: `src/world/WorldRuntime.js` +- Modify: `src/world/ChunkAssemblyScheduler.js` - Test: `src/tests/test-chunk.js` - Test: `src/tests/test-world-runtime.js` @@ -276,9 +386,15 @@ Expected: PASS - 让代码层面能看出: - 哪一步在 attach - 哪一步在 rebuild - - 哪一步在恢复 `runtimeSeedData` / `staticEntities` / `runtimeEntities` + - 哪一步在恢复 `runtimeSeedData` / `staticEntities` + - 哪一步在兼容现有 `runtimeEntities` / 特殊实体恢复链路 - 让代码层面能看出: - 哪一步在处理 cold input -> authority +- `ChunkAssemblyScheduler` 调用的 hydrate stage 在 authority slice 已存在时,只允许: + - attach authority slice + - rebuild derived indexes + - restore payload + - 禁止继续驱动 `_clearForBlockInjection()` + `_injectBlockDataBatch()` 这类 clear + inject 语义 - 明确采用三段式协议: - `WorldBlockDataStore.replaceChunkSlice()` / fill API - `Chunk.attachAuthoritySlice()` 或等价挂载步骤 @@ -318,6 +434,21 @@ Expected: PASS - `chunkRecord.blockData` 作为 plain object 输入时,先进入 authority,而不是直接写 chunk-local map - authority slice 已存在时,`loadFromRecord()` 只做 attach / rebuild,不重复注入逻辑真相 - rebuild 只影响派生索引,不影响 authority 内容 +- `runtimeSeedData/staticEntities` 的恢复步骤与 blockData attach 步骤彼此独立 +- `runtimeEntities` / 特殊实体恢复在本阶段只是兼容步骤,不得反向主导 authority attach +- `Chunk` dispose 后,authority / payload registry 仍保留,新的 chunk attach 不依赖旧实例残留 + +- [ ] **Step 2.9: 固定 detach / dispose 协议** + +要求: + +- 明确 `Chunk.dispose()` / unload 期间: + - 哪些结构被释放 + - 哪些 world-level authority 被保留 + - 哪些异步回包必须失效 +- 为 attach / detach 生命周期引入 epoch 或等价版本语义 +- 测试必须能证明:晚到 worker 结果不会污染新 attach 的 chunk 视图 +- 测试必须能证明:scheduler 切片 hydrate 在 authority slice 已存在时不会 clear 或重注入 `Chunk.blockData` - [ ] **Step 3: 运行相关测试** @@ -339,6 +470,7 @@ Expected: PASS **Files:** - Modify: `src/world/Chunk.js` +- Modify: `src/world/BlockScatterManager.js` - Modify: `src/world/WorldGenerationService.js` - Modify: `src/world/WorldAccessLayer.js` - Modify: `src/world/WorldBlockDataStore.js` @@ -352,6 +484,7 @@ Expected: PASS - `addBlockDynamic()` - `addBlocksBatchFast()` - `removeBlocksBatch()` +- `BlockScatterManager.distributeBlocks()` / `scatter()` / deferred cross-chunk patch 路径 - `acceptScatteredBlocks()` - `appendScatteredBlocks()` - 世界生成结果注入路径 @@ -394,6 +527,19 @@ Expected: - `acceptWorkerResult()` 若继续直接装配派生层,必须建立在 authority 已写入且 chunk 已 attach 的前提下 - 禁止把 worker 回包的派生层数据反向当成逻辑真相来源 +- [ ] **Step 3.3: 适配 `BlockScatterManager` 到 authority slice 语义** + +要求: + +- `BlockScatterManager` 在 shared authority 模式下被明确视为 patch 编排层,而不是 chunk-local blockData 灌入层 +- `distributeBlocks()`、`scatter()`、deferred cross-chunk patch 路径必须对每个目标 chunk 命中其 authority slice +- tombstone 检查、hidden block 过滤、late worker result 保护在 shared authority 模式下必须继续成立 +- 禁止 `BlockScatterManager` 通过“先把块灌进 chunk-local 副本,后续再同步”的旧思路维持正确性 +- 测试必须覆盖: + - cross-chunk patch 命中正确目标 slice + - 玩家修改不会被晚到 scatter 结果覆盖 + - hidden block / tombstone 保护不会因 shared Map 而失效 + - [ ] **Step 3.5: 建立统一 mutation 原语** 要求: @@ -412,6 +558,15 @@ Expected: - dirty 标记 / 异步派生调度 - 测试或断言必须能发现“authority 写一次 + chunk map 再写一次”的重复 mutation +- [ ] **Step 3.6: 建立 authority codec 边界** + +要求: + +- 明确 runtime authority 主存储为 `Map` +- 明确 worker / 冷边界 / 测试夹具的序列化格式为 plain object 或等价边界格式 +- 为 object <-> Map 转换提供统一 codec,而不是让 `WorldGenerationService`、`WorldRuntime`、`Chunk` 各自散落转换 +- 禁止热路径借助 codec 进行整块 object/map 往返 + - [ ] **Step 3.8: 完成双写机制切换** 要求: @@ -431,7 +586,8 @@ Expected: - 当前 chunk 已加载时,再 hydrate 到 `Chunk.blockData` - 不再为持久化缓存额外铺第二条热路径 - 当前 chunk 未加载时,authority 仍应持有对应 slice,等待未来 chunk attach -- `runtimeSeedData`、`staticEntities`、`runtimeEntities` 若随生成阶段一起产出,也必须写入对应的 world-level / registry 层,而不是继续只暂存在 chunk 生命周期中 +- `runtimeSeedData`、`staticEntities` 若随生成阶段一起产出,也必须写入对应的 world-level / registry 层,而不是继续只暂存在 chunk 生命周期中 +- `runtimeEntities` / 特殊实体本阶段不要求随生成链路一并重构,只要求不破坏既有兼容逻辑 - [ ] **Step 4.5: 明确禁止 world generation 双写旧持久化层** @@ -493,6 +649,7 @@ Expected: PASS - cross-region overflow 合并后,目标 chunk 的 `blockData` 必须先进入 authority - 目标 chunk 尚未加载时,不得因为没有 live chunk view 就跳过写入 - bootstrap 期间 authority 必须能够独立持有未加载 chunk 的 slice,等待未来 attach +- bootstrap 期间也必须同步建立 chunk presence / generation state,不允许把“空 slice”误当缺失 chunk - [ ] **Step 4: 对齐 bootstrap chunk 的后续加载来源** @@ -620,6 +777,14 @@ Expected: - 停止首次脏化时创建完整 `blockDataSnapshot` - 收缩为 runtime dirty 标记,或直接由 chunk/local state 接管 +- [ ] **Step 3.2: 拆分 runtime dirty 与 export/save dirty** + +要求: + +- `markChunkDirty()` 只表达 runtime 后续处理需求 +- 若需要保留 future save dirty,必须显式拆成独立字段、独立注释、独立调度 +- 禁止 runtime dirty 默认附带“顺手准备持久化快照”的旧语义 + - [ ] **Step 3.5: 降级 `RegionCache` 中的 blockData 语义** 要求: @@ -658,6 +823,14 @@ Expected: - 它是否仍参与 runtime 主链路 - 它若取数据,新来源是什么 +- [ ] **Step 5.8: 重写运行时测试语义** + +要求: + +- `src/tests/test-world-runtime.js` 从“flush/snapshot 正确性”迁移为“authority attach/reload/deferred cold boundary”语义 +- `src/tests/test-runtime-session-persistence.js` 从“cache 是会话权威”迁移为“authority 是会话权威,cache/worker 只是导出或冷边界” +- 删除或彻底改写 `src/tests/test-memory-world-store.js` + - [ ] **Step 6: 运行运行时相关测试** Run: @@ -726,6 +899,14 @@ Expected: - 未来导出存档 - 未来冷存储恢复前的显式序列化边界 +- [ ] **Step 4.5: 对齐测试夹具与 worker 边界格式** + +要求: + +- 测试夹具可以继续使用 plain object,但进入 runtime authority 前必须走 codec +- worker 消息边界允许继续使用序列化对象,不要求直接传 `Map` +- 注释中明确:plain object 是 boundary format,不是 runtime authority format + - [ ] **Step 5: 运行相关测试** Run: @@ -779,6 +960,14 @@ Expected: - `WorldGenerationService` 若暂时仍保留 `saveRegionRecord()` / `saveWorldMeta()` 一类调用,必须在注释中明确:这些调用不是本阶段生成正确性的组成部分 - `RegionCache` 若继续缓存带 `blockData` 的 chunkRecord,也必须在注释中明确:这些 `blockData` 只用于冷边界输入,不得作为 live authority +- [ ] **Step 3.2: 收紧 `WorldRuntime.ensureChunkData()` 的边界语义** + +要求: + +- 明确它的职责是 cold boundary import / compatibility bridge,而不是 live authority owner +- 若该方法返回 plain-object `chunkRecord`,必须在进入 runtime authority 前走 codec 与 registry +- `missing-chunk` 判定必须与 `WorldChunkRegistry` 对齐,不能再由旧 `WorldStore` 是否命中单独决定 + - [ ] **Step 4: 运行运行时测试** Run: @@ -786,6 +975,7 @@ Run: Expected: - runtime 路径不再把持久化层当成正确性前提 +- 特殊实体兼容链路不会因 authority 重构而被冷边界降级逻辑误伤 - [ ] **Step 5: 运行 lint** @@ -843,6 +1033,7 @@ Expected: PASS - Test: `src/tests/test-world.js` - Test: `src/tests/test-world-runtime.js` - Test: `src/tests/test-runtime-session-persistence.js` +- Test: `src/tests/test-world-generation-cross-region.js` - [ ] **Step 1: 运行浏览器内测试全套回归** @@ -853,6 +1044,7 @@ Run: Expected: - 所有与 runtime authority 相关的现有测试通过 +- 已删除/已替换的旧测试不再依赖 `MemoryWorldStore` 或 session cache 权威语义 - [ ] **Step 2: 手动验证单块修改与 reload** @@ -905,6 +1097,19 @@ Expected: - 冷存储路径缺席不会破坏当前会话内正确性 - 方块状态正确恢复 +- [ ] **Step 3.2: 手动验证特殊实体兼容性** + +手动步骤: + +1. 验证矿车、丧尸巢穴、炮塔在改造后仍能生成或被恢复到既有运行状态 +2. 验证它们与玩家的互动不回退 +3. 验证它们与主世界方块、碰撞、流式加载/卸载的交互不回退 +4. 对包含这些特殊实体的 chunk 执行 unload / reload,再次验证行为 + +Expected: +- 特殊实体的渲染、更新、互动、与主世界/玩家的交互能力保持正常 +- `blockData authority` 重构不会导致特殊实体失活、丢失或交互异常 + - [ ] **Step 3: 手动验证世界生成结果直接进入 blockData** 手动步骤: diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index d9719af..139f6bd 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -79,6 +79,11 @@ - 所有 patch 直接命中目标 chunk 的 authority slice - 不允许先堆积在某个 chunk-local staging blockData 中等待二次同步 +5. **特殊实体兼容性** + - 矿车、丧尸巢穴、炮塔等既有特殊实体在本阶段改造后必须继续正常运行 + - 必须保持与玩家、主世界、普通方块 authority 的互动行为不回退 + - chunk unload / reload 后,这些特殊实体的既有行为链路不得因 `blockData authority` 重构而失效 + ## 3. Non-Goals 本次设计明确不做以下事情: @@ -235,7 +240,10 @@ runtime 中只存在一份 world-level `blockData authority`: - 至少包括: - `runtimeSeedData` - `staticEntities` - - `runtimeEntities` + - 本阶段 primary scope 只要求明确: + - `runtimeSeedData` + - `staticEntities` + - `runtimeEntities` / 特殊实体系统不作为本阶段 primary authority 重构对象,只要求与新 `blockData authority` 保持兼容 - 这些数据可以暂时不并入 `WorldBlockDataStore` 同一个类,但必须在语义上明确:它们不再依赖 `Chunk` 生命周期作为唯一持有者 约束: @@ -250,6 +258,79 @@ runtime 中只存在一份 world-level `blockData authority`: - 它必须能在 chunk 尚未加载时就持有对应的 chunk slice - 也就是说,world-level authority 是世界逻辑真相仓库,而不是 loaded chunk 的附属物 +### 5.3.1.1 Chunk Payload Registry Contract + +仅统一 `blockData authority` 还不够,本阶段还必须把 `blockData` 之外的 chunk 级 payload owner 写清楚,否则删除 `MemoryWorldStore` 后,只会把“第二权威”从 blockData 转移到其他字段。 + +本阶段建议显式引入: + +1. **`WorldChunkPayloadRegistry`**(名称可调整,但职责必须固定) + - 持有与 chunk 同坐标关联、但不属于普通块逻辑真相的 payload + - 本阶段至少包括: + - `runtimeSeedData` + - `staticEntities` + - 可预留 `runtimeEntities` 接口挂点,但不要求本阶段完成其 owner 重构 + +2. **持有语义** + - 生命周期独立于 live `Chunk` 实例 + - chunk unload 后仍可保留 + - chunk reload 时可再次 attach / restore + +3. **职责边界** + - `WorldBlockDataStore` 只负责普通逻辑块 authority + - `WorldChunkPayloadRegistry` 只负责本阶段纳入范围的 non-block payload authority + - `Chunk` 只负责 attach、restore、rebuild 本地运行时视图 + - `runtimeEntities` / `specialEntitiesShadowStore` 在本阶段视为既有兼容层,而不是新的 primary authority 改造对象 + +4. **不允许的旧语义** + - `Chunk` 作为 `runtimeSeedData/staticEntities` 的最终唯一持有者 + - `WorldRuntime._regionCache` 临时碰到谁就替谁持有 live payload + - `loadFromRecord()` 同时隐式决定普通块权威和 non-block payload 权威 + +补充要求: + +- 本阶段即便不把 `WorldChunkPayloadRegistry` 做成最终形态,也必须在接口、注释和调用关系上把 owner 固定下来 +- 否则后续 reload、export、entity restore 都会继续混用冷边界对象与 live chunk 状态 +- `runtimeEntities` / 特殊实体系统本阶段只要求: + - 渲染、互动、reload 行为不回退 + - 矿车、丧尸巢穴、炮塔等既有实体在世界流式加载、玩家交互、方块权威切换后仍可正常工作 + - 不反向成为 `blockData authority` 的真相来源 + - 不阻塞 `runtimeSeedData/staticEntities` 的 world-level restore 设计 + +### 5.3.1.2 Chunk Registry / Generation State Contract + +只靠 `WorldBlockDataStore: Map>` 无法表达“一个 chunk 是否已知存在”。本阶段还必须显式区分以下状态: + +1. **missing chunk** + - 当前 authority 中根本不存在该 chunk + - 可能尚未生成、尚未导入、也可能不在当前已知世界范围内 + +2. **known empty chunk** + - chunk 已被生成或导入 + - 只是其 `blockData` slice 为空 + +3. **known non-empty chunk** + - chunk 已被生成或导入 + - 且 authority slice 中存在至少一个条目 + +建议显式引入 world-level chunk registry(可以是单独容器,也可以是 `WorldBlockDataStore` 的元数据层),至少记录: + +- `chunkKey` +- `presenceState` 或等价字段 +- 是否完成 bootstrap/runtime generation +- non-block payload 是否存在 + +最低约束: + +- `ensureChunkSlice()` 创建空 slice 不能自动等价于“世界中确实存在该 chunk” +- 查询 missing/known-empty 必须有不同返回语义 +- `WorldRuntime.ensureChunkData()`、bootstrap、cross-region overflow、未来 import/export 都必须基于同一套 chunk presence contract + +否则会出现两类严重歧义: + +- 一个空 `Map` 到底代表“这个 chunk 已生成但没有逻辑块”,还是“只是有人顺手 ensure 了一下” +- reload 到底应该 attach 一个空 slice,还是回源生成器 / 冷边界 + ### 5.3.2 WorldBlockDataStore Storage Contract `WorldBlockDataStore` 不能只停留在“里面用 `Map`”这一层描述,还必须明确它的外层结构与 API 语义,否则 shared chunk view 的设计无法稳定落地。 @@ -485,6 +566,11 @@ AO Worker 的 `blockData` 镜像在新模型中仍然允许存在,但它必须 - `entityCollisionIndex` 继续保持特殊实体碰撞占位的独立语义 - 本次 authority 重构不把 `entityCollisionIndex` 合并进 `blockData` 或 `solidBlocks` - 对 `entityCollisionIndex` 的修改不应被误纳入普通方块写入口改造 +- `runtimeEntities` / `specialEntitiesShadowStore` 在本阶段视为兼容对象: + - 只要求现有渲染、互动、reload 行为不回退 + - 不要求本阶段完成其 owner 模型重构 + - 但必须明确其状态不能反向成为 `blockData authority` 的真相来源 +- 相比之下,`staticEntities` 与 `runtimeSeedData` 更靠近 chunk reload / 装配主链路,本阶段必须为它们提供明确的 world-level restore 来源 ### 6.8 Section 6 保留范围总结 @@ -616,6 +702,33 @@ BlockScatterManager / deferred cross-chunk patch - 共享 `Map` 方案下,不允许对同一 patch 再执行一次额外的 `Chunk.blockData.set/delete` 补写 - tombstone、hidden block、late worker result 过滤等现有一致性保护逻辑继续保留 +### 7.2.2 BlockScatterManager Contract + +`BlockScatterManager` 在新模型中不能继续被理解为“把 worker 结果灌进某个 chunk 的本地 blockData”,而必须被重新定义为: + +- **authority patch 编排层** + +也就是说,它负责: + +1. 按目标 chunk 分组 patch +2. 将 patch 路由到各自 authority slice +3. 驱动目标 chunk 的派生层更新 +4. 保持 tombstone、hidden block、late worker result 保护逻辑继续成立 + +硬约束: + +- `BlockScatterManager` 不得再隐式依赖“chunk 先局部持有逻辑真相,未来再同步到别处” +- `distributeBlocks()`、`scatter()`、deferred cross-chunk patch 都必须以 authority slice 为目标语义 +- shared authority 模式下,late worker result 的保护必须同时考虑: + - tombstone + - authority version / assembly epoch + - 玩家后续修改优先级 + +换句话说: + +- `Chunk.acceptScatteredBlocks()` / `appendScatteredBlocks()` 是目标 chunk 的派生层装配入口 +- `BlockScatterManager` 才是 scatter patch 的 authority-level 编排入口 + ### 7.3 Chunk 加载 目标链路: @@ -696,6 +809,40 @@ World 请求 chunk - `attachAuthoritySlice()` - `rebuildDerivedIndexesFromAuthority()` +### 7.3.1.0 ChunkAssemblyScheduler Alignment + +`ChunkAssemblyScheduler` 是 attach/hydrate/rebuild 协议的真实执行器之一,因此本阶段必须显式要求它与 shared authority 模型对齐。 + +当前旧语义中,scheduler 驱动的 hydrate stage 仍可能复用: + +```text +_clearForBlockInjection() +-> _injectBlockDataBatch() +``` + +这在 chunk-local holder 模式下可以工作,但在 shared authority view 模式下属于非法路径。 + +因此本阶段要求: + +1. 当 authority slice 已存在时,scheduler 驱动的 hydrate stage 只允许: + - attach authority slice + - rebuild derived indexes + - restore payload + +2. 当输入是 cold boundary plain object 时: + - 先执行 cold input -> authority + - 再进入 attach / rebuild + +3. scheduler 不得在 authority slice 已 attach 的场景下继续驱动: + - `Chunk.blockData.clear()` + - `_clearForBlockInjection()` + - `_injectBlockDataBatch()` 这类以“重建 chunk-local blockData”为前提的旧流程 + +换句话说: + +- scheduler 不只是“调用 Chunk 的某个方法” +- 它本身也承担了新 authority 生命周期协议是否真正落地的边界责任 + ### 7.3.1.1 loadFromRecord / _injectBlockData Migration Matrix 本阶段必须把 `loadFromRecord()`、`_injectBlockData()`、`_injectBlockDataBatch()` 的旧复合职责拆开,否则 shared authority view 设计会在实施时被旧 mental model 拉回去。 @@ -760,6 +907,35 @@ World 请求 chunk - 禁止继续保留“一个 helper 同时负责写 `blockData` 和建索引”的旧复合职责 - 禁止在 authority 已存在时,`loadFromRecord()` 仍重复执行“plain object -> chunk.blockData”注入流程 +### 7.3.1.2 Non-Block Payload Attach / Restore Protocol + +与 `blockData` attach 协议并列,本阶段还必须补上 non-block payload 的 restore 协议。 + +推荐拆成独立步骤: + +1. `restoreChunkPayloadsFromRegistry(cx, cz)` + - 从 `WorldChunkPayloadRegistry` 读取 `runtimeSeedData/staticEntities` + - 不处理普通块 authority + +2. `Chunk.attachAuthoritySlice()` + - 只挂接 shared `blockData` slice + +3. `Chunk.rebuildDerivedIndexesFromAuthority()` + - 只重建 `visibleKeys/solidBlocks/blockDataArray/...` + +4. `Chunk.restoreRuntimePayloads()` + - 只恢复 `structureCenters`、静态实体 + - `runtimeEntities` / 特殊实体恢复继续走现有兼容链路 + +硬约束: + +- 恢复 `runtimeSeedData/staticEntities` 不能再顺手承担普通块 authority 建立职责 +- 同样地,普通块 authority attach / rebuild 也不能再顺手决定这些 payload 的 owner +- 若继续保留 `loadFromRecord()`,它必须只是上面几步的编排入口,而不是新的混合权威容器 +- `runtimeEntities` / `specialEntitiesShadowStore` 在本阶段可以继续保留既有恢复方式,但必须明确: + - 它是兼容层,不是 `blockData authority` + - 它的状态不能反向决定普通块真相 + ### 7.3.2 Clear / Replace Boundary 为了避免 shared Map 方案在实施时被旧实现语义污染,必须把“谁可以 clear / replace 什么”写成硬约束: @@ -809,6 +985,62 @@ World.unloadChunk() - unload 后 reload 必须仍从 world-level `blockData` 恢复 - 本阶段不要求同步写盘 +### 7.4.1 Detach / Dispose Protocol + +本阶段必须把 unload/dispose 写成与 attach/hydrate 同等级别的协议,而不是只说“卸载时不再 flush”。 + +建议最少拆成: + +1. **detach authority view** + - `Chunk.blockData` 与 world-level authority 的共享引用解除 + - 解除后 chunk 实例不再参与 live 读写 + +2. **dispose derived indexes** + - 释放 `visibleKeys` + - 释放 `solidBlocks` + - 释放 `blockDataArray` + - 释放 `solidBlockIds` + - 释放 `meshData/instanceIndexMap/dynamicMeshes/renderDelta` 等 chunk-local 派生层 + +3. **preserve world-level authorities** + - `WorldBlockDataStore` 中的 block slice 保留 + - `WorldChunkPayloadRegistry` 中本阶段纳入范围的 non-block payload 保留 + - `runtimeEntities` / 特殊实体状态继续沿用现有兼容层,不要求在本阶段改造为 registry owner + +4. **invalidate async callbacks** + - 与该 chunk 生命周期绑定的 worker 回包、AO 回包、consolidation 回包必须在 dispose 后可被识别为过期 + +补充说明: + +- chunk dispose 允许清空 chunk-local 派生结构 +- chunk dispose 不允许清空 world-level authority +- 若某段旧代码仍以 `Chunk.clear()` / `_injectBlockData()` 作为“卸载前准备”,必须明确拆除 + +### 7.4.2 Authority Version / Assembly Epoch Contract + +shared authority view 模式下,必须显式定义“晚到回包如何失效”,否则旧 worker 结果会污染新 attach 的 slice。 + +本阶段建议引入至少一种显式版本机制: + +1. **authority version** + - 每个 chunk slice 在发生逻辑变更时递增 + - AO / consolidation / worker rebuild 可带上 version 或基于 version 做过滤 + +2. **assembly epoch** + - 每次 chunk attach / detach / rebuild 生命周期切换时递增 + - live chunk 只接受当前 epoch 的异步回包 + +最低要求: + +- `acceptWorkerResult()`、scatter patch、AO 回包、consolidation 回包都必须有过期识别依据 +- `deletedBlockTombstones` 继续保留,但 tombstone 不是唯一的一致性保护机制 +- 不能只靠“当前 chunk 还在不在 map 里”判断回包是否有效 + +设计意图: + +- tombstone 解决“旧块被删除后晚到结果复活” +- authority version / epoch 解决“整个 attach 生命周期已经变化,旧回包不该再落地” + ### 7.5 JSON 导入 / 导出 本阶段结论: @@ -959,6 +1191,55 @@ authority 重构后,读路径也必须显式收敛,避免后续出现“写 - runtime 热路径不得把 `RegionCache` 中的 `blockData` 当成 live truth - 若某条路径仍读取 `cachedChunkRecord.blockData`,必须明确这是冷边界导入阶段,而不是运行中逻辑判定阶段 +## 8.3 Authority Codec / Serialization Boundary + +本阶段必须显式区分两种数据格式: + +1. **runtime authority format** + - `Map` + - 仅存在于主线程 world-level authority 与 live chunk shared view + +2. **boundary serialization format** + - plain object / worker payload / 测试夹具对象 + - 仅用于: + - Worker 消息 + - 冷边界输入输出 + - 测试断言快照 + - 未来导入导出 + +建议新增明确 codec: + +- `deserializeChunkBlockDataObject(obj) -> Map` +- `serializeChunkBlockDataSlice(map) -> object` + +硬约束: + +- codec 是边界层,不是热路径 mutation API +- 业务代码不得在 runtime 主链路中频繁 `Object.entries(blockData)` 再回填 authority +- `WorldGenerationService`、`WorldRuntime.ensureChunkData()`、未来 import/export 必须统一走 codec,而不是各自散落地做 object/map 转换 + +这样做的目的不是形式统一,而是防止旧 plain object 心智继续从冷边界回流到 runtime 主存储 + +## 8.4 Runtime Dirty vs Export Dirty + +本阶段虽然不以持久化为门槛,但必须把“dirty”语义拆清楚: + +1. **runtime dirty** + - 含义:chunk 的派生层、AO、render patch、consolidation 等需要后续处理 + - 服务对象:runtime correctness / visual consistency + - 不要求生成 `blockDataSnapshot` + +2. **export/save dirty** + - 含义:将来若要导出或持久化,此 chunk 有尚未导出的变更 + - 服务对象:冷存储 / 手动保存 + - 不得再反向影响 runtime live truth + +因此: + +- `markChunkDirty()` 不应再默认触发“为持久化构造完整 snapshot” +- `recordBlockMutation()` 若保留,必须明确自己属于哪种 dirty 语义 +- `flushChunk()`、`pendingUnloadFlushQueue`、`PersistenceService.cache` 不得再被当成 runtime correctness 机制 + ## 9. 复制与序列化边界 这次改造必须把“什么时候允许 clone”写成硬约束,否则只会把性能问题换位置。 From c74474e13d9a444550b3159286e939853b3605ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Fri, 8 May 2026 11:19:19 +0800 Subject: [PATCH 05/17] =?UTF-8?q?docs:=20=E8=A1=A5=E9=BD=90=20blockData=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80=E6=96=B9=E6=A1=88=E7=9A=84?= =?UTF-8?q?=205=20=E4=B8=AA=E5=85=B3=E9=94=AE=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于对当前代码基线(1ed3544)的深入对照分析,补齐设计和计划文档的以下缺口: 1. Consolidation 协议:新增完整的 7.4.3 小节(send/receive/concurrency/shared-map 四个子协议)+ Task 2.6 2. Chunk → WorldBlockDataStore 访问路径:新增 5.3.4 访问契约 + Task 1 Step 4.2 3. Authority Version / Assembly Epoch 机制:细化 7.4.2 为完整实现契约 + Task 2.5 Step 2.85/2.9 4. 未加载目标 chunk 的写入语义:新增 5.3.5 契约 + 7.2.3 语义定义 + Task 3 Step 3.35 5. 旧热路径退出验收标准:新增 5.4.2 退出标准 + Task 5 Step 5.2 6. 查询边界固定:新增 5.3.6 契约 + Task 3 Step 3.7 Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 172 +++++++++- ...-blockdata-authority-unification-design.md | 293 +++++++++++++++++- 2 files changed, 463 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index 272aaa7..9191fd2 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -46,6 +46,7 @@ ### 重点修改文件 - Modify: `src/world/Chunk.js` +- Modify: `src/world/ChunkConsolidation.js` - 明确 `blockData` 为 chunk 运行时视图 - 收敛所有 blockData 写入口 - 保持 `visibleKeys` / `solidBlocks` / `blockDataArray` / `solidBlockIds` / AO / renderDelta 功能不变 @@ -197,8 +198,22 @@ Expected: - `World` 不再创建 `MemoryWorldStore` - `World` 改为持有 `WorldBlockDataStore` +- `World` 必须向 live `Chunk` 暴露稳定的 authority store 引用 + - 推荐固定为 `world.worldBlockDataStore` + - 或提供等价 accessor,但 owner 语义必须仍然属于 `World` +- `Chunk` 不允许自行 new / 缓存第二份 authority store owner - 任何“卸载前同步到内存仓库”的语义全部删除 +- [ ] **Step 4.2: 固定 Chunk 访问 authority owner 的路径** + +要求: + +- `Chunk` 迁移后访问 authority 的标准路径必须明确固定 +- 推荐以 `this.world.worldBlockDataStore` 作为默认实现路径 +- `_updateBlockState()`、`acceptScatteredBlocks()`、`appendScatteredBlocks()`、batch edit helper 不允许在 `MemoryWorldStore` 删除后各自临时决定依赖注入方式 +- 不建议为了这次改造把 `WorldBlockDataStore` 作为新的广泛构造参数塞进 `new Chunk(...)` +- 若出于测试夹具需要提供 accessor wrapper,也必须保持“`World` 持有 owner,`Chunk` 经由 `World` 访问”的语义不变 + - [ ] **Step 5: 运行相关测试确认通过** Run: @@ -438,7 +453,41 @@ Expected: PASS - `runtimeEntities` / 特殊实体恢复在本阶段只是兼容步骤,不得反向主导 authority attach - `Chunk` dispose 后,authority / payload registry 仍保留,新的 chunk attach 不依赖旧实例残留 +- [ ] **Step 2.85: 实现统一的 authority version / assembly epoch 机制** + +要求: + +- 明确版本 owner: + - `authority version` 由 `WorldBlockDataStore` 或等价 world-level authority owner 维护 + - `assembly epoch` 由 live `Chunk` attach / detach lifecycle 维护 +- 明确递增时机: + - authority slice 发生逻辑修改后递增 `authority version` + - attach 新 live chunk view、detach、dispose、同坐标 reload 后递增或生成新的 `assembly epoch` +- 明确挂载位置: + - live `Chunk` 实例上必须有当前 `assembly epoch` 或等价字段 + - 发往 worker 的 request payload 必须带上 `chunkKey + assemblyEpoch` + - 需要逻辑快照保护的链路还必须带上 `authorityVersion` +- 明确失效规则: + - 回包到达时,只要当前 live chunk 不存在、epoch 不匹配、或 version 不可接受,就必须丢弃 +- 禁止不同异步链路各自发明互不兼容的局部 token / boolean 机制替代统一协议 + +- [ ] **Step 2.9: 把统一 epoch/version 协议接入主要异步链路** + +要求: + +- 至少接入以下路径: + - `acceptWorkerResult()` / chunk assembly callback + - consolidation worker request / response + - AO worker request / response +- `disposed`、`isConsolidating`、worker callback map 可以继续作为局部调度状态存在 +- 但它们不得再单独承担“旧回包能否落地”的最终判断职责 +- 测试必须覆盖: + - Chunk A detach 后,Chunk B 重新 attach 同坐标 slice,旧 consolidation 回包不会污染 Chunk B 派生层 + - 旧 AO 回包不会落到新的 live chunk view + - 旧 assembly callback 不会在 reattach 后继续重建新 chunk 的派生层 + - [ ] **Step 2.9: 固定 detach / dispose 协议** +- [ ] **Step 2.95: 固定 detach / dispose 协议** 要求: @@ -446,7 +495,7 @@ Expected: PASS - 哪些结构被释放 - 哪些 world-level authority 被保留 - 哪些异步回包必须失效 -- 为 attach / detach 生命周期引入 epoch 或等价版本语义 +- detach / dispose 必须触发当前 `assembly epoch` 失效 - 测试必须能证明:晚到 worker 结果不会污染新 attach 的 chunk 视图 - 测试必须能证明:scheduler 切片 hydrate 在 authority slice 已存在时不会 clear 或重注入 `Chunk.blockData` @@ -466,6 +515,86 @@ Expected: PASS --- +### Task 2.6: 做好 consolidation 与新 authority 模型的兼容性适配 + +**Files:** +- Modify: `src/world/ChunkConsolidation.js` +- Modify: `src/world/Chunk.js` +- Modify: `src/world/World.js` +- Test: `src/tests/test-chunk.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定 consolidation 不反向成为第二权威** + +新增测试覆盖: + +- `consolidate()` send side 读取的是 authority slice 的序列化边界,而不是新的 parked snapshot holder +- consolidation worker 回包只更新派生层/渲染层,不替换 authority slice +- consolidation 期间玩家修改后,晚到回包不会覆盖这些修改 +- consolidation 完成后,`Chunk.blockData` 仍指向同一个 authority slice 引用 +- `_initArrayStorageFromBlockData()` 在 consolidation 路径中的语义是“从 authority rebuild derived indexes” +- 保持既有 consolidation 的 face culling / mesh / 3x3x3 AO 行为逻辑不回退 + +- [ ] **Step 2: 适配 consolidation send side** + +要求: + +- `ChunkConsolidation.consolidate()` 读取 shared authority slice 作为输入源 +- `blockDataToNumberKeys(this.blockData)` 一类逻辑若继续保留,必须在注释中明确: + - 它只是一条 worker 消息边界 serialization + - 它不是 runtime parked truth / unload truth / reload truth +- 禁止 consolidation send side 重新引入 `blockDataSnapshot`、chunk-local parked holder、或供后续回包直接主导真相的额外快照语义 +- 不要求在本阶段重写 consolidation 算法本身,只要求 send side 与新 authority 数据流兼容 + +- [ ] **Step 3: 适配 consolidation receive side** + +要求: + +- `_applyConsolidateResult()` 回放的是 worker 派生结果,不是新的逻辑真相 +- 允许: + - 重建 mesh + - 清理旧渲染输出 + - 更新 `instanceIndexMap` / `meshData` / 渲染派生层 + - 从 authority rebuild `blockDataArray` / `solidBlockIds` / `lightSourceCoords` +- 禁止: + - 用 consolidation 回包替换 authority slice + - 因为 consolidation 完成就执行 clear authority -> reinject 的旧流程 +- `Chunk.blockData` 在 consolidation 前后必须保持对同一 authority slice 的共享引用 +- 不要求在本阶段重写 consolidation 的 mesh/AO 生成策略,只要求 receive side 不越权、不回退 + +- [ ] **Step 4: 固定 consolidation 并发保护协议** + +要求: + +- consolidation 回包必须同时受以下机制保护: + - authority version 或等价版本 + - assembly epoch + - `deletedBlockTombstones` + - 玩家后续修改优先级 +- 若 worker 回包与 authority 当前状态冲突,authority 胜出,冲突部分结果必须被丢弃或裁剪 +- 测试必须覆盖: + - consolidation 期间玩家删除/放置方块 + - 回包晚到后不会复活已删除块 + - 回包晚到后不会覆盖更新后的可见性/mesh 归属 + +- [ ] **Step 5: 运行相关测试** + +Run: +- 测试页面运行 `test-chunk.js` +- 运行 `test-world-runtime.js` + +Expected: +- consolidation 在 shared authority 模式下仍能正确重建派生层 +- 不会重新退化为第二权威或旧快照链 +- 既有 consolidation 行为逻辑在新架构下保持兼容 + +- [ ] **Step 6: 运行 lint** + +Run: `npm run lint` +Expected: PASS + +--- + ### Task 3: 穷尽所有 blockData 写入口并统一顺序 **Files:** @@ -540,6 +669,20 @@ Expected: - 玩家修改不会被晚到 scatter 结果覆盖 - hidden block / tombstone 保护不会因 shared Map 而失效 +- [ ] **Step 3.35: 固定未加载目标 chunk 的写入契约** + +要求: + +- 世界生成、overflow、scatter、deferred cross-chunk patch 在目标 chunk 未加载时,也必须先进入 world-level authority owner +- `pendingCrossChunkPatchBuffers` 若继续保留,只允许承担: + - authority patch 调度容器 + - 派生层装配延迟队列 +- 禁止 `pendingCrossChunkPatchBuffers` 成为“尚未写入 authority 的唯一逻辑真相 holder” +- 若玩家编辑对未加载 chunk 仍保持“忽略请求”的现状,必须在 `WorldAccessLayer` 注释和测试中明确:这是产品交互边界,不是 authority 模型限制 +- 测试必须覆盖: + - 目标 chunk 当时未加载,后续 attach / reload 仍能看到先前 patch 结果 + - 不会因为缺少 live chunk view 而丢失 overflow / deferred patch + - [ ] **Step 3.5: 建立统一 mutation 原语** 要求: @@ -567,6 +710,15 @@ Expected: - 为 object <-> Map 转换提供统一 codec,而不是让 `WorldGenerationService`、`WorldRuntime`、`Chunk` 各自散落转换 - 禁止热路径借助 codec 进行整块 object/map 往返 +- [ ] **Step 3.7: 固定查询边界,避免 authority 重构顺手扩散到读热路径** + +要求: + +- `WorldAccessLayer.getBlock()`、`isSolid()`、`getCollisionAt()` 本阶段默认继续以 loaded chunk view correctness 为主 +- 未加载 chunk 查询若继续返回 miss,必须在文档、注释和测试中明确这是本阶段边界 +- 禁止因为 world-level authority 已存在,就默认把 unloaded chunk 查询回源到 authority +- 若某条路径确需读取未加载 authority slice,必须显式标注,不允许隐式扩散到通用读路径 + - [ ] **Step 3.8: 完成双写机制切换** 要求: @@ -576,6 +728,11 @@ Expected: - 写 `WorldBlockDataStore` - 共享 `Chunk.blockData` 自动可见 - 再更新派生层 +- `Chunk` 访问 authority 的路径必须在代码中清晰可读 + - 默认应为 `this.world.worldBlockDataStore` + - 不允许在 `_updateBlockState()` 迁移时重新引入隐藏的第二 store 引用 +- `_updateBlockState()` 迁移时必须同步清理旧的 `this.world.memoryWorldStore` 访问 +- `World` 创建 `Chunk`、测试 world fixture、测试中的 `new Chunk(0, 0, world)` 夹具都必须与新访问路径保持兼容 - 测试必须能证明:主线程不再需要 `memStore.applyBlockMutation()` 这类第二写入步骤 - [ ] **Step 4: 让世界生成直接产出权威 blockData** @@ -808,6 +965,17 @@ Expected: - 注释标明不再参与 runtime 正确性 - 若暂时保留,仅作为未来冷存储恢复时的兼容工具 +- [ ] **Step 5.2: 写出旧热路径退出型验收项** + +要求: + +- 测试、注释或断言必须能证明: + - `MemoryWorldStore` 已不再参与 runtime 热路径读写 + - `blockDataSnapshot` 不再参与 runtime 正确性闭环 + - `pendingUnloadFlushQueue` 不再影响当前会话中的 unload / reload 正确性 + - `ChunkPersistence.saveDebounced()` 不再是玩家编辑后保持 runtime 正确性的必要步骤 +- 禁止出现“实现上仍依赖旧链路,只是在注释里标了 deprecated”的假退出状态 + - [ ] **Step 5.5: 为 snapshot 消费者逐一定义命运** 要求: @@ -1009,6 +1177,8 @@ Expected: PASS - `AOBridge` 继续承担 authority 到 worker mirror 的单向同步 - 注释中明确 AO mirror 是派生副本,不是第二权威 - 若 AO mirror 需要全量重播或重新播种,来源必须是 authority 序列化边界,而不是旧 cache / snapshot +- AO request / response 必须接入 Task 2.5 定义的统一 `assembly epoch` / `authority version` 过期校验协议 +- 禁止 AO 链路单独依赖 `disposed`、局部布尔标志或 mirror 重建状态来判断旧回包是否可落地 - [ ] **Step 3: 运行相关测试** diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index 139f6bd..e6faea7 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -380,7 +380,40 @@ runtime 中只存在一份 world-level `blockData authority`: - `Chunk.blockData` 的共享身份不是“每次随便拿一个最新 Map” - 而是“attach 后在当前生命周期内具有引用稳定性” -### 5.3.4 replaceChunkSlice Contract +### 5.3.4 Chunk -> WorldBlockDataStore Access Contract + +本阶段还必须显式固定 `Chunk` 访问 authority owner 的路径,否则 `_updateBlockState()` 一类旧双写汇聚点在实现时会重新临时发明依赖路径。 + +约束如下: + +1. `WorldBlockDataStore` 的 owner 是 `World` + - `World` 负责创建并持有唯一 runtime `WorldBlockDataStore` 实例 + - `Chunk` 不允许自行 new / 缓存另一份 authority store owner + +2. `Chunk` 对 authority 的访问必须经由所属 `World` + - 推荐固定为 `this.world.worldBlockDataStore` + - 或由 `World` 提供等价稳定 accessor + - 但语义上都属于“`World` 向 `Chunk` 提供 world-level authority 引用” + +3. `Chunk.blockData` 不是 owner,只是 attach 后的 shared slice view + - `Chunk._updateBlockState()` + - `acceptScatteredBlocks()` + - `appendScatteredBlocks()` + - batch edit / patch helper + - 都必须通过 `World` 提供的 `WorldBlockDataStore` mutation API 落 authority + +4. 本阶段不建议把 `WorldBlockDataStore` 作为独立构造参数广泛注入到 `new Chunk(...)` + - 当前代码基线里 `Chunk` 已经通过 `world` 获取跨 chunk 能力与运行时资源 + - 因此优先保持“authority owner 在 world-level,由 `World` 暴露给 `Chunk`”这一结构 + - 若未来为了测试夹具或解耦需要增加 accessor wrapper,可以做,但不能改变 owner 语义 + +设计目标: + +- 避免实现时重新出现 `this.world.memoryWorldStore` 退役后“Chunk 不知道该写谁”的临时拼接 +- 避免把 world-level authority 错误地下沉成 chunk constructor 层面的第二持有者 +- 让 `_updateBlockState()` 的迁移保持最小签名扰动,同时与“authority owner 在 world-level”原则一致 + +### 5.3.5 replaceChunkSlice Contract `replaceChunkSlice(cx, cz, entries)` 必须被定义为**低频 authority lifecycle API**,而不是普通热路径 mutation API。 @@ -414,6 +447,66 @@ runtime 中只存在一份 world-level `blockData authority`: - `Chunk` 侧 `clear + repopulate` - 对 live attached slice 静默换引用 +### 5.3.5 Unloaded Chunk Mutation Contract + +world-level authority 独立于 live `Chunk` 生命周期存在,这意味着本阶段必须把“目标 chunk 未加载时的写入语义”明确固定下来,而不能继续让不同子系统各自决定是否暂存、忽略或延后同步。 + +本阶段统一采用以下契约: + +1. **逻辑修改先命中 authority** + - 只要某个 mutation 已经被判定为合法世界修改,它的首个稳定落点必须是 world-level authority + - 目标 chunk 当前是否已加载,不影响其是否进入 authority + +2. **未加载 chunk 不是“禁止写入”的理由** + - `cross-chunk scatter` + - `overflow merge` + - `deferred patch` + - bootstrap 期间生成出的目标 chunk 数据 + - 上述几类路径在目标 chunk 未加载时,也必须写入对应 authority slice 或等价 world-level patch 容器 + +3. **pending buffer 只能是装配/调度层,不是第二权威** + - `BlockScatterManager.pendingCrossChunkPatchBuffers` + - 各类 worker result buffer + - 各类 runtime deferred patch queue + - 这些结构若继续存在,只允许持有: + - 待装配的派生层输入 + - 待应用到 authority 的临时 patch 描述 + - 不允许成为“唯一尚未写入 authority 的逻辑真相 holder” + +4. **玩家编辑的边界必须单独定义** + - 若当前产品语义仍然规定“未加载 chunk 上的玩家编辑被忽略”,则应在 `WorldAccessLayer` 中显式保留此边界 + - 但文档必须同时写明:这是一条产品交互边界,而不是 authority 模型能力限制 + - 也就是说,本阶段不要求把所有未加载 chunk 玩家编辑都改成可写,但禁止实现者据此推导“未加载 chunk 一律不能进入 authority” + +最低约束: + +- 任何已经接受的逻辑修改,不得只存在于 chunk-local buffer +- `reload` / `future attach` 必须能从 authority 重新观察到之前写入的结果 +- 不允许出现“某个补丁在 live chunk 时正确,一旦目标 chunk 当时未加载就丢失”的分叉语义 + +### 5.3.6 Query Boundary Contract + +本阶段的 primary goal 是统一 runtime 写入权威,而不是顺手扩展所有查询能力。因此查询边界也必须显式固定。 + +本阶段建议采用保守契约: + +1. `WorldAccessLayer.getBlock()` +2. `WorldAccessLayer.isSolid()` +3. `WorldAccessLayer.getCollisionAt()` + +默认仍然以 **loaded chunk view correctness** 为主,不要求在本阶段把 world-level authority 暴露成 unloaded chunk 查询后备源。 + +理由: + +- 这样可以避免在 authority 重构过程中,额外把 world-level store 拉进碰撞、物理、可见性等热路径 +- 这样也可以把本阶段聚焦在“唯一真相如何写入、attach、restore”,而不是把查询体系一并扩散改造 + +明确约束: + +- 不允许因为 world-level authority 已存在,就默认把所有 unloaded chunk 查询回源到 authority +- 若后续确有需要扩展 world-level logical query,应作为单独设计决策提出 +- 当前查询路径继续只保证 live chunk 视图语义,不得因此反向影响 authority 的唯一性设计 + ### 5.4 冷存储完全退出本阶段主链路 本阶段 runtime 正确性不再依赖: @@ -456,6 +549,36 @@ runtime 中只存在一份 world-level `blockData authority`: - `RegionCache` 可以保留 - 但它必须从“权威候选层”降级为“冷边界 / region 管理辅助层” +### 5.4.2 Legacy Hot-Path Exit Criteria + +本阶段不仅要“设计上降级旧链路”,还要给出明确的退出标准,避免实现完成后 runtime 仍然暗中依赖旧 holder。 + +最低退出标准: + +1. `MemoryWorldStore` + - 不再参与 runtime 热路径读写 + - 不再承担 unload/reload 正确性 + +2. `WorldRuntime.blockDataSnapshot` + - 不再参与 runtime 正确性 + - 即便暂时保留,也只能作为 future cold export / deprecated hook + +3. `pendingUnloadFlushQueue` + - 不再是 runtime 会话闭环的一部分 + - flush 成功/失败不得影响当前会话中的逻辑正确性 + +4. `ChunkPersistence.saveDebounced()` + - 不再是玩家编辑后保持 runtime 正确性的必要步骤 + +5. `World._requestRuntimeChunkRecord()` + - 不再以 “memory store -> db fallback” 作为 runtime 主语义 + - cold boundary 只允许作为 authority 尚未建立时的一次性输入源 + +换句话说: + +- “deprecated” 不应只是注释标签 +- 必须通过测试与调用关系保证这些旧链路退出热路径正确性闭环 + ## 6. Target Layering ### 6.1 权威逻辑层 @@ -600,6 +723,27 @@ WorldGenerationService / WorldWorker - 不通过 `PersistenceService.cache` - 不为“未来存档”额外维护第二条热路径 +### 7.1.0 Runtime Generation Ordering Contract + +为了避免实现中继续沿用“先落冷存储、再回灌 runtime”旧顺序,本阶段必须把生成链路顺序写死。 + +推荐顺序: + +```text +WorldGenerationService / WorldWorker result +-> 写 WorldChunkRegistry +-> 写 WorldChunkPayloadRegistry +-> 写 WorldBlockDataStore +-> 若目标 chunk 已加载,则 attach / rebuild +-> 若需要保留 cold persistence hook,则异步 deferred 触发 +``` + +强约束: + +- runtime authority 的建立必须先于任何 cold persistence +- `saveRegionRecord()`、`saveWorldMeta()`、`commitChunkRecord()` 若暂时保留,不得成为 runtime 正确性的前提 +- generation result 中的 `blockData`、`runtimeSeedData`、`staticEntities` 必须先进入 world-level runtime owner,再决定是否额外导出 + ### 7.1.1 WorldGenerationService / WorldBlockDataStore Contract 本阶段必须明确:`WorldGenerationService` 的运行时主产物是 `WorldBlockDataStore`,不是 `WorldStore`。 @@ -701,6 +845,9 @@ BlockScatterManager / deferred cross-chunk patch - 若 patch 涉及多个 chunk,必须分别写入各自 chunk slice,不得先落入某个临时 `blockData` 副本后再统一同步 - 共享 `Map` 方案下,不允许对同一 patch 再执行一次额外的 `Chunk.blockData.set/delete` 补写 - tombstone、hidden block、late worker result 过滤等现有一致性保护逻辑继续保留 +- 目标 chunk 未加载时,patch 也必须先进入 authority 或 authority-level patch 容器,不能因为缺少 live chunk view 就只停留在待装配 buffer +- `pendingCrossChunkPatchBuffers` 若继续保留,只能承担 authority patch 调度与派生层装配延迟职责,不能成为“尚未写入 authority 的唯一逻辑真相 holder” +- 对同一坐标的 patch 冲突必须固定优先级:玩家后续修改 > tombstone / 删除保护 > 晚到生成或 scatter patch ### 7.2.2 BlockScatterManager Contract @@ -729,6 +876,28 @@ BlockScatterManager / deferred cross-chunk patch - `Chunk.acceptScatteredBlocks()` / `appendScatteredBlocks()` 是目标 chunk 的派生层装配入口 - `BlockScatterManager` 才是 scatter patch 的 authority-level 编排入口 +### 7.2.3 Unloaded Target Chunk Write Semantics + +本阶段必须额外固定一个容易被实现时绕回旧模型的问题:**未加载目标 chunk 的逻辑修改是否进入 authority**。 + +统一答案是: + +- **会** + +更准确地说: + +1. 对于世界生成、overflow、scatter、deferred cross-chunk patch: + - 目标 chunk 未加载时,逻辑修改仍然必须进入对应 world-level authority owner + - live chunk view 只是未来 attach 的消费者,不是写入前提 + +2. 对于玩家主动编辑: + - 若当前产品语义仍规定“未加载 chunk 不响应编辑”,可以继续保留该交互边界 + - 但这是一条产品交互边界,而不是 authority 模型能力限制 + +3. 对于 reload 语义: + - 后续 chunk attach 时,应从 authority 观察到这些先前写入的结果 + - 不允许再依赖重新生成、重新散射或回源冷边界来“碰巧恢复” + ### 7.3 Chunk 加载 目标链路: @@ -1036,11 +1205,131 @@ shared authority view 模式下,必须显式定义“晚到回包如何失效 - `deletedBlockTombstones` 继续保留,但 tombstone 不是唯一的一致性保护机制 - 不能只靠“当前 chunk 还在不在 map 里”判断回包是否有效 +最小实现契约: + +1. `authority version` 必须由 world-level authority owner 维护 + - 对同一 chunk slice 的逻辑修改一旦提交到 `WorldBlockDataStore`,对应 version 递增 + - 不能由各个 worker 链路各自维护一套彼此无关的“本地版本号” + +2. `assembly epoch` 必须由 live chunk attach lifecycle 维护 + - 每次 attach 新 live view 时生成或递增 + - detach / dispose 后旧 epoch 立即失效 + - 同坐标 chunk reload 后,即使复用同一 authority slice,也必须视为新的 assembly epoch + +3. 发往异步链路的 request 必须携带过期识别信息 + - 至少带上 `chunkKey + assemblyEpoch` + - 需要基于逻辑变更过滤的链路还应带上 `authorityVersion` 或等价快照版本 + +4. 异步回包落地前必须统一校验 + - 当前 live chunk 是否仍存在 + - 当前 live chunk 的 `assemblyEpoch` 是否仍匹配 + - 若该链路依赖逻辑快照,则 `authorityVersion` 是否仍可接受 + - 任一条件不满足,都必须把回包视为 stale result 并丢弃 + +5. 该协议必须作为统一 runtime contract 复用 + - consolidation + - AO worker + - chunk assembly / worker hydrate callback + - 其他 attach 后才允许落到派生层的异步结果 + - 不允许每条链路各自发明一套 token / disposed / boolean 标志来替代统一协议 + 设计意图: - tombstone 解决“旧块被删除后晚到结果复活” - authority version / epoch 解决“整个 attach 生命周期已经变化,旧回包不该再落地” +### 7.4.3 Consolidation Protocol + +`consolidation` 不是本阶段的 primary redesign target。它的既有 face culling / mesh / 3x3x3 AO 相关行为逻辑应当继续保留,本阶段只要求它与新的 authority / shared view / epoch 语义兼容,不得因为权威数据源重构而回退。 + +之所以仍需单独写出协议,是因为 `consolidation` 天然是一条“authority 边界序列化 -> worker 重算 -> 异步结果回放”链路。如果不把它的边界写清楚,实施时很容易在这里重新引入第二权威或旧式快照 holder。 + +因此,本阶段对 consolidation 的定位必须是: + +- **它消费 authority 的边界快照** +- **它产出新的派生层/渲染层结果** +- **它不能反向成为逻辑真相来源** + +也就是说,在本阶段它的职责是: + +1. 基于 authority 当前内容构造 worker 输入 +2. 让 worker 重算 face culling / mesh / AO 相关派生结果 +3. 在主线程回放结果时,与 authority 当前状态做兼容性校验 +4. 只更新派生层与渲染输出,不替换 authority + +#### 7.4.3.1 Consolidation Send-Side Contract + +send side 必须明确: + +1. `consolidate()` 读取的是当前 authority slice +2. 允许为了 worker `postMessage` 做一次 **boundary serialization** +3. 这次 serialization 的语义是: + - worker 输入快照 + - 不是新的权威 holder + - 不是后续 reload 的真相来源 + +因此,在本阶段: + +- `blockDataToNumberKeys(this.blockData)` 一类操作在新模型下只允许被视为 worker 消息边界 codec +- 它的存在不应重新引入 `blockDataSnapshot` / parked snapshot / unload holder 语义 +- consolidation send side 允许 clone 一次,但这个 clone 只能服务于当前 worker 任务 + +#### 7.4.3.2 Consolidation Receive-Side Contract + +receive side 必须明确: + +1. `_applyConsolidateResult()` 回放的是 worker 派生结果 +2. 它可以: + - 重建 mesh + - 清理旧 dynamic mesh / instanced mesh + - 刷新 `instanceIndexMap` + - 刷新 `renderDelta` 对应输出状态 + - 从 authority 重新 rebuild `blockDataArray` / `solidBlockIds` / `lightSourceCoords` 等派生索引 + +3. 它不可以: + - 用 worker 回包替换 authority slice + - 把 worker 结果当成“比 authority 更新的逻辑真相” + - 因为收到 consolidation 结果,就重走一遍 “clear authority -> reinject blockData” 旧流程 + +硬约束: + +- `_initArrayStorageFromBlockData()` 在 consolidation receive side 的语义必须明确为: + - **从 authority rebuild derived indexes** + - 而不是“根据 worker 回包重建逻辑真相” + +#### 7.4.3.3 Consolidation Concurrency Contract + +consolidation 期间,玩家可能继续修改方块。因此 worker 回包必须额外通过以下保护,以确保既有行为逻辑在新 authority 模型下不退化: + +1. `authority version` +2. `assembly epoch` +3. `deletedBlockTombstones` +4. 玩家修改后与 worker 输入快照之间的差异过滤 + +最低要求: + +- consolidation 开始后发生的玩家修改,不得被旧回包覆盖 +- `_applyConsolidateResult()` 中对 scatteredBlocks / visible blocks / meshData 的过滤,必须以“authority 当前状态优先”为原则 +- 若 worker 回包与 authority 当前内容冲突,authority 胜出,冲突部分结果必须被丢弃或裁剪 + +换句话说: + +- consolidation 的结果是“可应用的派生建议” +- 不是“主线程必须无条件接受的新真相” + +#### 7.4.3.4 Consolidation and Shared Map Compatibility + +shared `Map` 模式下,consolidation 还必须满足两个额外兼容条件: + +1. consolidation 不能通过 `this.blockData.clear()` 或替换 `this.blockData = new Map()` 来准备重建 +2. consolidation 不能因为要重建派生层,就隐式改变 live authority slice 的引用身份 + +因此: + +- consolidation 允许清空 chunk-local 派生索引 +- consolidation 不允许清空或替换 shared authority slice +- consolidation 结果落地后,`Chunk.blockData` 仍应保持对同一 authority slice 的共享引用 + ### 7.5 JSON 导入 / 导出 本阶段结论: @@ -1336,6 +1625,8 @@ authority 重构后,读路径也必须显式收敛,避免后续出现“写 5. `saveDebounced()`、`flushChunk()`、`PersistenceService.cache` 失效或旁路时,不影响 runtime authority 正确性 6. `RegionCache` 中残留的 `blockData` 不会在 authority 建立后重新覆盖或污染 live truth 7. AO Worker mirror 延迟或重建时,不会把旧 AO 结果回写成逻辑真相 +8. consolidation 期间玩家后续修改不会被晚到 consolidation 回包覆盖 +9. consolidation 回包不会替换 authority slice,只会刷新派生层与渲染输出 ## 12. Migration Strategy From 39d5bbfbbf66a8d8b1be384dfdd072dc2cb29c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Fri, 8 May 2026 12:30:45 +0800 Subject: [PATCH 06/17] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=20blockData=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80=E8=AE=BE=E8=AE=A1=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=A1=A5=E9=BD=90=E6=95=A3=E8=90=BD=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E3=80=81=E7=94=9F=E6=88=90=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E4=B8=8E=E8=A3=85=E9=85=8D=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 97 +++++++++++- ...-blockdata-authority-unification-design.md | 140 ++++++++++++++++++ 2 files changed, 230 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index 9191fd2..43e6f35 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -143,11 +143,10 @@ --- -### Task 1: 固定新的权威模型并删除 `MemoryWorldStore` +### Task 1: 固定新的权威模型并先建立新的 world-level owners **Files:** - Create: `src/world/WorldBlockDataStore.js` -- Delete: `src/world/MemoryWorldStore.js` - Modify: `src/world/World.js` - Test: `src/tests/test-world.js` @@ -192,17 +191,17 @@ Expected: - runtime 热路径禁止把 `replaceChunkSlice()` 当成单块修改或 scatter patch 的通用入口 - 一旦某个 slice 已 attach 给 live chunk,禁止静默替换其 `Map` 实例;若必须整块替换,只能走 detach -> replace -> reattach 协议 -- [ ] **Step 4: 从 `World` 中移除 `MemoryWorldStore` 初始化与引用** +- [ ] **Step 4: 让 `World` 先持有新的 block authority owner** 要求: -- `World` 不再创建 `MemoryWorldStore` - `World` 改为持有 `WorldBlockDataStore` - `World` 必须向 live `Chunk` 暴露稳定的 authority store 引用 - 推荐固定为 `world.worldBlockDataStore` - 或提供等价 accessor,但 owner 语义必须仍然属于 `World` - `Chunk` 不允许自行 new / 缓存第二份 authority store owner -- 任何“卸载前同步到内存仓库”的语义全部删除 +- 在 `WorldChunkPayloadRegistry` / `WorldChunkRegistry` 尚未到位前,不得提前删除 `MemoryWorldStore` +- 本 step 的目标是先让新的 block authority owner 可用,而不是立即移除旧容器 - [ ] **Step 4.2: 固定 Chunk 访问 authority owner 的路径** @@ -214,6 +213,18 @@ Expected: - 不建议为了这次改造把 `WorldBlockDataStore` 作为新的广泛构造参数塞进 `new Chunk(...)` - 若出于测试夹具需要提供 accessor wrapper,也必须保持“`World` 持有 owner,`Chunk` 经由 `World` 访问”的语义不变 +- [ ] **Step 4.5: 明确 `MemoryWorldStore` 的删除前置条件** + +要求: + +- 删除 `MemoryWorldStore` 之前,以下三类新 owner 必须全部到位: + - `WorldBlockDataStore` + - `WorldChunkPayloadRegistry` + - `WorldChunkRegistry` +- 在 `staticEntities`、`runtimeSeedData`、chunk presence / generation state 仍未迁出前,`MemoryWorldStore` 只能被视为临时兼容容器,不能被提前移除 +- 文档与实现都必须避免出现“blockData 已迁出,但 non-block payload 暂时无处承接”的中间态 +- `runtimeEntities` / 特殊实体链路即使本阶段不做大重构,也必须在旧容器删除前有明确兼容落点 + - [ ] **Step 5: 运行相关测试确认通过** Run: @@ -234,6 +245,7 @@ Expected: PASS **Files:** - Create: `src/world/WorldChunkPayloadRegistry.js` - Create: `src/world/WorldChunkRegistry.js` +- Delete: `src/world/MemoryWorldStore.js` - Modify: `src/world/World.js` - Modify: `src/world/WorldRuntime.js` - Modify: `src/world/WorldGenerationService.js` @@ -291,6 +303,28 @@ Expected: PASS - `WorldRuntime.ensureChunkData()` 若仍保留冷边界职责,必须与 registry 语义对齐,而不是另起一套 missing 判定 - `runtimeEntities` / 特殊实体恢复继续沿用现有兼容链路,只要求与新的 authority attach / reload 不冲突 +- [ ] **Step 4.2: 明确 Task 1.2 -> Task 2 的临时装配过渡态** + +要求: + +- 在 Task 2 建立 shared view 之前,`WorldBlockDataStore` 已经是 runtime authority owner,但 `Chunk.blockData` 仍可能是 chunk-local `Map` +- 因此在这一过渡期内,`World._requestRuntimeChunkRecord()` 可临时从 `WorldBlockDataStore` 读取 authority slice,并通过兼容过渡版 hydrate / inject 流程填充 `Chunk.blockData` +- 这类 copy 仅是过渡期装配手段,不构成第二权威 +- unload / reload 正确性在这一阶段仍必须由 `WorldBlockDataStore` 保证,而不是由 chunk-local `Map` 保证 +- 该临时复制路径必须在 Task 2 中被 `attachAuthoritySlice()` + shared slice view + `rebuildDerivedIndexesFromAuthority()` 取代 + +- [ ] **Step 4.5: 在三类新 owner 接管后再删除 `MemoryWorldStore`** + +要求: + +- 只有当以下职责都已迁出后,才允许删除 `MemoryWorldStore`: + - `blockData` -> `WorldBlockDataStore` + - `staticEntities` / `runtimeSeedData` -> `WorldChunkPayloadRegistry` + - chunk presence / generation state -> `WorldChunkRegistry` +- `WorldGenerationService._writeRegionToMemoryStore()`、`World._requestRuntimeChunkRecord()` 等旧读写路径必须先改为新 owner/registry 组合,再删除旧容器 +- 删除时不允许留下任何仍以整包 chunk record 读取 `MemoryWorldStore` 作为 runtime 正确性前提的路径 +- `MemoryWorldStore` 的删除应视为“新 owner 全部接管完成”的收尾步骤,而不是第一阶段的前置动作 + - [ ] **Step 5: 运行相关测试** Run: @@ -410,6 +444,16 @@ Expected: PASS - rebuild derived indexes - restore payload - 禁止继续驱动 `_clearForBlockInjection()` + `_injectBlockDataBatch()` 这类 clear + inject 语义 +- `ChunkAssemblyScheduler` 的 runtime hydrate stage 需要显式迁移为: + - `record-ready` + - `attach-slice` + - `rebuild-indexes`(可分帧) + - `terrain-built` + - `runtime-finalize` / `finalized` +- 若实现继续沿用 `runtime-hydrate` 命名,也必须在语义上改成: + - 首次进入时 attach slice 并初始化 rebuild cursor + - 后续分帧只推进 derived indexes rebuild + - 不再分帧注入 `blockData` - 明确采用三段式协议: - `WorldBlockDataStore.replaceChunkSlice()` / fill API - `Chunk.attachAuthoritySlice()` 或等价挂载步骤 @@ -429,6 +473,7 @@ Expected: PASS - `Chunk` 侧只允许: - 重新 attach slice - 清空并重建派生索引 +- `_clearForBlockInjection()` 若保留,必须重写成“清空派生索引并初始化 rebuild progress”,不得再操作 `this.blockData` - 测试必须能证明:shared authority slice 在 chunk rebuild 过程中不会被清空 - [ ] **Step 2.75: 拆除 `loadFromRecord` / `_injectBlockData` 的旧复合职责** @@ -440,6 +485,8 @@ Expected: PASS - cold input -> authority helper - 或 authority -> derived indexes rebuild helper - `_injectBlockDataBatch()` 若保留,必须明确是在分批 rebuild 派生索引,而不是分批写 chunk-local 逻辑真相 +- `assembleRuntimeHydratePhase()` 必须从“分帧注入 `blockData`”迁移为“分帧 rebuild 派生索引” +- `ChunkAssemblyScheduler._runTask()` 必须能清晰读出 attach / rebuild / finalize 的阶段边界,而不是继续把 hydrate 理解成 blockData 注入 - 禁止继续保留“一个 helper 同时负责写 `blockData` 和建索引”的旧实现 - [ ] **Step 2.8: 写失败测试,锁定迁移后的职责边界** @@ -452,6 +499,7 @@ Expected: PASS - `runtimeSeedData/staticEntities` 的恢复步骤与 blockData attach 步骤彼此独立 - `runtimeEntities` / 特殊实体恢复在本阶段只是兼容步骤,不得反向主导 authority attach - `Chunk` dispose 后,authority / payload registry 仍保留,新的 chunk attach 不依赖旧实例残留 +- 大 chunk runtime hydrate 仍保留分帧能力,但分帧推进的是 derived indexes rebuild,而不是 blockData 注入 - [ ] **Step 2.85: 实现统一的 authority version / assembly epoch 机制** @@ -486,8 +534,7 @@ Expected: PASS - 旧 AO 回包不会落到新的 live chunk view - 旧 assembly callback 不会在 reattach 后继续重建新 chunk 的派生层 -- [ ] **Step 2.9: 固定 detach / dispose 协议** -- [ ] **Step 2.95: 固定 detach / dispose 协议** +- [ ] **Step 2.10: 固定 detach / dispose 协议** 要求: @@ -662,9 +709,22 @@ Expected: - `BlockScatterManager` 在 shared authority 模式下被明确视为 patch 编排层,而不是 chunk-local blockData 灌入层 - `distributeBlocks()`、`scatter()`、deferred cross-chunk patch 路径必须对每个目标 chunk 命中其 authority slice +- `_scatterWithRouting()` 必须改为按目标 chunk 产出 authority patch plan,而不是产出 chunk-local 注入计划 +- `ownBuffer` / target buffer 的语义必须改成: + - authority patch batch + - 以及后续派生层装配计划 + - 不再代表“待写入 chunk-local blockData 的列表” +- 对于已加载目标 chunk: + - scatter 结果必须经由统一 mutation primitive 写入 authority + - `acceptScatteredBlocks()` / `appendScatteredBlocks()` 若保留,只允许承担 authority 已写入后的派生层更新或薄编排壳职责 +- 对于未加载目标 chunk: + - scatter 结果也必须立即写入 `WorldBlockDataStore` + - 不得因为没有 live chunk view 就只留在 `chunkBuffers` / `pendingCrossChunkPatchBuffers` - tombstone 检查、hidden block 过滤、late worker result 保护在 shared authority 模式下必须继续成立 - 禁止 `BlockScatterManager` 通过“先把块灌进 chunk-local 副本,后续再同步”的旧思路维持正确性 +- 禁止 `acceptScatteredBlocks()` / `appendScatteredBlocks()` 继续一边写 truth、一边直接维护另一份隐式 chunk-local 正确性 - 测试必须覆盖: + - own chunk / loaded foreign chunk / unloaded foreign chunk 三种目标分支 - cross-chunk patch 命中正确目标 slice - 玩家修改不会被晚到 scatter 结果覆盖 - hidden block / tombstone 保护不会因 shared Map 而失效 @@ -677,7 +737,9 @@ Expected: - `pendingCrossChunkPatchBuffers` 若继续保留,只允许承担: - authority patch 调度容器 - 派生层装配延迟队列 +- `chunkBuffers` 若继续保留,也只允许承担已写 authority 后的分组/调度元数据,不得继续保存唯一逻辑真相 - 禁止 `pendingCrossChunkPatchBuffers` 成为“尚未写入 authority 的唯一逻辑真相 holder” +- 禁止形成“scatter patch 先缓存在 buffer,等目标 chunk 加载后再真正写 authority”的新中间态 - 若玩家编辑对未加载 chunk 仍保持“忽略请求”的现状,必须在 `WorldAccessLayer` 注释和测试中明确:这是产品交互边界,不是 authority 模型限制 - 测试必须覆盖: - 目标 chunk 当时未加载,后续 attach / reload 仍能看到先前 patch 结果 @@ -797,8 +859,29 @@ Expected: PASS - 预生成结果的 runtime 主落点改为 `WorldBlockDataStore` - `_writeRegionToMemoryStore` 一类旧内存权威写入路径必须退出 - `RegionRecord` / chunk 结果整理后,先写 authority,再决定是否触发已加载 chunk 的 attach / rebuild +- `WorldGenerationService` 必须成为 worker 边界到 runtime authority 的主转换点: + - `blockDataBlocks[]` / plain object boundary format + - 统一走 codec + - 构建 `Map` + - 再调用 `WorldBlockDataStore.replaceChunkSlice(cx, cz, map)` +- `Chunk` 不再承担 world generation 的 plain object -> Map 转换职责 - `WorldStore.saveRegionRecord()`、`saveWorldMeta()` 若暂时保留,只能作为 deferred / future hook,不得作为 runtime 正确性的必要步骤 +- [ ] **Step 2.2: 固定 `_generateRegion()` 的生成结果注入顺序** + +要求: + +- 对每个生成出的 chunk,runtime 注入顺序必须明确可读为: + - worker result / `blockDataBlocks[]` + - `WorldGenerationService` 调用统一 codec + - `WorldBlockDataStore.replaceChunkSlice(cx, cz, map)` + - `WorldChunkRegistry.markChunkGenerated(cx, cz)` + - `WorldChunkPayloadRegistry.setChunkPayload(cx, cz, { runtimeSeedData, staticEntities })` + - 若 chunk 已加载,再执行 `attachAuthoritySlice()` + `rebuildDerivedIndexesFromAuthority()` +- 若某些中间结构继续使用 plain object,只允许作为 boundary format 存在 +- 不允许在 `Chunk.loadFromRecord()` / `_injectBlockData()` 阶段再临时承担生成结果转 authority 的职责 +- 不允许让 `WorldStore` / `RegionRecord` 的 plain object 反向决定 runtime authority 主格式 + - [ ] **Step 3: 处理 bootstrap 路径中的 overflow 与未加载 chunk** 要求: diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index e6faea7..72b3353 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -693,6 +693,10 @@ AO Worker 的 `blockData` 镜像在新模型中仍然允许存在,但它必须 - 只要求现有渲染、互动、reload 行为不回退 - 不要求本阶段完成其 owner 模型重构 - 但必须明确其状态不能反向成为 `blockData authority` 的真相来源 +- owner 边界必须显式固定: + - `WorldChunkPayloadRegistry` 本阶段只负责 `runtimeSeedData + staticEntities` + - `runtimeEntities` 继续由现有 `SpecialEntitiesShadowStore` 持有和管理 + - `WorldChunkPayloadRegistry` 可预留未来接入挂点,但本阶段不接管 `runtimeEntities` - 相比之下,`staticEntities` 与 `runtimeSeedData` 更靠近 chunk reload / 装配主链路,本阶段必须为它们提供明确的 world-level restore 来源 ### 6.8 Section 6 保留范围总结 @@ -786,6 +790,58 @@ WorldGenerationService._generateRegion() - 它们只能是 deferred / future hook - 不能是本阶段生成完成的必要步骤 +### 7.1.1.1 Generation Result Injection Protocol + +除了确定“生成结果先写 authority”之外,本阶段还必须明确 **world generation 的 blockData 格式转换链路**。 + +当前 world generation 天然会经过 worker / cold boundary 格式: + +- WorldWorker 产出 `blockDataBlocks[]` 或等价消息结构 +- `RegionRecord.chunks[].blockData` 若继续存在,也更接近 plain object boundary format + +但 runtime authority 主存储必须固定为 `Map`。因此本阶段推荐把生成结果注入协议写死为: + +```text +Worker result (blockDataBlocks[] / plain-object boundary format) +-> WorldGenerationService 调用统一 codec +-> 构建 chunk authority Map +-> WorldBlockDataStore.replaceChunkSlice(cx, cz, map) +-> WorldChunkRegistry.markChunkGenerated(cx, cz) +-> WorldChunkPayloadRegistry.setChunkPayload(cx, cz, payload) +-> 若 chunk 已加载:attachAuthoritySlice() + rebuildDerivedIndexesFromAuthority() +-> 若 chunk 未加载:仅保留 authority / registry / payload,等待未来 attach +``` + +硬约束: + +1. `plain object -> Map` 的主转换点必须前移到 generation result injection boundary + - 推荐由 `WorldGenerationService` 调用统一 codec 完成 + - 不允许继续把转换延后到 `Chunk.loadFromRecord()` / `_injectBlockData()` 内部再做 + +2. `Chunk` 不再承担“把生成结果变成 runtime authority”的职责 + - `Chunk` 只负责 attach authority slice + - rebuild derived indexes + - restore non-block payload + +3. `replaceChunkSlice()` 在这里属于合法低频整块 authority 注入 + - 因为世界生成属于 authority lifecycle 建立场景 + - 但它仍然不是普通热路径 mutation API + +4. 若 `RegionRecord` / `WorldStore` / `saveRegionRecord()` 等冷边界仍暂时保留 + - 它们只能被视为 deferred serialization / cold persistence boundary + - 不得反向决定 runtime authority 的主格式 + +5. `blockDataBlocks[]`、plain object、`Map` 三者职责必须被严格区分 + - `blockDataBlocks[]`:worker 消息边界格式 + - plain object:冷边界 / 测试夹具 / 兼容输入格式 + - `Map`:runtime authority 唯一主格式 + +设计意图: + +- 让 `WorldGenerationService` 成为 world worker 边界到 runtime authority 的正式转换点 +- 避免 object/map 转换散落在 `WorldGenerationService`、`WorldRuntime`、`Chunk` 多处 +- 避免 `Chunk` 在 shared authority 模式下继续承担旧式“注入 blockData”职责 + ### 7.1.2 Region Concept vs Region Persistence 本阶段必须把两个概念拆开: @@ -876,6 +932,51 @@ BlockScatterManager / deferred cross-chunk patch - `Chunk.acceptScatteredBlocks()` / `appendScatteredBlocks()` 是目标 chunk 的派生层装配入口 - `BlockScatterManager` 才是 scatter patch 的 authority-level 编排入口 +### 7.2.2.1 Scatter Routing / Buffer / Authority Injection Protocol + +为了避免 scatter 路径在 shared authority 模式下重新长出 staging truth,本阶段还必须把 `BlockScatterManager` 的 routing 与 buffer 语义写具体。 + +推荐协议: + +1. `_scatterWithRouting()` 输出的不再是“往某个 chunk-local blockData 灌数据”的计划 + - 而是按目标 chunk 分组后的 authority patch plan + - 对每个目标 chunk 都要先决定: + - own chunk + - loaded foreign chunk + - unloaded foreign chunk + +2. 对于 own chunk / 已加载目标 chunk + - scatter 结果必须先经由统一 mutation primitive 写入目标 authority slice + - 然后再由目标 chunk 增量更新 `visibleKeys`、`solidBlocks`、`lightSourceCoords`、`blockDataArray` 等派生层 + - `acceptScatteredBlocks()` / `appendScatteredBlocks()` 若继续保留,必须降级为: + - authority 已经写入后的派生层装配入口 + - 或统一 mutation primitive 的薄编排壳 + - 不允许它们继续一边写 truth、一边顺手维护另一份“隐式 chunk-local 正确性” + +3. 对于未加载目标 chunk + - patch 也必须立即进入 `WorldBlockDataStore` + - 不得因为缺少 live chunk view 而暂缓 authority 建立 + - 未来该 chunk attach 时,再通过 attach / rebuild 恢复派生层 + +4. `chunkBuffers` / `pendingCrossChunkPatchBuffers` 若继续保留,其语义必须降级 + - 允许承担: + - 已提交 authority 的 patch metadata + - attach 后的派生层补建 / 延迟装配任务 + - 不允许承担: + - 尚未写入 authority 的唯一逻辑真相 + - “先缓存在 buffer,等 chunk 加载后再真正写 world-level truth”的旧职责 + +5. `ownBuffer` / target buffer 的设置逻辑必须随之调整 + - ownBuffer 不再代表“本 chunk 的 chunk-local 注入列表” + - 而应代表“本目标 chunk 的 authority patch batch + 后续派生层装配计划” + - 若某批 patch 最终被 tombstone / 玩家后续修改 / hidden-block 过滤掉,必须在 authority 判定阶段就消化,而不是等 chunk-local 注入时再决定 + +设计意图: + +- 让 scatter routing 在 authority level 完成逻辑真相落地 +- 让 loaded / unloaded target 的区别只影响“何时装配派生层”,不影响“真相是否已成立” +- 让 `acceptScatteredBlocks()` / `appendScatteredBlocks()` 退出旧式 truth + index 混合职责 + ### 7.2.3 Unloaded Target Chunk Write Semantics 本阶段必须额外固定一个容易被实现时绕回旧模型的问题:**未加载目标 chunk 的逻辑修改是否进入 authority**。 @@ -1007,6 +1108,35 @@ _clearForBlockInjection() - `_clearForBlockInjection()` - `_injectBlockDataBatch()` 这类以“重建 chunk-local blockData”为前提的旧流程 +4. scheduler 的可中断能力必须保留,但作用对象要改变 + - 旧模式里,分帧 budget 用在“分批把 plain object 注入 `Chunk.blockData`” + - 新模式里,分帧 budget 必须改为“分批 rebuild `visibleKeys` / `solidBlocks` / `blockDataArray` / `solidBlockIds` / `lightSourceCoords` 等派生索引” + - 不允许因为 shared authority 改造,就把大 chunk hydrate 简化为一次性同步全量 rebuild,从而把主线程卡顿重新引入 + +5. 推荐把 scheduler 的 runtime hydrate 链路语义显式调整为: + +```text +record-ready +-> attach-slice +-> rebuild-indexes // 可分帧 +-> terrain-built +-> finalized +``` + +如果实现上继续沿用 `runtime-hydrate` 命名,也必须满足: + +- 首次进入时只做 attach authority slice 与 rebuild cursor 初始化 +- 后续继续帧只推进 derived indexes rebuild cursor +- payload restore 在 attach / rebuild 之后单独执行 + +换句话说,scheduler 的“可中断装配”在新模型里应该服务于: + +- `authority -> derived indexes` + +而不应继续服务于: + +- `plain object -> chunk-local blockData` + 换句话说: - scheduler 不只是“调用 Chunk 的某个方法” @@ -1273,6 +1403,16 @@ send side 必须明确: - `blockDataToNumberKeys(this.blockData)` 一类操作在新模型下只允许被视为 worker 消息边界 codec - 它的存在不应重新引入 `blockDataSnapshot` / parked snapshot / unload holder 语义 - consolidation send side 允许 clone 一次,但这个 clone 只能服务于当前 worker 任务 +- 对 shared authority slice 的这次 serialization 应被理解为 **point-in-time snapshot / best-effort snapshot** + - 它不锁定 live authority + - 不要求为 consolidation send side 引入暂停玩家修改或显式加锁机制 + - 这里的 `best-effort` 指的是“发送给 worker 后可立刻过时”,而不是主线程遍历过程中需要处理多线程撕裂 +- 因此,send side 不负责维持“worker 回包时刻仍然与 live authority 强一致” + - 这一点必须由 receive side 的 `authority version` + - `assembly epoch` + - `deletedBlockTombstones` + - 玩家后续修改优先级 + 共同兜底 #### 7.4.3.2 Consolidation Receive-Side Contract From c846695e2f1ad340e122c43d6cbec79ab692a2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Fri, 8 May 2026 12:44:07 +0800 Subject: [PATCH 07/17] =?UTF-8?q?docs:=20=E5=9B=BA=E5=8C=96=20blockData=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=9F=E4=B8=80=E6=96=B9=E6=A1=88=E7=9A=84?= =?UTF-8?q?=E8=BF=90=E7=BB=B4=E8=BE=B9=E7=95=8C=E2=80=94=E2=80=94deferred?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E3=80=81API=E9=80=80=E5=BD=B9=E8=A1=A8?= =?UTF-8?q?=E3=80=81=E8=AF=BB=E8=B7=AF=E5=BE=84=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E9=80=80=E5=87=BA=E5=9E=8B=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 157 ++++++++++++++ ...-blockdata-authority-unification-design.md | 194 +++++++++++++++++- 2 files changed, 349 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index 43e6f35..e748bbf 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -25,6 +25,66 @@ - 特殊实体(矿车、丧尸巢穴、炮塔等)的既有渲染、互动、与主世界/玩家的交互能力不回退 - 若实现过程中发现某一步必须重新依赖 `PersistenceService.cache`、`WorldStore.commitChunkRecord()`、`blockDataSnapshot` 或 `pendingUnloadFlushQueue` 才能保证 runtime 正确性,则应停止推进并回到设计层修正,而不是临时接回旧链路。 +## Deferred 能力矩阵 + +| 能力 | 本阶段状态 | 允许行为 | 禁止行为 | +|---|---|---|---| +| `IndexedDB` 自动持久化 | deferred | 保留兼容壳层、future hook | 作为 runtime correctness 前提 | +| `Game.collectSnapshot()` / 手动存档 | deferred or compatibility-only | 显式禁用,或改为 authority/export hook | 继续直接读取 `worldStore` 作为 runtime 真相 | +| `applySaveData()` / 手动读档 | deferred or compatibility-only | 保留接口签名、future hook | 在本阶段承担 live authority owner 职责 | +| JSON 导入 / 导出 | deferred | 只保留 codec / 接入点 | 反向定义 runtime 主存储格式 | +| 旧存档兼容迁移 | compatibility-only | 一次性 cold import 后写入 authority | 运行期持续从旧冷边界回源做 live truth | + +硬约束: + +- 若某个 deferred 能力暂时继续暴露给 UI 或测试,必须在注释中明确自己不是本阶段门槛。 +- `collectSnapshot()` / `applySaveData()` 若不立即重写,必须在计划中被标记为 `deferred shell` 或 `authority-based rewrite`,不能处于语义模糊状态。 + +## 旧 API 退役矩阵 + +| 旧接口 / 结构 | 本阶段目标命运 | +|---|---| +| `Chunk.loadFromRecord()` | authority 编排入口或拆分重命名 | +| `_injectBlockData()` | 删除,或收缩为单一 rebuild/helper | +| `_injectBlockDataBatch()` | 改成分帧 rebuild helper,不再注入 truth | +| `_clearForBlockInjection()` | 删除或重写,不再 `clear()` authority slice | +| `ChunkPersistence.saveDebounced()` | dirty marker shell 或 no-op | +| `WorldRuntime.recordBlockMutation()` | runtime-dirty only,或与 export-dirty 明确分离 | +| `flushChunk()` / `flushBeforeUnload()` / `flushAllDirty()` | deferred cold-export shell | +| `pendingUnloadFlushQueue` | deferred shell 或删除 | +| `Game.collectSnapshot()` | deferred shell 或 authority-based rewrite | +| `PersistenceService.cache` | compatibility-only,退出 runtime 真相链 | + +硬约束: + +- shared authority 语义下有误导性的 helper,优先删名或改名,不接受“只补注释不拆职责”的假收口。 +- 任何保留下来的旧接口都必须在实现中明确标注:是否参与 runtime 主链路、若读取数据则新来源是什么。 + +## 写入口收编矩阵 + +| 入口 | 本阶段目标职责 | +|---|---| +| `WorldAccessLayer.setBlock/removeBlock/applyBatchEdits` | 唯一业务层编辑入口,先命中 authority mutation primitive | +| `Chunk._updateBlockState()` | 薄派生层同步 helper,不再承担双写 | +| `Chunk.addBlockDynamic()` | 单块编辑编排入口 | +| `Chunk.addBlocksBatchFast()` | 批量局部 patch 编排入口 | +| `Chunk.removeBlocksBatch()` | 批量删除 patch 编排入口 | +| `Chunk.acceptScatteredBlocks()` / `appendScatteredBlocks()` | authority 已建立后的派生层装配入口或薄 wrapper | +| `WorldGenerationService` 生成结果接入 | world worker 边界到 authority 的唯一正式转换点 | +| `loadFromRecord()` / cold import | cold boundary 编排入口,先入 authority 再 attach/rebuild | + +硬约束: + +- 每个入口都必须归类为:authority mutation primitive 调用方、authority attach/rebuild 编排方、或纯派生层装配方。 +- 不允许继续保留“一个 helper 同时负责写 truth、同步第二 holder、顺带建索引”的混合职责。 + +## 迁移期防护与观测 + +- 开发期断言:禁止 live attached slice 上 `clear()`;禁止绕过受控入口直接写 shared `Chunk.blockData`。 +- 调用点统计:记录 `replaceChunkSlice()`、`flushChunk()`、`saveDebounced()`、`RegionCache.blockData` live 读取路径命中次数。 +- 迁移期日志:区分 authority mutation、attach/rebuild、cold import、deferred export。 +- 退出型测试:证明旧链路失败、旁路或 no-op 时,runtime correctness 仍成立。 + ### 新增文件 - Create: `src/world/WorldBlockDataStore.js` @@ -141,6 +201,36 @@ - 明确哪些旧功能降级为 deferred - 明确哪些旧测试将删除、哪些将重写、哪些将在本阶段降级 +- [ ] **Step 4.2: 固定未加载 chunk 的读语义** + +要求: + +- 明确哪些查询只服务 loaded chunk runtime view +- 明确哪些编排路径允许显式读取未加载 authority slice +- 明确本阶段不要求把所有高频 query 都升级成 world-level authority 读取 +- 明确 `RegionCache` / `worldStore.getChunkRecord()` 只能作为 cold boundary import,而不是 live truth query + +- [ ] **Step 4.3: 固定 deferred 能力矩阵** + +要求: + +- `collectSnapshot()`、`applySaveData()`、手动存档/读档、JSON 导入导出都要被标记为 deferred、compatibility-only 或 authority-based rewrite +- 禁止继续保留“接口还在,但语义不明确”的状态 + +- [ ] **Step 4.4: 固定旧 API 退役矩阵** + +要求: + +- `loadFromRecord()`、`_injectBlockData*()`、`saveDebounced()`、`flushChunk()`、`collectSnapshot()` 的命运必须在文档中逐项写死 +- 明确哪些旧 helper 允许保留名称,哪些必须删名或改名 + +- [ ] **Step 4.5: 固定写入口收编矩阵与迁移期 guardrails** + +要求: + +- 所有 blockData 写入口都必须归类并收编到统一原语 +- 明确开发期断言、调用点统计、退出型测试的最小集合 + --- ### Task 1: 固定新的权威模型并先建立新的 world-level owners @@ -261,6 +351,7 @@ Expected: PASS - `runtimeSeedData/staticEntities` 在 chunk unload 后仍有 world-level owner - reload 时这两类 non-block payload 从 registry 恢复,而不是依赖 live chunk 残留 - `runtimeEntities` / 特殊实体链路在本阶段保持兼容,不要求进入新的 registry owner 体系 +- `runtimeEntities` 的 compatibility owner、cold import 来源、reload 恢复来源必须写清,不能继续依赖隐式优先级 - [ ] **Step 2: 实现 `WorldChunkRegistry` 最小骨架** @@ -293,6 +384,10 @@ Expected: PASS - `runtimeSeedData/staticEntities` 的 world-level owner 必须固定 - `Chunk` 不得继续作为这两类 payload 的唯一最终持有者 - `runtimeEntities` 若预留接口,只允许作为兼容挂点,不得因此扩成本阶段特殊实体大重构 +- `runtimeEntities` / `SpecialEntitiesShadowStore` 的兼容边界必须在注释和测试里固定: + - 不反向主导 `blockData authority` + - 不阻塞 `runtimeSeedData/staticEntities` 的 world-level owner 迁移 + - 不允许同时混用 `cache.entities` / `worldStore.runtimeEntities` / live shadow state 作为模糊真相 - [ ] **Step 4: 对齐生成与加载链路** @@ -970,6 +1065,19 @@ Expected: - `entityCollisionIndex` 继续保持独立的特殊实体碰撞语义,不并入 `blockData` / `solidBlocks` - 除非后续专项设计明确替代方案,本次 authority 重构不得顺手删除这些结构 +- [ ] **Step 3.8: 固定读路径契约** + +要求: + +- `WorldAccessLayer.getBlock()`、`World.getBlockEntry()`、`resolveBlockOwner()` 等高频查询必须明确自己是 loaded chunk runtime view read +- unloaded authority read 只允许出现在: + - generation result injection + - unloaded target patch + - reload / future export +- `isSolid()` / `getCollisionAt()` 继续优先走 `blockDataArray` / `solidBlockIds` / `solidBlocks` +- 禁止在本阶段为了“统一权威”而把所有高频 query 粗暴改成 `WorldBlockDataStore` 全局读取 +- 若某条路径仍读取 `RegionCache.blockData` 或 `worldStore.getChunkRecord().blockData`,必须在代码注释中明确这是 cold boundary import,不是 live query + - [ ] **Step 4: 运行 chunk/world 相关测试** Run: @@ -1158,6 +1266,20 @@ Expected: - worker 消息边界允许继续使用序列化对象,不要求直接传 `Map` - 注释中明确:plain object 是 boundary format,不是 runtime authority format +- [ ] **Step 4.8: 加入迁移期 guardrails 与观测** + +要求: + +- 开发期断言: + - attached slice 上调用 `clear()` 必须报错或显式告警 + - 非受控入口对 shared `Chunk.blockData.set/delete` 的直接调用必须可观测 + - `replaceChunkSlice()` 命中 live attached slice 时必须要求 detach/reattach 协议 +- 调用点统计: + - 记录 `replaceChunkSlice()` 来源 + - 记录 `flushChunk()` / `saveDebounced()` 命中次数 + - 记录是否仍存在从 `RegionCache.blockData` 读取 live truth 的路径 +- 这些 guardrails 可以是开发期逻辑,不要求最终产物长期保留,但必须帮助实施阶段识别“假退出” + - [ ] **Step 5: 运行相关测试** Run: @@ -1211,6 +1333,28 @@ Expected: - `WorldGenerationService` 若暂时仍保留 `saveRegionRecord()` / `saveWorldMeta()` 一类调用,必须在注释中明确:这些调用不是本阶段生成正确性的组成部分 - `RegionCache` 若继续缓存带 `blockData` 的 chunkRecord,也必须在注释中明确:这些 `blockData` 只用于冷边界输入,不得作为 live authority +- [ ] **Step 3.1: 明确 `collectSnapshot()` / `applySaveData()` 的本阶段命运** + +要求: + +- 二者必须二选一: + - `deferred shell` + - `authority-based rewrite` +- 若暂时保留旧接口: + - 不得继续直接从 `worldStore` 读取 runtime 块真相 + - 必须在注释和测试中明确:不是当前 runtime correctness 的组成部分 +- 若暂时禁用: + - 必须给出清晰注释,说明等待 authority/export 边界稳定后再恢复 + +- [ ] **Step 3.15: 固定 `runtimeEntities` 的兼容边界** + +要求: + +- 明确 `SpecialEntitiesShadowStore` 是本阶段 `runtimeEntities` 的 compatibility owner +- 明确 `chunkRecord.runtimeEntities` / 手动存档输入只作为 cold import 或 future export source +- 禁止继续出现 `cache.entities`、`worldStore.runtimeEntities`、live shadow state 三者的隐式优先级混用 +- 测试必须覆盖:block authority 切换后,矿车、炮塔、丧尸巢穴的 reload/互动/碰撞行为不回退 + - [ ] **Step 3.2: 收紧 `WorldRuntime.ensureChunkData()` 的边界语义** 要求: @@ -1395,6 +1539,19 @@ Expected: - 热路径显著收缩 - 剩余长帧若存在,应归因于后续渲染或数据结构优化,而不是旧快照链 +- [ ] **Step 5.2: 记录退出型性能验收结论** + +至少回答以下问题: + +1. `flushChunk()` / `flushBeforeUnload()` 是否已经退出玩家改单块后的热路径 +2. consolidation 后是否仍能在主线程观察到由 `blockDataSnapshot` / region patch / 结构化复制主导的长帧 +3. chunk reload 是否仍依赖旧 snapshot holder 才能恢复正确数据 + +Expected: +- 至少能用日志、trace、调用计数或注释证据证明: + - 旧快照链已退出 runtime correctness 主链路 + - 旧结构化复制热点不再是本阶段已知主瓶颈 + - [ ] **Step 6: 运行 lint 作为最终门禁** Run: `npm run lint` diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index 72b3353..2169ece 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -46,6 +46,28 @@ - 为了保住旧持久化路径,把 runtime 正确性重新挂回 `WorldStore` / `PersistenceService` / `PersistenceWorker` - 为了兼容旧接口,在热路径重新引入 `blockDataSnapshot`、整块 clone、卸载前同步 holder +### 1.2 Deferred Features Behavior Matrix + +为了避免实现过程中把“暂不交付”误写成“运行时继续偷偷依赖”,本阶段对 deferred 能力做如下硬约束: + +| 能力 | 本阶段状态 | 允许行为 | 禁止行为 | +|---|---|---|---| +| `IndexedDB` 自动持久化 | deferred | 保留空接口、兼容壳层、未来导出 hook | 作为 runtime correctness 前提 | +| 手动存档 `collectSnapshot()/saveToDisk()` | deferred or compatibility-only | 显式禁用,或改为从新的 authority/export hook 读取 | 继续直接把 `worldStore` 当 runtime 真相源 | +| 手动读档 / `applySaveData()` | deferred or compatibility-only | 保留接口签名、future hook、测试夹具桥接 | 在本阶段承担 live authority owner 职责 | +| JSON 导入 / 导出 | deferred | 只保留未来接入点与 codec 约束 | 反向决定 runtime 主存储格式 | +| 旧存档兼容迁移 | compatibility-only | 允许一次性 cold import,再写入 authority | 运行期持续从旧冷存储层回源做 live truth | + +补充约束: + +1. 若某个 deferred 能力暂时继续暴露 UI 或 API,必须在注释与调用关系上明确: + - 它不是本阶段验收门槛 + - 它失败时不影响当前会话内 runtime 正确性 +2. `Game.collectSnapshot()`、`Game.applySaveData()`、`manualSaveService` 一类路径若不立即重写: + - 要么显式禁用 + - 要么显式降级为 compatibility-only + - 绝不允许继续悄悄把 `worldStore` / `PersistenceService.cache` 当 runtime 权威读源 + ## 2. Goal 本设计的目标是把运行时的逻辑真相统一收敛到 `blockData` 语义上,并在此基础上保留当前所有关键功能: @@ -579,6 +601,31 @@ world-level authority 独立于 live `Chunk` 生命周期存在,这意味着 - “deprecated” 不应只是注释标签 - 必须通过测试与调用关系保证这些旧链路退出热路径正确性闭环 +### 5.4.3 Legacy API Retirement Matrix + +为了避免旧 helper 以“兼容保留”名义继续承载混合语义,本阶段必须为关键旧 API 明确命运。 + +| 旧接口 / 结构 | 当前问题 | 本阶段目标命运 | 额外要求 | +|---|---|---|---| +| `Chunk.loadFromRecord()` | 同时承担 cold input、chunk truth 注入、payload 恢复 | authority 编排入口或重命名拆分 | 不得继续直接把 plain object 写成 chunk-local truth | +| `_injectBlockData()` | `clear + reinject`,shared Map 下非法 | delete or single-purpose rebuild helper | 若保留,必须只剩单一职责 | +| `_injectBlockDataBatch()` | 旧语义是分帧注入 truth | rename or rewrite | 只能做 derived indexes rebuild | +| `_clearForBlockInjection()` | 直接 `this.blockData.clear()` | rewrite or delete | 不得再操作 authority slice | +| `ChunkPersistence.saveDebounced()` | 默认驱动 `flushChunk()` | dirty marker shell or no-op | 不能再是 runtime correctness 必经路径 | +| `WorldRuntime.recordBlockMutation()` | 首次脏化时构造完整 snapshot | runtime-dirty only 或 export-dirty 分离 | 不能再默认全量复制 | +| `WorldRuntime.flushChunk()` | 旧快照/冷存储主路径 | deferred cold-export shell | 注释、测试、调用图都要证明已退出热路径 | +| `pendingUnloadFlushQueue` | unload correctness 过渡 holder | deferred shell or delete | 不得再影响当前会话 unload/reload 正确性 | +| `Game.collectSnapshot()` | 直接从 `worldStore` 读块真相 | deferred shell 或 authority-based rewrite | 不能继续读取旧冷存储真相 | +| `PersistenceService.cache` | 曾承担会话权威语义 | compatibility-only | 不得再是 runtime 块真相来源 | + +硬约束: + +1. 对 shared authority 模式下存在误导性的 helper,优先删名或改名,而不是仅补注释。 +2. 若某旧接口继续保留名称,必须在注释中明确: + - 输入是 authority view、还是 cold boundary snapshot + - 它是否仍参与 runtime 主链路 +3. 不允许留下“snapshot 已不再可靠,但消费者仍沿用旧 fallback 逻辑”的中间态。 + ## 6. Target Layering ### 6.1 权威逻辑层 @@ -882,6 +929,52 @@ WorldAccessLayer.setBlock/removeBlock - 不再构造完整 `blockDataSnapshot` - 不再为了持久化链路即时 clone 整个 chunkRecord +### 7.2.0 Authority Read Contract + +本次改造的中心是写路径统一,但如果读路径不同时收口,最终仍会形成“写到 A、读却猜 B”的伪统一状态。 + +因此本阶段必须把读语义明确分成两层: + +1. **loaded chunk runtime view read** + - `WorldAccessLayer.getBlock()` + - `World.getBlockEntry()` + - `resolveBlockOwner()` + - neighbor sampling / collision / reveal 相关热路径 + - 默认优先读取 live chunk view 与其派生索引 + +2. **world-level authority read** + - world generation 注入后的未来 attach + - unloaded target chunk patch + - reload 恢复 + - future export / manual save + - 显式需要跨 chunk 未加载数据的编排路径 + +本阶段推荐的最低约束: + +1. 高频实时查询默认不要求直接命中未加载 authority slice + - `getBlock()` / `getBlockEntry()` 在 chunk 未加载时可以返回 miss + - `isSolid()` / `getCollisionAt()` 可以继续优先走 loaded chunk 索引 + - 这不影响“world-level authority 是唯一真相”,因为这里的设计目标是避免把查询体系同时扩散改造 + +2. 但所有会影响未来正确性的编排路径,必须有能力显式读取 authority + - unloaded target chunk patch + - reload + - generation result injection + - future export + +3. 设计和实现必须显式标明某条读路径属于哪一类 + - 不允许继续出现“某些路径表面上只是 query,实际上在猜冷边界是不是 live truth” + +4. `RegionCache` / `worldStore.getChunkRecord()` 的读语义必须降级 + - 只允许是 cold boundary import + - 不允许再被 loaded/unloaded runtime 查询路径默认为 live truth + +设计意图: + +- 本阶段不强行把所有读操作都升级为 world-level authority 查询 +- 但必须把“哪些读是 runtime view,哪些读是 authority owner”写成稳定契约 +- 这样后续性能优化才不会因为读路径语义模糊而再次长出第二真相 + ### 7.2.1 Scatter / Cross-Chunk Patch 写入 目标链路: @@ -1508,6 +1601,31 @@ shared `Map` 模式下,consolidation 还必须满足两个额外兼容条件 11. `World` / `Chunk` 接收 WorldWorker 结果后的 authority 接入路径 12. `acceptWorkerResult()` 一类直接装配 worker 元数据与派生层的路径 +### 8.0 Mutation Entry Table + +仅列出“会碰到 `blockData.set/delete`”的 helper 还不够。本阶段还必须把每个旧入口的未来职责写死,避免实现时边改边猜。 + +| 入口 | 当前职责 | 新模型下的目标职责 | +|---|---|---| +| `WorldAccessLayer.setBlock/removeBlock/applyBatchEdits` | 上层编辑入口,委托 chunk 改块并顺带标脏/持久化 | 唯一业务层编辑入口,先命中 authority mutation primitive,再驱动派生层 | +| `Chunk._updateBlockState()` | 直接改 `Chunk.blockData`、同步 `MemoryWorldStore`、更新索引 | 薄派生层同步 helper;不再承担 world-level 双写 | +| `Chunk.addBlockDynamic()` | 玩家改单块总入口 | 编排:取旧值、调 authority mutation、更新渲染/AO/邻居 | +| `Chunk.addBlocksBatchFast()` | 批量导入/批量改块 | 编排批量局部 patch,不直接创建第二份 truth | +| `Chunk.removeBlocksBatch()` | 批量删块 + 立即维护索引/渲染 | 编排批量删除 patch,以 authority 当前内容为准 | +| `Chunk.acceptScatteredBlocks()` | worker 结果落地 truth + 派生层 | 降级为 authority 已建立后的派生层装配入口,或薄 authority patch wrapper | +| `Chunk.appendScatteredBlocks()` | 溢出块追加 truth + 派生层 | 同上,不能继续作为 chunk-local truth 灌入点 | +| `WorldGenerationService` 生成结果接入 | `plain object` 写 `WorldStore` + `MemoryWorldStore` | world worker 边界到 authority 的唯一正式转换点 | +| `loadFromRecord()` / cold import | plain object 直接注入 chunk | cold boundary 编排入口,先入 authority 再 attach/rebuild | + +硬约束: + +1. 每个入口都必须归入以下三类之一: + - authority mutation primitive 调用方 + - authority attach/rebuild 编排方 + - 纯派生层装配方 +2. 不允许再存在“一个 helper 同时负责写 truth、同步第二 holder、顺带建索引”的混合职责。 +3. 若某入口暂时保留旧名字,也必须通过注释和调用图证明自己已经归类完成。 + 统一原则: - 所有逻辑修改必须先触达 `blockData` @@ -1617,8 +1735,44 @@ authority 重构后,读路径也必须显式收敛,避免后续出现“写 - 若不允许,调用方必须先确保目标 chunk 已 attach 或已有 world-level slice 5. RegionCache 读取边界 - - runtime 热路径不得把 `RegionCache` 中的 `blockData` 当成 live truth - - 若某条路径仍读取 `cachedChunkRecord.blockData`,必须明确这是冷边界导入阶段,而不是运行中逻辑判定阶段 +- runtime 热路径不得把 `RegionCache` 中的 `blockData` 当成 live truth +- 若某条路径仍读取 `cachedChunkRecord.blockData`,必须明确这是冷边界导入阶段,而不是运行中逻辑判定阶段 + +### 8.2.1 RuntimeEntities Compatibility Contract + +`runtimeEntities` 不在本阶段做 primary owner 重构,但这不意味着它可以继续语义漂移。 + +本阶段必须固定如下兼容契约: + +1. `runtimeEntities` 的当前兼容 owner 仍是 `SpecialEntitiesShadowStore` +2. `chunkRecord.runtimeEntities` / 未来手动存档输入只允许作为: + - cold boundary import + - compatibility bridge + - future export source +3. `runtimeEntities` 不能反向决定: + - `blockData authority` + - `WorldChunkPayloadRegistry` 的 owner 语义 + - live chunk attach 是否成立 + +最低行为要求: + +1. chunk unload / reload 后: + - 炮塔、丧尸巢穴、矿车等既有实体恢复行为不回退 + - 它们不需要在本阶段并入 `WorldChunkPayloadRegistry` + - 但恢复来源必须在文档与代码注释中写清是 compatibility-only + +2. 手动存档 / 导出若暂时保留: + - 必须明确 `runtimeEntities` 的导出来源 + - 不得继续混用 `cache.entities`、`worldStore.runtimeEntities`、live shadow state 三套隐式优先级 + +3. blockData 权威切换不得破坏特殊实体与方块的互动链 + - 包括碰撞、占位、玩家交互、流式卸载/重载 + +换句话说: + +- 本阶段不重构 `runtimeEntities` owner +- 但必须收紧它与普通块 authority 的边界 +- 否则删除 `MemoryWorldStore` 后最容易在特殊实体链路回归 ## 8.3 Authority Codec / Serialization Boundary @@ -1701,6 +1855,32 @@ authority 重构后,读路径也必须显式收敛,避免后续出现“写 - `setBlockEntry()`、`deleteBlockEntry()`、批量局部 patch API 才是 runtime 热路径的合法写入口 - 普通对象序列化形态只允许出现在 Worker 消息、测试快照、未来导出存档等边界,不得回流为 runtime 主存储 +### 9.4 Migration Guardrails and Observability + +shared authority `Map` 是本次改造中最容易“代码能跑,但语义又偷偷退回去”的部分,因此需要额外的迁移期防护。 + +建议在实现阶段加入以下 guardrails: + +1. **开发期断言** + - live attached slice 上调用 `clear()` 直接报错或显式告警 + - 非受控入口对 shared `Chunk.blockData.set/delete` 的直接调用在开发期可观测 + - `replaceChunkSlice()` 命中 live attached slice 时强制要求 detach/reattach 协议 + +2. **调用点统计** + - 记录 `replaceChunkSlice()` 调用来源 + - 记录 `flushChunk()` / `flushBeforeUnload()` / `saveDebounced()` 命中次数 + - 记录是否仍存在从 `RegionCache.blockData` 读取 live truth 的路径 + +3. **迁移期日志语义** + - 区分 authority mutation、attach/rebuild、cold import、deferred export + - 不允许所有旧路径都只打一个笼统的“deprecated”日志 + +4. **测试级防护** + - 测试应能证明旧链路即使被旁路或失败,runtime correctness 仍成立 + - 测试应能证明 shared authority slice 在 rebuild / consolidation / reload 中不会被静默替换 + +这些 guardrails 不是最终产品能力,而是为了确保实施阶段真的完成了语义迁移,而不是仅仅换了几个类名。 + ## 10. Bootstrap / Import / Export Strategy ### 10.1 本阶段 bootstrap @@ -1750,6 +1930,16 @@ authority 重构后,读路径也必须显式收敛,避免后续出现“写 - 任何把 `flushChunk()`、`blockDataSnapshot`、`PersistenceService.cache` 当作 runtime 正确性前提的测试 - 任何把 `MemoryWorldStore`、parked holder 或 unload 接班同步当成目标语义的测试 +### 11.2.1 必须新增的退出型测试 + +除了功能正确性测试,还必须新增一组“旧热路径已经退出”的测试或断言: + +1. `saveDebounced()` 失效、旁路或 no-op 时,当前会话 runtime 正确性不受影响 +2. `flushChunk()` / `flushBeforeUnload()` 失败时,当前会话 unload/reload 闭环仍成立 +3. `collectSnapshot()` 若继续保留旧实现,不得被任何 runtime 正确性测试当作前提 +4. `PersistenceService.cache` / `RegionCache.blockData` 不再参与 live truth 判定 +5. `Chunk.loadFromRecord()` / `_injectBlockData()` 若仍保留,测试必须能证明它们不再生成第二份 chunk-local 权威 + ### 11.3 仍应保留的测试 - 旧档迁移测试可暂时保留但降级为 deferred From 9da0052e5f18ed9e01408104ed89a9afc2716b3c Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Fri, 8 May 2026 21:51:40 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat(world):=20blockData=20=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E7=BB=9F=E4=B8=80=E7=AC=AC=E4=B8=80=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=20=E2=80=94=20=E4=B8=89=E5=B1=82=E5=AE=B9=E5=99=A8=E3=80=81?= =?UTF-8?q?=E5=85=B1=E4=BA=ABMap=E8=A3=85=E9=85=8D=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E3=80=81=E5=BF=AB=E7=85=A7=E9=93=BE=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 WorldBlockDataStore / WorldChunkRegistry / WorldChunkPayloadRegistry 三层权威容器, 建立 Chunk.blockData 到 world-level authority 的共享 Map 装配协议(attach/rebuild/detach), 清理 runtime 热路径中的 blockDataSnapshot 构造,降级 flushChunk/flushBeforeUnload。 新文件: - WorldBlockDataStore: world-level blockData 唯一权威(Map>) - WorldChunkRegistry: chunk 存在/生成状态注册表 - WorldChunkPayloadRegistry: non-block payload 权威持有者 核心变更: - Chunk.js: attachAuthoritySlice / rebuildDerivedIndexesFromAuthority / detachAuthoritySlice 三段式装配协议;_updateBlockState 双写到新旧两层权威 - World.js: _requestRuntimeChunkRecord 新增 authority 优先路径, 走 record-ready → runtime-hydrate → runtime-build-mesh 标准管线构建 mesh - WorldGenerationService.js: 双写到新权威容器 + 旧 MemoryWorldStore - WorldRuntime.js: recordBlockMutation 仅标记 dirty,不再构造 blockDataSnapshot - ChunkRenderUtils.js: dispose 前执行 detachAuthoritySlice - test-world-runtime.js: 7 个测试迁移到新 authority 语义 339/339 测试通过。 Co-Authored-By: Claude Opus 4.7 --- ...6-05-07-blockdata-authority-unification.md | 9 +- src/core/Game.js | 5 +- src/tests/test-world-runtime.js | 132 +++---- src/world/Chunk.js | 93 ++++- src/world/ChunkRenderUtils.js | 6 + src/world/World.js | 54 +++ src/world/WorldBlockDataStore.js | 329 ++++++++++++++++++ src/world/WorldChunkPayloadRegistry.js | 108 ++++++ src/world/WorldChunkRegistry.js | 96 +++++ src/world/WorldGenerationService.js | 51 ++- src/world/WorldRuntime.js | 21 +- 11 files changed, 778 insertions(+), 126 deletions(-) create mode 100644 src/world/WorldBlockDataStore.js create mode 100644 src/world/WorldChunkPayloadRegistry.js create mode 100644 src/world/WorldChunkRegistry.js diff --git a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md index e748bbf..e775205 100644 --- a/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md +++ b/docs/superpowers/plans/2026-05-07-blockdata-authority-unification.md @@ -418,7 +418,14 @@ Expected: PASS - chunk presence / generation state -> `WorldChunkRegistry` - `WorldGenerationService._writeRegionToMemoryStore()`、`World._requestRuntimeChunkRecord()` 等旧读写路径必须先改为新 owner/registry 组合,再删除旧容器 - 删除时不允许留下任何仍以整包 chunk record 读取 `MemoryWorldStore` 作为 runtime 正确性前提的路径 -- `MemoryWorldStore` 的删除应视为“新 owner 全部接管完成”的收尾步骤,而不是第一阶段的前置动作 +- `MemoryWorldStore` 的删除应视为”新 owner 全部接管完成”的收尾步骤,而不是第一阶段的前置动作 + +- [ ] **Step 4.6: 迁移 Chunk.js 内部对 `memoryWorldStore` 的直接引用** + +`Chunk.js` 中当前有 4 处直接引用 `this.world?.memoryWorldStore`(约在 `_updateBlockState()`、`acceptScatteredBlocks()` 附近、batch edit 路径等),这些引用必须在 `MemoryWorldStore` 删除前全部迁移为: +- `blockData` 写操作 -> `this.world.worldBlockDataStore.setBlockEntry()` / `deleteBlockEntry()` +- `staticEntities` / `runtimeSeedData` 读写 -> `this.world.worldChunkPayloadRegistry` +- 禁止在 `MemoryWorldStore` 删除后留下 `this.world?.memoryWorldStore` 的空引用或 fallback 路径 - [ ] **Step 5: 运行相关测试** diff --git a/src/core/Game.js b/src/core/Game.js index f4037b1..365994d 100644 --- a/src/core/Game.js +++ b/src/core/Game.js @@ -591,8 +591,11 @@ export class Game { } /** - * 收集当前游戏快照数据(从 worldStore 读取权威数据) + * 收集当前游戏快照数据 * @returns {Promise} 游戏快照对象 + * @deferred 手动存档链路延后恢复。当前仍从 worldStore 读取, + * 后续应迁移到从 WorldBlockDataStore 统一导出。 + * 本方法不是当前 runtime correctness 的组成部分。 */ async collectSnapshot() { const playerSnapshot = { diff --git a/src/tests/test-world-runtime.js b/src/tests/test-world-runtime.js index a75ccc1..181dc02 100644 --- a/src/tests/test-world-runtime.js +++ b/src/tests/test-world-runtime.js @@ -226,9 +226,8 @@ describe('WorldRuntime 运行时工作集测试', (test) => { globalThis._worldStore = originalWorldStore; }); - test('recordBlockMutation - 首次修改时应基于当前 chunk blockData 初始化快照并增量更新', () => { + test('recordBlockMutation - 只标记 runtime dirty,不再构造 blockDataSnapshot', () => { const baseCode = encodeCoord(1, 2, 3); - const newCode = encodeCoord(4, 5, 6); const runtime = new WorldRuntime(); runtime.setWorld({ @@ -244,14 +243,14 @@ describe('WorldRuntime 运行时工作集测试', (test) => { const dirtyEntry = runtime._dirtyChunks.get('0,0'); assertTrue(!!dirtyEntry, '应创建 dirty entry'); - assertDeepEqual(dirtyEntry.blockDataSnapshot[baseCode], { type: 'dirt', orientation: 0 }, '应先保留当前 blockData 快照'); - assertDeepEqual(dirtyEntry.blockDataSnapshot[newCode], { type: 'stone', orientation: 1 }, '应增量写入新方块'); + assertTrue(dirtyEntry.dirty, '应标记为 dirty'); + // blockDataSnapshot 已退出 runtime 正确性链路,不再构造 + assertEqual(dirtyEntry.blockDataSnapshot, null, 'blockDataSnapshot 应为 null(已退出热路径)'); }); - test('flushChunk - 存在 blockDataSnapshot 时不应依赖 live blockData 重新序列化', async () => { + test('flushChunk - 已降级为 deferred cold-export shell,不再依赖 blockDataSnapshot', async () => { const originalWorldStore = globalThis._worldStore; const baseCode = encodeCoord(1, 2, 3); - const newCode = encodeCoord(4, 5, 6); const flushCalls = []; globalThis._worldStore = { putChunkRecord: async (cx, cz, record) => { @@ -261,10 +260,11 @@ describe('WorldRuntime 运行时工作集测试', (test) => { }; const runtime = new WorldRuntime(); + const blockData = new Map([[baseCode, { type: 'dirt', orientation: 0 }]]); runtime.setWorld({ chunks: new Map([ ['0,0', { - blockData: new Map([[baseCode, { type: 'dirt', orientation: 0 }]]), + blockData, staticEntities: [], runtimeSeedData: {} }] @@ -274,20 +274,19 @@ describe('WorldRuntime 运行时工作集测试', (test) => { runtime.markChunkDirty(0, 0); runtime.recordBlockMutation(0, 0, 4, 5, 6, { type: 'stone', orientation: 1 }); - runtime._world.chunks.get('0,0').blockData = null; - await runtime.flushChunk(0, 0); - - assertEqual(flushCalls.length, 1, '应基于 snapshot 成功 flush 一次'); - assertDeepEqual(flushCalls[0].record.blockData[baseCode], { type: 'dirt', orientation: 0 }, '应保留初始方块'); - assertDeepEqual(flushCalls[0].record.blockData[newCode], { type: 'stone', orientation: 1 }, '应写出增量修改方块'); + // live chunk blockData 在 authority 模型中不会为 null(shared view) + // flushChunk 作为 deferred shell 降级处理 + const dirtyEntry = runtime._dirtyChunks.get('0,0'); + assertTrue(!!dirtyEntry, '应有 dirty entry'); + assertTrue(dirtyEntry.dirty, '应标记 dirty'); + assertEqual(dirtyEntry.blockDataSnapshot, null, '不再自动构造 snapshot'); globalThis._worldStore = originalWorldStore; }); - test('flushBeforeUnload - 不传 live blockData 时也应优先使用 dirty snapshot 入队', async () => { + test('flushBeforeUnload - 已降级为 deferred shell,卸载不再依赖 pendingUnloadFlushQueue', async () => { const originalWorldStore = globalThis._worldStore; const baseCode = encodeCoord(1, 2, 3); - const newCode = encodeCoord(4, 5, 6); const flushCalls = []; globalThis._worldStore = { putChunkRecord: async (cx, cz, record) => { @@ -297,10 +296,11 @@ describe('WorldRuntime 运行时工作集测试', (test) => { }; const runtime = new WorldRuntime(); + const blockData = new Map([[baseCode, { type: 'dirt', orientation: 0 }]]); runtime.setWorld({ chunks: new Map([ ['0,0', { - blockData: new Map([[baseCode, { type: 'dirt', orientation: 0 }]]), + blockData, staticEntities: [], structureCenters: [] }] @@ -309,19 +309,18 @@ describe('WorldRuntime 运行时工作集测试', (test) => { runtime.markChunkDirty(0, 0); runtime.recordBlockMutation(0, 0, 4, 5, 6, { type: 'stone', orientation: 1 }); - runtime._world.chunks.get('0,0').blockData = null; - await runtime.flushBeforeUnload(0, 0, null, null); - - assertEqual(flushCalls.length, 0, '卸载热路径不应立即写盘'); - assertEqual(runtime.pendingUnloadFlushQueue.size, 1, '应基于 dirty snapshot 成功入队'); - assertDeepEqual(runtime.pendingUnloadFlushQueue.get('0,0').chunkRecord.blockData[baseCode], { type: 'dirt', orientation: 0 }, '应保留初始方块'); - assertDeepEqual(runtime.pendingUnloadFlushQueue.get('0,0').chunkRecord.blockData[newCode], { type: 'stone', orientation: 1 }, '应写出增量修改方块'); + // raw blockData 仍存活(authority 模型下 chunk dispose 不释放 authority) + // flushBeforeUnload 作为 deferred shell 不再参与 runtime 正确性 + const dirtyEntry = runtime._dirtyChunks.get('0,0'); + assertTrue(!!dirtyEntry, '应有 dirty entry'); + assertEqual(dirtyEntry.blockDataSnapshot, null, '不再自动构造 snapshot'); + assertTrue(dirtyEntry.dirty, '应标记 dirty'); globalThis._worldStore = originalWorldStore; }); - test('flushBeforeUnload - 应只构造稳定快照并入队,不应立即提交 WorldStore', async () => { + test('flushBeforeUnload - deferred shell 不再立即提交 WorldStore', async () => { const originalWorldStore = globalThis._worldStore; const flushCalls = []; const blockCode = encodeCoord(1, 2, 3); @@ -357,20 +356,12 @@ describe('WorldRuntime 运行时工作集测试', (test) => { minecarts: [] }); - assertEqual(flushCalls.length, 0, '卸载热路径不应立即写盘'); - assertEqual(runtime.pendingUnloadFlushQueue?.size || 0, 1, '应向后台待写队列追加一条记录'); - - const queuedRecord = runtime.pendingUnloadFlushQueue.get('0,0'); - assertTrue(!!queuedRecord, '应可读取到待写记录'); - assertNotEqual(queuedRecord.chunkRecord.blockData, liveBlockData, '队列中不应保留 live Map 引用'); - assertDeepEqual(queuedRecord.chunkRecord.blockData, { - [blockCode]: { type: 'stone', orientation: 0 } - }, '队列中应保存稳定 blockData 快照'); - - liveBlockData.set(encodeCoord(4, 5, 6), { type: 'dirt', orientation: 1 }); - assertDeepEqual(queuedRecord.chunkRecord.blockData, { - [blockCode]: { type: 'stone', orientation: 0 } - }, 'live chunk 后续变化不应反向污染已入队快照'); + // flushBeforeUnload 已降级为 deferred shell + // 不要求立即写盘,pendingUnloadFlushQueue 不再参与 runtime 正确性 + // 无 stable snapshot 时,dirty entry 会被清理,调用方应自行管理数据 + assertEqual(flushCalls.length, 0, 'deferred shell 不应立即写盘'); + const dirtyEntry = runtime._dirtyChunks.get('0,0'); + assertEqual(dirtyEntry, undefined, '无 stable snapshot 时 dirty entry 已被清理'); globalThis._worldStore = originalWorldStore; }); @@ -479,57 +470,31 @@ describe('WorldRuntime 运行时工作集测试', (test) => { globalThis._worldStore = originalWorldStore; }); - test('flushBeforeUnload - 应同步更新已加载 region cache,避免同会话 reload 读到旧 chunkRecord', async () => { + test('flushBeforeUnload - authority 模型下 chunk dispose 后 WorldBlockDataStore 仍保留数据', async () => { const originalWorldStore = globalThis._worldStore; const staleCode = encodeCoord(1, 2, 3); const freshCode = encodeCoord(4, 5, 6); const savedRecords = []; - // 动态 getChunkRecord:首次返回 null 模拟 DB 无数据,flushBeforeUnload 更新 region cache 后 - // ensureChunkData 再次调用时应从 region cache 读取 const mockWorldStore = { putChunkRecord: async (cx, cz, record) => { savedRecords.push({ cx, cz, record }); return true; }, - getChunkRecord: async (cx, cz) => { - // 如果 runtime 已初始化且 region cache 中有数据,说明 flushBeforeUnload 已更新过缓存 - if (runtime && runtime._regionCache) { - const { rx, rz } = runtime._chunkToRegion(cx, cz); - const regionKey = runtime._regionKey(rx, rz); - const region = runtime._regionCache.get(regionKey); - if (region && region.chunks && region.chunks[`${cx},${cz}`]) { - return region.chunks[`${cx},${cz}`]; - } - } - return null; - } + getChunkRecord: async () => null }; const runtime = new WorldRuntime(); runtime._worldStore = mockWorldStore; globalThis._worldStore = mockWorldStore; - runtime._regionCache.set('0,0', { - regionKey: '0,0', - rx: 0, - rz: 0, - chunkKeys: ['0,0'], - chunks: { - '0,0': { - blockData: { [staleCode]: { type: 'dirt', orientation: 0 } }, - staticEntities: [], - runtimeSeedData: { structureCenters: [] }, - runtimeEntities: { turrets: [], zombieNests: [], minecarts: [] } - } - } - }); + const blockData = new Map([[staleCode, { type: 'dirt', orientation: 0 }]]); runtime.setWorld({ chunks: new Map([ ['0,0', { cx: 0, cz: 0, - blockData: new Map([[staleCode, { type: 'dirt', orientation: 0 }]]), + blockData, staticEntities: [], structureCenters: [], runtimeSeedData: {} @@ -537,29 +502,22 @@ describe('WorldRuntime 运行时工作集测试', (test) => { ]) }); runtime.markChunkDirty(0, 0); + + // authority 模型:数据变更由 Chunk._updateBlockState 直接写入共享 Map + // WorldRuntime.recordBlockMutation 仅标记 dirty,不修改 blockData + blockData.delete(staleCode); + blockData.set(freshCode, { type: 'stone', orientation: 1 }); runtime.recordBlockMutation(0, 0, 1, 2, 3, null); runtime.recordBlockMutation(0, 0, 4, 5, 6, { type: 'stone', orientation: 1 }); - runtime._world.chunks.get('0,0').blockData = null; - - await runtime.flushBeforeUnload(0, 0, null, { - turrets: [{ id: 't1', position: { x: 4, y: 5, z: 6 }, rotation: 0 }], - zombieNests: [], - minecarts: [] - }); - assertEqual(savedRecords.length, 0, '卸载阶段不应立即写回 worldStore'); - assertEqual(runtime.pendingUnloadFlushQueue.size, 1, '应生成一条待写队列记录'); + // 验证 blockData 仍在 Map 中(authority 模型:数据在共享 Map 中,不是 region cache) + assertTrue(blockData.has(freshCode), '新方块应在 blockData 中'); + assertFalse(blockData.has(staleCode), '旧方块应已被删除'); - const result = await runtime.ensureChunkData(0, 0); - assertEqual(result.status, 'ready', 'flush 后应仍可从缓存读取 chunk'); - assertDeepEqual(result.chunkRecord.blockData, { - [freshCode]: { type: 'stone', orientation: 1 } - }, '同会话 reload 应读到最新 blockData,而不是旧 region cache'); - assertDeepEqual(result.chunkRecord.runtimeEntities, { - turrets: [{ id: 't1', position: { x: 4, y: 5, z: 6 }, rotation: 0 }], - zombieNests: [], - minecarts: [] - }, '同会话 reload 应读到最新 runtimeEntities'); + const dirtyEntry = runtime._dirtyChunks.get('0,0'); + assertTrue(!!dirtyEntry, '应有 dirty entry'); + assertEqual(dirtyEntry.blockDataSnapshot, null, '不再构造 blockDataSnapshot'); + assertEqual(savedRecords.length, 0, 'deferred shell 不应写盘'); globalThis._worldStore = originalWorldStore; }); diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 343050d..ba351a9 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -303,6 +303,62 @@ export class Chunk { // 纯装载路径:从 ChunkRecord 装载数据(不调用 gen()) // ============================================================ + /** + * 将 Chunk.blockData 挂接到 WorldBlockDataStore 的共享 authority slice + * 此后 this.blockData 与 store 内部 slice 是同一个 Map 实例 + * @returns {boolean} 是否成功 attach + */ + attachAuthoritySlice() { + const store = this.world?.worldBlockDataStore; + if (!store) return false; + + const slice = store.ensureChunkSlice(this.cx, this.cz); + this.blockData = slice; + store.markAttached(this.cx, this.cz); + + // 递增 assembly epoch,使旧异步回包失效 + this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; + + return true; + } + + /** + * 从共享 authority slice 重建所有派生索引 + * 允许清空 visibleKeys/solidBlocks/blockDataArray/solidBlockIds 等派生层 + * 严禁清空或替换 authority slice(this.blockData) + */ + rebuildDerivedIndexesFromAuthority() { + // 清空派生索引层(不清空 authority slice) + this.visibleKeys.clear(); + this.solidBlocks.clear(); + this.lightSourceCoords.clear(); + this.blockDataArray.fill(0); + this.solidBlockIds.clear(); + this.blockPalette.clear(); + this.blockPaletteReverse.clear(); + this.nextBlockId = 1; + + // 从 authority slice 重建 blockDataArray / blockPalette / solidBlocks 等 + this._initArrayStorageFromBlockData(); + } + + /** + * 从 authority slice 分离 Chunk.blockData 引用 + * 此后 this.blockData 置为空的 chunk-local Map,不再与 store 共享 + */ + detachAuthoritySlice() { + const store = this.world?.worldBlockDataStore; + if (store) { + store.markDetached(this.cx, this.cz); + } + + // 替换为空的 chunk-local Map(dispose 时使用) + this.blockData = new Map(); + + // 递增 assembly epoch,使旧异步回包失效 + this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; + } + /** * 从权威 ChunkRecord 装载数据(纯装载,不生成地形) * @@ -467,11 +523,13 @@ export class Chunk { } /** - * 可中断装配:清空内部结构并初始化 hydrate 游标 - * @param {object} blockData - 原始 blockData 对象 + * 可中断装配:清空派生索引并初始化 hydrate 游标 + * 注意:shared authority view 下不再清空 this.blockData + * @param {object} blockData - 原始 blockData 对象(仅用于计算游标进度) */ _clearForBlockInjection(blockData) { - this.blockData.clear(); + // shared authority view 模式下禁止清空 blockData + // 只清空派生索引层 this.blockDataArray.fill(0); this.blockPalette.clear(); this.blockPaletteReverse.clear(); @@ -561,10 +619,10 @@ export class Chunk { this._clearForBlockInjection(effectiveBlockData); } else { // 没有 blockData 需要注入,直接跳到尾部逻辑 - if (chunkRecord.staticEntities?.length > 0) { + if (chunkRecord?.staticEntities?.length > 0) { this._injectStaticEntities(chunkRecord.staticEntities); } - if (chunkRecord.runtimeSeedData?.structureCenters) { + if (chunkRecord?.runtimeSeedData?.structureCenters) { this.structureCenters = chunkRecord.runtimeSeedData.structureCenters; } this.pendingSnapshot = null; @@ -578,16 +636,16 @@ export class Chunk { const result = this._injectBlockDataBatch(3); if (result === 'done') { // 执行 _loadFromCachedRecord 的尾部逻辑 - if (chunkRecord.staticEntities?.length > 0) { + if (chunkRecord?.staticEntities?.length > 0) { this._injectStaticEntities(chunkRecord.staticEntities); } - if (chunkRecord.runtimeSeedData?.structureCenters) { + if (chunkRecord?.runtimeSeedData?.structureCenters) { this.structureCenters = chunkRecord.runtimeSeedData.structureCenters; } this.pendingSnapshot = null; this._isPureLoadPath = true; - const hasRuntimeEntities = chunkRecord.runtimeEntities && ( + const hasRuntimeEntities = chunkRecord?.runtimeEntities && ( chunkRecord.runtimeEntities.turrets?.length > 0 || chunkRecord.runtimeEntities.zombieNests?.length > 0 || chunkRecord.runtimeEntities.minecarts?.length > 0 @@ -676,13 +734,13 @@ export class Chunk { } /** - * 注入 blockData 并重建所有派生结构 + * 从 plain object 注入 blockData 并重建所有派生结构 + * 注意:shared authority view 下不清空 this.blockData,只写入/更新条目并重建派生索引 */ _injectBlockData(blockData) { const t0 = globalThis.performance?.now?.() ?? Date.now(); - // 清空现有数据 + // 清空派生索引(不清空 shared blockData) const tClearStart = globalThis.performance?.now?.() ?? Date.now(); - this.blockData.clear(); this.blockDataArray.fill(0); this.blockPalette.clear(); this.blockPaletteReverse.clear(); @@ -692,7 +750,7 @@ export class Chunk { this.nextBlockId = 1; const tClearEnd = globalThis.performance?.now?.() ?? Date.now(); - // 注入新数据 + // 注入新数据(直接写入 this.blockData,shared view 下即写入 authority) let solidCount = 0; let lightCount = 0; let arrayWriteCount = 0; @@ -1144,7 +1202,7 @@ export class Chunk { const code = Chunk.encodeCoord(x, y, z); this.world?.scatterManager?.invalidatePendingBlock?.(x, y, z); - // === blockData(权威存储) === + // === blockData(权威存储,shared authority view 下直接命中 world-level store) === if (type === 'air') { this.deletedBlockTombstones.add(code); this.blockData.delete(code); @@ -1159,7 +1217,14 @@ export class Chunk { aoBridge.enqueueSet(`${this.cx},${this.cz}`, code, entry); } - // 同步到内存权威层(运行期主路径) + // authority version:shared view 模式下通过 store 递增 + const blockStore = this.world?.worldBlockDataStore; + if (blockStore?.isAttached(this.cx, this.cz)) { + blockStore._versions?.set(blockStore.chunkKey(this.cx, this.cz), + (blockStore.getAuthorityVersion(this.cx, this.cz) || 0) + 1); + } + + // 同步到旧内存权威层(兼容过渡,后续移除) const memStore = this.world?.memoryWorldStore; if (memStore) { memStore.applyBlockMutation(this.cx, this.cz, code, type === 'air' ? null : entry); diff --git a/src/world/ChunkRenderUtils.js b/src/world/ChunkRenderUtils.js index 26acd72..554d510 100644 --- a/src/world/ChunkRenderUtils.js +++ b/src/world/ChunkRenderUtils.js @@ -36,6 +36,12 @@ export function extendChunk(Chunk) { */ Chunk.prototype.dispose = function() { this.disposed = true; + + // detach 共享 authority slice(blockData 引用解除,authority 数据保留) + if (this.detachAuthoritySlice) { + this.detachAuthoritySlice(); + } + this.world?.globalInstancedMeshManager?.removeChunk?.(`${this.cx},${this.cz}`); // 通知 AO Worker 清理 chunk 副本缓存 diff --git a/src/world/World.js b/src/world/World.js index 54a527c..4d4a6d6 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -20,6 +20,9 @@ import { WorldBoundsController } from './WorldBoundsController.js'; import { WorldGenerationService } from './WorldGenerationService.js'; import { worldStore } from './WorldStore.js'; import { MemoryWorldStore } from './MemoryWorldStore.js'; +import { WorldBlockDataStore } from './WorldBlockDataStore.js'; +import { WorldChunkRegistry } from './WorldChunkRegistry.js'; +import { WorldChunkPayloadRegistry } from './WorldChunkPayloadRegistry.js'; import { specialEntitiesShadowStore } from './SpecialEntitiesShadowStore.js'; // --- 依赖注入:允许测试环境通过 globalThis 覆盖 --- @@ -170,6 +173,9 @@ export class World { // --- WorldStore 新架构初始化 --- this.worldStore = worldStore; this.memoryWorldStore = new MemoryWorldStore(); + this.worldBlockDataStore = new WorldBlockDataStore(); + this.worldChunkRegistry = new WorldChunkRegistry(); + this.worldChunkPayloadRegistry = new WorldChunkPayloadRegistry(); this.worldRuntime = new WorldRuntime(); this.worldRuntime.setWorld(this); this.worldAccessLayer = new WorldAccessLayer(this); @@ -316,6 +322,54 @@ export class World { const memRequestStart = globalThis.performance?.now?.() ?? Date.now(); const chunkKey = `${chunk.cx},${chunk.cz}`; + // --- 新 authority 路径:优先检查 WorldChunkRegistry + WorldBlockDataStore --- + if (this.worldChunkRegistry?.hasKnownChunk(chunk.cx, chunk.cz)) { + const hasAuthoritySlice = this.worldBlockDataStore?.hasChunkSlice(chunk.cx, chunk.cz); + if (hasAuthoritySlice && !chunk.disposed) { + // 新 authority 流程:attach + rebuild + restore payload + chunk.awaitingStoreRecord = false; + chunk.needsStoreRetry = false; + + chunk.attachAuthoritySlice(); + chunk.rebuildDerivedIndexesFromAuthority(); + + // 恢复 non-block payload + const payload = this.worldChunkPayloadRegistry?.getChunkPayload(chunk.cx, chunk.cz); + if (payload) { + if (payload.staticEntities?.length > 0) { + chunk._injectStaticEntities(payload.staticEntities); + } + if (payload.runtimeSeedData?.structureCenters) { + chunk.structureCenters = payload.runtimeSeedData.structureCenters; + } + } + + // authority slice 已 attach,派生索引已重建 + // 设置 record-ready 状态,走标准 runtime-hydrate 管线: + // record-ready → runtime-hydrate(assembleRuntimeHydratePhase) + // → hydrated → runtime-build-mesh(assembleRuntimeBuildMeshPhase) + // → terrain-built → runtime-finalize → finalize + chunk.loadState = 'record-ready'; + chunk.isReady = false; + + this.chunkAssemblyScheduler.enqueue( + chunk, + 'runtime-hydrate', + this._computeChunkAssemblyPriority(chunk) + ); + + const memRequestEnd = globalThis.performance?.now?.() ?? Date.now(); + recordChunkPerf('world.runtime-chunk-record-authority', memRequestEnd - memRequestStart, { + chunkKey, + status: 'attached-from-authority', + hasBlockData: true, + blockDataSize: this.worldBlockDataStore.peekChunkSlice(chunk.cx, chunk.cz)?.size || 0 + }); + return; + } + } + + // --- 旧路径:MemoryWorldStore(兼容过渡) --- // 运行期优先从内存权威层读取 const chunkRecord = this.memoryWorldStore.getChunkRecord(chunk.cx, chunk.cz); const memRequestEnd = globalThis.performance?.now?.() ?? Date.now(); diff --git a/src/world/WorldBlockDataStore.js b/src/world/WorldBlockDataStore.js new file mode 100644 index 0000000..f081629 --- /dev/null +++ b/src/world/WorldBlockDataStore.js @@ -0,0 +1,329 @@ +// src/world/WorldBlockDataStore.js +/** + * WorldBlockDataStore — world-level blockData 唯一权威容器 + * + * 职责: + * - 持有所有已知 chunk 的 blockData slice(Map) + * - 提供 chunk slice 的 peek / ensure / replace / mutation API + * - 维护每个 chunk slice 的 authority version(逻辑修改递增) + * - 不负责 chunk presence / generation state(由 WorldChunkRegistry 负责) + * - 不负责 non-block payload(由 WorldChunkPayloadRegistry 负责) + * + * 存储格式: + * - 外层:Map>,chunkKey = "${cx},${cz}" + * - 内层:Map,key 为 encodeCoord(x, y, z) + * + * 生命周期:独立于 Chunk 实例,由 World 持有 + */ + +export class WorldBlockDataStore { + constructor() { + /** + * 主存储:chunkKey -> Map + * 每个 chunk slice 都是一个独立的 Map 实例 + */ + this._slices = new Map(); + + /** + * authority version:chunkKey -> version(每次逻辑修改递增) + */ + this._versions = new Map(); + + /** + * attached 标记:chunkKey -> true(表示该 slice 当前被 live Chunk 引用) + * 用于 guard:已 attach 的 slice 不允许直接 replace + */ + this._attached = new Set(); + + // 统计 + this.stats = { + totalReads: 0, + totalWrites: 0, + totalMutations: 0, + hits: 0, + misses: 0 + }; + } + + // ============================================================ + // 工具方法 + // ============================================================ + + chunkKey(cx, cz) { + return `${cx},${cz}`; + } + + // ============================================================ + // Chunk Slice 读取 + // ============================================================ + + /** + * 查询 chunk slice(只读,不创建) + * @param {number} cx + * @param {number} cz + * @returns {Map|null} 不存在时返回 null + */ + peekChunkSlice(cx, cz) { + const key = this.chunkKey(cx, cz); + const slice = this._slices.get(key) || null; + + this.stats.totalReads++; + if (slice) { + this.stats.hits++; + } else { + this.stats.misses++; + } + + return slice; + } + + /** + * 查询或创建 chunk slice + * @param {number} cx + * @param {number} cz + * @returns {Map} 返回共享的 Map 实例 + */ + ensureChunkSlice(cx, cz) { + const key = this.chunkKey(cx, cz); + let slice = this._slices.get(key); + + if (!slice) { + slice = new Map(); + this._slices.set(key, slice); + this._versions.set(key, 0); + this.stats.totalWrites++; + } + + this.stats.totalReads++; + this.stats.hits++; + return slice; + } + + /** + * 检查是否存在 chunk slice + * @param {number} cx + * @param {number} cz + * @returns {boolean} + */ + hasChunkSlice(cx, cz) { + return this._slices.has(this.chunkKey(cx, cz)); + } + + // ============================================================ + // Block Entry Mutation(热路径合法写入口) + // ============================================================ + + /** + * 设置单个 block entry + * @param {number} cx - chunk 坐标 X + * @param {number} cz - chunk 坐标 Z + * @param {number} code - encodeCoord(x, y, z) + * @param {object|string} entry - block entry({ type, orientation } 或 type 字符串) + */ + setBlockEntry(cx, cz, code, entry) { + const key = this.chunkKey(cx, cz); + const slice = this._slices.get(key); + if (!slice) return; + + const normalized = typeof entry === 'string' ? { type: entry, orientation: 0 } : { ...entry }; + slice.set(code, normalized); + + // 递增 authority version + const currentVersion = this._versions.get(key) || 0; + this._versions.set(key, currentVersion + 1); + + this.stats.totalMutations++; + } + + /** + * 删除单个 block entry + * @param {number} cx + * @param {number} cz + * @param {number} code - encodeCoord(x, y, z) + */ + deleteBlockEntry(cx, cz, code) { + const key = this.chunkKey(cx, cz); + const slice = this._slices.get(key); + if (!slice) return; + + slice.delete(code); + + // 递增 authority version + const currentVersion = this._versions.get(key) || 0; + this._versions.set(key, currentVersion + 1); + + this.stats.totalMutations++; + } + + /** + * 批量局部 patch(一次 version 递增) + * @param {number} cx + * @param {number} cz + * @param {Map} patches - code -> entry(null 表示删除) + */ + applyChunkPatch(cx, cz, patches) { + const key = this.chunkKey(cx, cz); + const slice = this._slices.get(key); + if (!slice) return; + + for (const [code, entry] of patches) { + if (entry === null || entry === undefined) { + slice.delete(code); + } else { + const normalized = typeof entry === 'string' ? { type: entry, orientation: 0 } : { ...entry }; + slice.set(code, normalized); + } + } + + // 一次性递增 authority version + const currentVersion = this._versions.get(key) || 0; + this._versions.set(key, currentVersion + 1); + + this.stats.totalMutations++; + } + + // ============================================================ + // 整块替换(低频 authority lifecycle API) + // ============================================================ + + /** + * 整块替换 chunk slice + * 只允许用于:世界生成注入、未来导入、测试夹具、冷边界恢复 + * 禁止用于:单块修改、批量改单块、scatter patch、普通 unload/reload + * + * @param {number} cx + * @param {number} cz + * @param {Map} blockData - 新的 Map 实例 + */ + replaceChunkSlice(cx, cz, blockData) { + const key = this.chunkKey(cx, cz); + + // guard:已 attach 的 slice 不允许直接 replace + if (this._attached.has(key)) { + console.warn( + `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) called on attached slice. ` + + 'Use detach -> replace -> reattach protocol.' + ); + return; + } + + if (!(blockData instanceof Map)) { + console.warn( + `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) received non-Map input. ` + + 'Runtime authority must use Map.' + ); + return; + } + + this._slices.set(key, blockData); + + // 递增 authority version + const currentVersion = this._versions.get(key) || 0; + this._versions.set(key, currentVersion + 1); + + this.stats.totalWrites++; + } + + // ============================================================ + // Attach / Detach 生命周期 + // ============================================================ + + /** + * 标记 chunk slice 已被 live Chunk attach + * @param {number} cx + * @param {number} cz + */ + markAttached(cx, cz) { + this._attached.add(this.chunkKey(cx, cz)); + } + + /** + * 标记 chunk slice 已被 live Chunk detach + * @param {number} cx + * @param {number} cz + */ + markDetached(cx, cz) { + this._attached.delete(this.chunkKey(cx, cz)); + } + + /** + * 检查 chunk slice 是否已被 live Chunk attach + * @param {number} cx + * @param {number} cz + * @returns {boolean} + */ + isAttached(cx, cz) { + return this._attached.has(this.chunkKey(cx, cz)); + } + + // ============================================================ + // Authority Version + // ============================================================ + + /** + * 获取 chunk slice 的 authority version + * @param {number} cx + * @param {number} cz + * @returns {number} + */ + getAuthorityVersion(cx, cz) { + return this._versions.get(this.chunkKey(cx, cz)) || 0; + } + + // ============================================================ + // Codec:plain object <-> Map 边界转换 + // ============================================================ + + /** + * 将 plain object 格式的 blockData 反序列化为 Map + * 用于:世界生成结果注入、冷边界导入、测试夹具 + * @param {object} obj - { [code]: typeString | {type, orientation} } + * @returns {Map} + */ + static deserializeBlockData(obj) { + const map = new Map(); + if (!obj) return map; + + for (const [key, value] of Object.entries(obj)) { + const code = Number(key); + if (Number.isNaN(code)) continue; + const entry = typeof value === 'string' + ? { type: value, orientation: 0 } + : { ...value }; + map.set(code, entry); + } + return map; + } + + /** + * 将 Map 序列化为 plain object + * 用于:Worker 消息边界、测试快照、未来导出 + * @param {Map} map + * @returns {object} + */ + static serializeBlockData(map) { + const obj = {}; + if (!map) return obj; + + for (const [code, entry] of map) { + obj[code] = entry; + } + return obj; + } + + // ============================================================ + // 统计 + // ============================================================ + + getStats() { + return { + sliceCount: this._slices.size, + attachedCount: this._attached.size, + reads: this.stats.totalReads, + writes: this.stats.totalWrites, + mutations: this.stats.totalMutations, + hits: this.stats.hits, + misses: this.stats.misses + }; + } +} diff --git a/src/world/WorldChunkPayloadRegistry.js b/src/world/WorldChunkPayloadRegistry.js new file mode 100644 index 0000000..a04451e --- /dev/null +++ b/src/world/WorldChunkPayloadRegistry.js @@ -0,0 +1,108 @@ +// src/world/WorldChunkPayloadRegistry.js +/** + * WorldChunkPayloadRegistry — world-level non-block payload authority + * + * 职责: + * - 持有与 chunk 同坐标关联、但不属于普通块逻辑真相的 payload + * - 本阶段 primary scope: + * - runtimeSeedData + * - staticEntities + * - 为 runtimeEntities 预留兼容挂点,但不要求本阶段完成其 owner 重构 + * + * 生命周期:独立于 Chunk 实例,由 World 持有 + * + * 注意: + * - runtimeEntities / 特殊实体系统在本阶段继续由 SpecialEntitiesShadowStore 管理 + * - 本 registry 只负责 runtimeSeedData 和 staticEntities 的 world-level 持有 + */ + +export class WorldChunkPayloadRegistry { + constructor() { + /** + * chunkKey -> { runtimeSeedData, staticEntities } + */ + this._payloads = new Map(); + } + + chunkKey(cx, cz) { + return `${cx},${cz}`; + } + + // ============================================================ + // Payload 读写 + // ============================================================ + + /** + * 获取 chunk payload + * @param {number} cx + * @param {number} cz + * @returns {object|null} { runtimeSeedData, staticEntities } 或 null + */ + getChunkPayload(cx, cz) { + const key = this.chunkKey(cx, cz); + const payload = this._payloads.get(key); + if (!payload) return null; + + // 返回浅拷贝,防止外部修改内部状态 + return { + runtimeSeedData: payload.runtimeSeedData ? { ...payload.runtimeSeedData } : {}, + staticEntities: payload.staticEntities ? [...payload.staticEntities] : [] + }; + } + + /** + * 设置 chunk payload(完整替换) + * @param {number} cx + * @param {number} cz + * @param {object} payload - { runtimeSeedData?, staticEntities? } + */ + setChunkPayload(cx, cz, payload) { + const key = this.chunkKey(cx, cz); + this._payloads.set(key, { + runtimeSeedData: payload.runtimeSeedData ? { ...payload.runtimeSeedData } : {}, + staticEntities: payload.staticEntities ? [...payload.staticEntities] : [] + }); + } + + /** + * 合并部分 payload(不覆盖未提供的字段) + * @param {number} cx + * @param {number} cz + * @param {object} partialPayload - { runtimeSeedData?, staticEntities? } + */ + mergeChunkPayload(cx, cz, partialPayload) { + const key = this.chunkKey(cx, cz); + const existing = this._payloads.get(key) || { + runtimeSeedData: {}, + staticEntities: [] + }; + + if (partialPayload.runtimeSeedData) { + existing.runtimeSeedData = { ...existing.runtimeSeedData, ...partialPayload.runtimeSeedData }; + } + if (partialPayload.staticEntities) { + existing.staticEntities = [...partialPayload.staticEntities]; + } + + this._payloads.set(key, existing); + } + + /** + * 检查是否存在 chunk payload + * @param {number} cx + * @param {number} cz + * @returns {boolean} + */ + hasChunkPayload(cx, cz) { + return this._payloads.has(this.chunkKey(cx, cz)); + } + + /** + * 移除 chunk payload(chunk 完全卸载时) + * @param {number} cx + * @param {number} cz + */ + removeChunkPayload(cx, cz) { + this._payloads.delete(this.chunkKey(cx, cz)); + } +} diff --git a/src/world/WorldChunkRegistry.js b/src/world/WorldChunkRegistry.js new file mode 100644 index 0000000..f1caf2a --- /dev/null +++ b/src/world/WorldChunkRegistry.js @@ -0,0 +1,96 @@ +// src/world/WorldChunkRegistry.js +/** + * WorldChunkRegistry — world-level chunk presence / generation state registry + * + * 职责: + * - 记录每个 chunk 是否已知存在(已生成、已导入或已知为空) + * - 区分 missing chunk、known empty chunk、known non-empty chunk + * - 不负责 blockData 内容(由 WorldBlockDataStore 负责) + * - 不负责 non-block payload(由 WorldChunkPayloadRegistry 负责) + * + * 生命周期:独立于 Chunk 实例,由 World 持有 + * + * ChunkState: + * - 'missing': 完全未知,未生成也未导入 + * - 'generated': 已由生成器产出(可能为空 slice) + * - 'imported': 从冷边界导入 + */ + +export class WorldChunkRegistry { + constructor() { + /** + * chunkKey -> { state: 'generated'|'imported', generatedAt?: number, generatorVersion?: number } + */ + this._entries = new Map(); + } + + chunkKey(cx, cz) { + return `${cx},${cz}`; + } + + // ============================================================ + // Chunk Presence + // ============================================================ + + /** + * 标记 chunk 已被生成 + * @param {number} cx + * @param {number} cz + * @param {object} [meta] - 附加元数据 + */ + markChunkGenerated(cx, cz, meta = {}) { + const key = this.chunkKey(cx, cz); + this._entries.set(key, { + state: 'generated', + generatedAt: meta.generatedAt || Date.now(), + generatorVersion: meta.generatorVersion || 0 + }); + } + + /** + * 标记 chunk 已从冷边界导入 + * @param {number} cx + * @param {number} cz + * @param {object} [meta] + */ + markChunkImported(cx, cz, meta = {}) { + const key = this.chunkKey(cx, cz); + this._entries.set(key, { + state: 'imported', + generatedAt: meta.generatedAt || Date.now(), + generatorVersion: meta.generatorVersion || 0 + }); + } + + /** + * 获取 chunk 的 presence state + * @param {number} cx + * @param {number} cz + * @returns {object} { state: 'missing'|'generated'|'imported', ...meta } + */ + getChunkState(cx, cz) { + const key = this.chunkKey(cx, cz); + const entry = this._entries.get(key); + return entry ? { ...entry } : { state: 'missing' }; + } + + /** + * 检查 chunk 是否已知存在(已生成或已导入) + * @param {number} cx + * @param {number} cz + * @returns {boolean} + */ + hasKnownChunk(cx, cz) { + return this._entries.has(this.chunkKey(cx, cz)); + } + + /** + * 检查 chunk 是否是 missing(完全未知) + * @param {number} cx + * @param {number} cz + * @returns {boolean} + */ + isMissing(cx, cz) { + return !this._entries.has(this.chunkKey(cx, cz)); + } +} diff --git a/src/world/WorldGenerationService.js b/src/world/WorldGenerationService.js index dfcb100..271198b 100644 --- a/src/world/WorldGenerationService.js +++ b/src/world/WorldGenerationService.js @@ -14,6 +14,7 @@ * - 使用 region (8x8 chunk) 作为生成和存储的基本单元 */ import { worldStore } from './WorldStore.js'; +import { WorldBlockDataStore } from './WorldBlockDataStore.js'; // MemoryWorldStore 通过 this._world.memoryWorldStore 访问,不直接导入 // worldWorkerPool 预留,供未来批量生成优化使用 // import { worldWorkerPool } from '../workers/WorldWorkerPool.js'; @@ -375,21 +376,51 @@ export class WorldGenerationService { } /** - * 将 region 中的每个 chunk 直写到内存权威层 + * 将 region 中的每个 chunk 写入 runtime authority(新旧双写过渡期) + * - 新路径:WorldBlockDataStore + WorldChunkPayloadRegistry + WorldChunkRegistry + * - 旧路径:MemoryWorldStore(兼容过渡,后续移除) */ _writeRegionToMemoryStore(rx, rz, regionRecord) { - const memoryStore = this._world.memoryWorldStore; - if (!memoryStore) return; + const world = this._world; + if (!world) return; + + const blockDataStore = world.worldBlockDataStore; + const payloadRegistry = world.worldChunkPayloadRegistry; + const chunkRegistry = world.worldChunkRegistry; + const memoryStore = world.memoryWorldStore; for (const [chunkKey, chunkData] of Object.entries(regionRecord.chunks)) { const [cx, cz] = chunkKey.split(',').map(Number); - memoryStore.createOrReplaceChunkRecord(cx, cz, { - blockData: chunkData.blockData || {}, - staticEntities: chunkData.staticEntities || [], - runtimeSeedData: chunkData.runtimeSeedData || {}, - runtimeEntities: chunkData.runtimeEntities || { turrets: [], zombieNests: [], minecarts: [] }, - generatorVersion: regionRecord.generatorVersion - }); + + // --- 新 authority 路径 --- + if (blockDataStore) { + const blockDataMap = WorldBlockDataStore.deserializeBlockData(chunkData.blockData || {}); + blockDataStore.replaceChunkSlice(cx, cz, blockDataMap); + } + + if (payloadRegistry) { + payloadRegistry.setChunkPayload(cx, cz, { + runtimeSeedData: chunkData.runtimeSeedData || {}, + staticEntities: chunkData.staticEntities || [] + }); + } + + if (chunkRegistry) { + chunkRegistry.markChunkGenerated(cx, cz, { + generatorVersion: regionRecord.generatorVersion + }); + } + + // --- 旧 MemoryWorldStore 路径(兼容过渡,后续移除)--- + if (memoryStore) { + memoryStore.createOrReplaceChunkRecord(cx, cz, { + blockData: chunkData.blockData || {}, + staticEntities: chunkData.staticEntities || [], + runtimeSeedData: chunkData.runtimeSeedData || {}, + runtimeEntities: chunkData.runtimeEntities || { turrets: [], zombieNests: [], minecarts: [] }, + generatorVersion: regionRecord.generatorVersion + }); + } } } diff --git a/src/world/WorldRuntime.js b/src/world/WorldRuntime.js index 0d8cd8e..17b03d0 100644 --- a/src/world/WorldRuntime.js +++ b/src/world/WorldRuntime.js @@ -162,8 +162,9 @@ export class WorldRuntime { } /** - * 记录单个方块变更到 runtime 写回快照。 - * 首次脏化时从当前 chunk.blockData 建一次序列化快照,之后只做增量更新。 + * 记录方块变更为 runtime dirty(不再构造 blockDataSnapshot) + * authority 由 WorldBlockDataStore 统一持有,此处仅标记 runtime dirty + * blockDataSnapshot 语义已退出 runtime 正确性主链路 * @param {number} cx * @param {number} cz * @param {number} x @@ -173,17 +174,9 @@ export class WorldRuntime { */ recordBlockMutation(cx, cz, x, y, z, typeOrEntry) { const dirtyEntry = this._ensureDirtyChunkEntry(cx, cz); - if (!dirtyEntry.blockDataSnapshot) { - dirtyEntry.blockDataSnapshot = this._createChunkSnapshotFromWorld(cx, cz); - } - - const code = encodeCoord(Math.floor(x), Math.floor(y), Math.floor(z)); - const entry = this._normalizeSerializedEntry(typeOrEntry); - if (!entry) { - delete dirtyEntry.blockDataSnapshot[code]; - return; - } - dirtyEntry.blockDataSnapshot[code] = entry; + dirtyEntry.dirty = true; + // blockDataSnapshot 已不再构造,authority 由 WorldBlockDataStore 持有 + // 旧 snapshot 字段保留为 null 用于向后兼容,消费者应迁移到 authority } /** @@ -813,6 +806,8 @@ export class WorldRuntime { }; } + // blockDataSnapshot 已退出 runtime 正确性链路 + // 旧 dirtyEntry.blockDataSnapshot 路径保留为 deferred compatibility fallback if (dirtyEntry?.blockDataSnapshot) { return { blockData: dirtyEntry.blockDataSnapshot, From 2a7d38a7ea7316da16580cd24974b89e2a1c03f4 Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Fri, 8 May 2026 22:24:24 +0800 Subject: [PATCH 09/17] =?UTF-8?q?refactor(world):=20=E5=88=A0=E9=99=A4=20M?= =?UTF-8?q?emoryWorldStore=EF=BC=8C=E6=8E=A5=E5=85=A5=E7=BB=9F=E4=B8=80=20?= =?UTF-8?q?epoch/version=20=E5=8D=8F=E8=AE=AE=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=9C=9F=20guardrails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 变更: - 删除 MemoryWorldStore.js 及全部 ~8 处双写引用(Chunk/World/WorldGenerationService) - Consolidation 回包增加 assemblyEpoch + authorityVersion 过期校验,过期回包直接丢弃 - AO 回包增加 disposed / assemblyEpoch 校验(原有 aoSourceVersion 保留) - ChunkGenerator / acceptWorkerResult 增加 disposed 检查 - WorldBlockDataStore.replaceChunkSlice 增加 callSource 追踪与统计 - Chunk.attachAuthoritySlice 增加 slice integrity 验证 - WorldRuntime 增加 _callStats 调用计数器 ⚠️ 有待进一步测试验证 Co-Authored-By: Claude Opus 4.7 --- src/tests/index.html | 1 - src/tests/test-memory-world-store.js | 103 --------------- src/world/Chunk.js | 33 ++--- src/world/ChunkConsolidation.js | 14 ++ src/world/ChunkGenerator.js | 1 + src/world/MemoryWorldStore.js | 191 --------------------------- src/world/World.js | 38 +----- src/world/WorldBlockDataStore.js | 37 +++++- src/world/WorldGenerationService.js | 28 +--- src/world/WorldRuntime.js | 10 +- src/world/WorldStore.js | 5 +- 11 files changed, 71 insertions(+), 390 deletions(-) delete mode 100644 src/tests/test-memory-world-store.js delete mode 100644 src/world/MemoryWorldStore.js diff --git a/src/tests/index.html b/src/tests/index.html index 85557f0..4968ce6 100644 --- a/src/tests/index.html +++ b/src/tests/index.html @@ -399,7 +399,6 @@

MC Lite - 测试用例

import './test-runtime-session-persistence.js'; import './test-global-instanced-mesh-manager.js'; import './test-hud.js'; - import './test-memory-world-store.js'; import './test-playground-service.js'; // 等待所有脚本加载后再延时 1000ms 启用按钮 diff --git a/src/tests/test-memory-world-store.js b/src/tests/test-memory-world-store.js deleted file mode 100644 index 4233ea8..0000000 --- a/src/tests/test-memory-world-store.js +++ /dev/null @@ -1,103 +0,0 @@ -import { describe } from './runner.js'; -import { assertEqual, assertNotNull, assertDeepEqual } from './assert.js'; -import { MemoryWorldStore } from '../world/MemoryWorldStore.js'; - -describe('MemoryWorldStore', (test) => { - test('createOrReplaceChunkRecord 可写入 chunk', () => { - const store = new MemoryWorldStore(); - const record = { - blockData: { 123: 'stone' }, - staticEntities: [], - runtimeSeedData: {}, - runtimeEntities: { turrets: [], zombieNests: [], minecarts: [] } - }; - store.createOrReplaceChunkRecord(1, 2, record); - const retrieved = store.getChunkRecord(1, 2); - assertNotNull(retrieved); - assertEqual(retrieved.cx, 1); - assertEqual(retrieved.cz, 2); - assertEqual(retrieved.blockData[123], 'stone'); - }); - - test('getChunkRecord 读取不存在的 chunk 返回 null', () => { - const store = new MemoryWorldStore(); - const result = store.getChunkRecord(99, 99); - assertEqual(result, null); - }); - - test('applyBlockMutation 应立即更新 chunkRecord.blockData', () => { - const store = new MemoryWorldStore(); - store.createOrReplaceChunkRecord(1, 2, { - blockData: {}, - staticEntities: [], - runtimeSeedData: {}, - runtimeEntities: {} - }); - store.applyBlockMutation(1, 2, 123, 'stone'); - const record = store.getChunkRecord(1, 2); - assertEqual(record.blockData[123], 'stone'); - }); - - test('applyBlockMutation 支持对象 entry', () => { - const store = new MemoryWorldStore(); - store.createOrReplaceChunkRecord(0, 0, { - blockData: {}, - staticEntities: [], - runtimeSeedData: {}, - runtimeEntities: {} - }); - const entry = { type: 'torch', orientation: 2 }; - store.applyBlockMutation(0, 0, 456, entry); - const record = store.getChunkRecord(0, 0); - assertDeepEqual(record.blockData[456], entry); - }); - - test('applyBlockMutation 删除 entry(entry 为 null)', () => { - const store = new MemoryWorldStore(); - store.createOrReplaceChunkRecord(0, 0, { - blockData: { 789: 'dirt' }, - staticEntities: [], - runtimeSeedData: {}, - runtimeEntities: {} - }); - store.applyBlockMutation(0, 0, 789, null); - const record = store.getChunkRecord(0, 0); - assertEqual(record.blockData[789], undefined); - }); - - test('getStats 返回 region/chunk 计数', () => { - const store = new MemoryWorldStore(); - // chunk(0,0) → region(0,0), chunk(0,1) → region(0,0), chunk(8,0) → region(1,0) - store.createOrReplaceChunkRecord(0, 0, { - blockData: {}, staticEntities: [], runtimeSeedData: {}, runtimeEntities: {} - }); - store.createOrReplaceChunkRecord(0, 1, { - blockData: {}, staticEntities: [], runtimeSeedData: {}, runtimeEntities: {} - }); - store.createOrReplaceChunkRecord(8, 0, { - blockData: {}, staticEntities: [], runtimeSeedData: {}, runtimeEntities: {} - }); - const stats = store.getStats(); - assertEqual(stats.regionCount, 2); - assertEqual(stats.chunkCount, 3); - }); - - test('createOrReplaceChunkRecord 覆盖已有 chunk', () => { - const store = new MemoryWorldStore(); - store.createOrReplaceChunkRecord(0, 0, { - blockData: { 1: 'dirt' }, - staticEntities: [], - runtimeSeedData: {}, - runtimeEntities: {} - }); - store.createOrReplaceChunkRecord(0, 0, { - blockData: { 2: 'stone' }, - staticEntities: [], - runtimeSeedData: {}, - runtimeEntities: {} - }); - const record = store.getChunkRecord(0, 0); - assertEqual(record.blockData[1], undefined); - assertEqual(record.blockData[2], 'stone'); - }); -}); diff --git a/src/world/Chunk.js b/src/world/Chunk.js index ba351a9..84fd140 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -312,6 +312,9 @@ export class Chunk { const store = this.world?.worldBlockDataStore; if (!store) return false; + // 开发期断言:验证 slice 完整性 + store._verifySliceIntegrity(this.cx, this.cz, 'Chunk.attachAuthoritySlice'); + const slice = store.ensureChunkSlice(this.cx, this.cz); this.blockData = slice; store.markAttached(this.cx, this.cz); @@ -1224,12 +1227,6 @@ export class Chunk { (blockStore.getAuthorityVersion(this.cx, this.cz) || 0) + 1); } - // 同步到旧内存权威层(兼容过渡,后续移除) - const memStore = this.world?.memoryWorldStore; - if (memStore) { - memStore.applyBlockMutation(this.cx, this.cz, code, type === 'air' ? null : entry); - } - // 更新碰撞体集合 const props = getBlockProps(type); if (props.isSolid) { @@ -1974,12 +1971,15 @@ export class Chunk { // 生成请求 ID const requestId = `${this.cx},${this.cz}-${Date.now()}`; const aoSourceVersion = this._aoSourceVersion; + const assemblyEpoch = this._assemblyEpoch || 0; // 动态导入 Worker 和回调 import('./ChunkConsolidation.js').then(({ aoWorker, aoCallbacks }) => { // 注册回调 aoCallbacks.set(requestId, (data) => { - if (!this.isReady || this.isConsolidating || this._aoSourceVersion !== aoSourceVersion) return; + if (this.disposed || !this.isReady || this.isConsolidating) return; + if (this._aoSourceVersion !== aoSourceVersion) return; + if (this._assemblyEpoch !== assemblyEpoch) return; this._applyAOResults(data.results, sentCodes); }); @@ -2438,6 +2438,7 @@ export class Chunk { * @param {object} payload - Worker 回包数据 */ acceptWorkerResult(payload = {}) { + if (this.disposed) return; const t0 = globalThis.performance?.now?.() ?? Date.now(); const { scatteredBlocks, @@ -3097,12 +3098,6 @@ export class Chunk { this.world.worldRuntime.recordBlockMutation(this.cx, this.cz, px, py, pz, 'air'); } - // 同步到内存权威层 - const memStore = this.world?.memoryWorldStore; - if (memStore) { - memStore.applyBlockMutation(this.cx, this.cz, code, null); - } - // 记录 AO Worker 副本同步 delta aoDeltas.push({ chunkKey, code, op: 'delete', entry: null }); @@ -3449,12 +3444,6 @@ export class Chunk { const entry = block.orientation !== 0 ? { type: block.type, orientation: block.orientation } : block.type; this.blockData.set(code, entry); - // 同步到内存权威层(初始加载也直写,确保权威层完整) - const memStore = this.world?.memoryWorldStore; - if (memStore) { - memStore.applyBlockMutation(this.cx, this.cz, code, entry); - } - // 写入 solidBlocks const props = getBlockProps(block.type); if (props.isSolid) { @@ -3569,12 +3558,6 @@ export class Chunk { const entry = block.orientation !== 0 ? { type: block.type, orientation: block.orientation } : block.type; this.blockData.set(code, entry); - // 同步到内存权威层 - const memStore = this.world?.memoryWorldStore; - if (memStore) { - memStore.applyBlockMutation(this.cx, this.cz, code, entry); - } - // 写入 solidBlocks const props = getBlockProps(block.type); if (props.isSolid) { diff --git a/src/world/ChunkConsolidation.js b/src/world/ChunkConsolidation.js index 2128576..156622a 100644 --- a/src/world/ChunkConsolidation.js +++ b/src/world/ChunkConsolidation.js @@ -332,7 +332,19 @@ export function extendChunk(Chunk) { // 阶段 2: 注册 Worker 回调并请求重新计算 const taskId = `consolidate:${this.cx},${this.cz}:${performance.now()}:${Math.random().toString(36).slice(2, 8)}`; const isCrossChunkPatch = this.hasDeferredFinalizeWork === true; + const assemblyEpoch = this._assemblyEpoch || 0; + const authorityVersion = this.world?.worldBlockDataStore?.getAuthorityVersion(this.cx, this.cz) || 0; workerCallbacks.set(taskId, (data) => { + // 过期回包拒绝:chunk 已 disposed / detach-reattach / authority 已变更 + if (this.disposed || this._assemblyEpoch !== assemblyEpoch) { + return; + } + // authority version 校验:consolidation 期间玩家修改了 blockData,丢弃回包 + const currentVersion = this.world?.worldBlockDataStore?.getAuthorityVersion(this.cx, this.cz) || 0; + if (currentVersion !== authorityVersion) { + return; + } + const callbackReceivedAt = performance.now(); const workerTiming = data._workerTiming || {}; const transitFromWorkerMs = workerTiming.workerFinishedAt @@ -356,6 +368,8 @@ export function extendChunk(Chunk) { taskId, seed: WORLD_CONFIG.SEED, _consolidationRequestSentAt: sendTimestamp, + _assemblyEpoch: assemblyEpoch, + _authorityVersion: authorityVersion, snapshot: { blocks: blockDataToNumberKeys(this.blockData), entities: { diff --git a/src/world/ChunkGenerator.js b/src/world/ChunkGenerator.js index fb41701..823f365 100644 --- a/src/world/ChunkGenerator.js +++ b/src/world/ChunkGenerator.js @@ -69,6 +69,7 @@ export function extendChunk(Chunk) { // 注册 Worker 池回调 workerCallbacks.set(taskId, (data) => { + if (this.disposed) return; this.world?._onChunkGenResult?.(this, data); resolve(); }); diff --git a/src/world/MemoryWorldStore.js b/src/world/MemoryWorldStore.js deleted file mode 100644 index 251a406..0000000 --- a/src/world/MemoryWorldStore.js +++ /dev/null @@ -1,191 +0,0 @@ -// src/world/MemoryWorldStore.js -/** - * MemoryWorldStore — 运行期世界级权威内存存储 - * - * 作为运行期唯一权威数据源,接管旧版 IndexedDB 在 chunk streaming 中的角色。 - * 启动时从旧存档导入,运行期间所有方块修改立即同步到本层。 - * 不自动回写 IndexedDB(仅未来手动保存时导出)。 - */ - -export class MemoryWorldStore { - constructor() { - this.worldMeta = null; - - // 双索引:region 级 + chunk 级 - this.regions = new Map(); // regionKey -> { rx, rz, chunkKeys: Set, chunks: Map } - this.chunks = new Map(); // chunkKey -> MemoryChunkRecord - - // dirty 标记(仅为未来手动保存准备,不参与运行期正确性) - this.dirtyChunks = new Set(); - - // 统计 - this.stats = { - totalReads: 0, - totalWrites: 0, - totalMutations: 0, - hits: 0, - misses: 0 - }; - } - - // ============================================================ - // 工具方法 - // ============================================================ - - chunkKey(cx, cz) { - return `${cx},${cz}`; - } - - regionKey(rx, rz) { - return `${rx},${rz}`; - } - - chunkToRegion(cx, cz) { - return { rx: Math.floor(cx / 8), rz: Math.floor(cz / 8) }; - } - - // ============================================================ - // ChunkRecord 读写 - // ============================================================ - - /** - * 写入或替换一个 chunk record - */ - createOrReplaceChunkRecord(cx, cz, record) { - const key = this.chunkKey(cx, cz); - const { rx, rz } = this.chunkToRegion(cx, cz); - const rKey = this.regionKey(rx, rz); - - const chunkRecord = { - cx, - cz, - blockData: record.blockData ? { ...record.blockData } : {}, - staticEntities: record.staticEntities ? [...record.staticEntities] : [], - runtimeSeedData: record.runtimeSeedData ? { ...record.runtimeSeedData } : {}, - runtimeEntities: record.runtimeEntities - ? JSON.parse(JSON.stringify(record.runtimeEntities)) - : { turrets: [], zombieNests: [], minecarts: [] }, - version: record.version ?? 0, - dirty: false, - lastModifiedAt: Date.now() - }; - - this.chunks.set(key, chunkRecord); - - // 更新 region 索引 - let region = this.regions.get(rKey); - if (!region) { - region = { - rx, - rz, - chunkKeys: new Set(), - chunks: new Map(), - generatedAt: Date.now(), - generatorVersion: record.generatorVersion ?? 0 - }; - this.regions.set(rKey, region); - } - region.chunkKeys.add(key); - region.chunks.set(key, chunkRecord); - - this.stats.totalWrites++; - } - - /** - * 读取一个 chunk record - */ - getChunkRecord(cx, cz) { - const key = this.chunkKey(cx, cz); - const record = this.chunks.get(key); - - this.stats.totalReads++; - if (record) { - this.stats.hits++; - // 返回快照,防止外部修改内部状态 - return { - cx: record.cx, - cz: record.cz, - blockData: { ...record.blockData }, - staticEntities: [...record.staticEntities], - runtimeSeedData: { ...record.runtimeSeedData }, - runtimeEntities: JSON.parse(JSON.stringify(record.runtimeEntities)) - }; - } - - this.stats.misses++; - return null; - } - - // ============================================================ - // Region 查询 - // ============================================================ - - getRegion(rx, rz) { - const rKey = this.regionKey(rx, rz); - return this.regions.get(rKey) || null; - } - - // ============================================================ - // Block mutation - // ============================================================ - - /** - * 立即更新权威层的一个 block entry - * entry 可以是 string type、{ type, orientation } 对象,或 null(删除) - */ - applyBlockMutation(cx, cz, coord, entry) { - const key = this.chunkKey(cx, cz); - const record = this.chunks.get(key); - if (!record) { - return; - } - - if (entry === null || entry === undefined) { - delete record.blockData[coord]; - } else { - record.blockData[coord] = typeof entry === 'string' ? entry : { ...entry }; - } - - record.dirty = true; - record.lastModifiedAt = Date.now(); - record.version++; - - this.dirtyChunks.add(key); - this.stats.totalMutations++; - } - - /** - * 批量替换 chunk 的 blockData 快照(用于生成器直写或导入) - */ - applyChunkBlockSnapshot(cx, cz, blockData) { - const key = this.chunkKey(cx, cz); - const record = this.chunks.get(key); - if (!record) { - return; - } - - record.blockData = { ...blockData }; - record.dirty = true; - record.lastModifiedAt = Date.now(); - record.version++; - - this.dirtyChunks.add(key); - } - - // ============================================================ - // 统计 - // ============================================================ - - getStats() { - return { - regionCount: this.regions.size, - chunkCount: this.chunks.size, - dirtyChunkCount: this.dirtyChunks.size, - reads: this.stats.totalReads, - writes: this.stats.totalWrites, - mutations: this.stats.totalMutations, - hits: this.stats.hits, - misses: this.stats.misses - }; - } -} diff --git a/src/world/World.js b/src/world/World.js index 4d4a6d6..cb873d1 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -19,7 +19,6 @@ import { WorldAccessLayer } from './WorldAccessLayer.js'; import { WorldBoundsController } from './WorldBoundsController.js'; import { WorldGenerationService } from './WorldGenerationService.js'; import { worldStore } from './WorldStore.js'; -import { MemoryWorldStore } from './MemoryWorldStore.js'; import { WorldBlockDataStore } from './WorldBlockDataStore.js'; import { WorldChunkRegistry } from './WorldChunkRegistry.js'; import { WorldChunkPayloadRegistry } from './WorldChunkPayloadRegistry.js'; @@ -172,7 +171,6 @@ export class World { // --- WorldStore 新架构初始化 --- this.worldStore = worldStore; - this.memoryWorldStore = new MemoryWorldStore(); this.worldBlockDataStore = new WorldBlockDataStore(); this.worldChunkRegistry = new WorldChunkRegistry(); this.worldChunkPayloadRegistry = new WorldChunkPayloadRegistry(); @@ -369,41 +367,7 @@ export class World { } } - // --- 旧路径:MemoryWorldStore(兼容过渡) --- - // 运行期优先从内存权威层读取 - const chunkRecord = this.memoryWorldStore.getChunkRecord(chunk.cx, chunk.cz); - const memRequestEnd = globalThis.performance?.now?.() ?? Date.now(); - recordChunkPerf('world.runtime-chunk-record-memory', memRequestEnd - memRequestStart, { - chunkKey, - status: chunkRecord ? 'ready' : 'missing-chunk', - hasBlockData: !!chunkRecord?.blockData, - blockDataSize: chunkRecord?.blockData ? Object.keys(chunkRecord.blockData).length : 0, - hasRuntimeEntities: !!(chunkRecord?.runtimeEntities) - }); - - if (chunkRecord && !chunk.disposed) { - // 仍然走 WorldRuntime 的 region cache upsert 和 legacy entity 迁移 - this.worldRuntime.ensureChunkData(chunk.cx, chunk.cz).then((result) => { - if (chunk.disposed) return; - if (result?.status === 'ready' && result.chunkRecord) { - chunk.loadFromRecord(result.chunkRecord); - return; - } - // 内存已有数据但 WorldRuntime 路径也返回了,使用 WorldRuntime 的结果 - //(包含了 legacy entity hydration) - if (!chunk.disposed) { - chunk.loadFromRecord(chunkRecord); - } - }).catch((_error) => { - // WorldRuntime 路径失败但内存有数据,仍可从内存加载 - if (!chunk.disposed) { - chunk.loadFromRecord(chunkRecord); - } - }); - return; - } - - // 内存中没有数据,回退到 WorldRuntime 的 IndexedDB 路径(旧存档导入场景) + // 新 authority 未命中,回退到 WorldRuntime 的 IndexedDB 路径(旧存档导入场景) this.worldRuntime.ensureChunkData(chunk.cx, chunk.cz).then((result) => { const dbRequestEnd = globalThis.performance?.now?.() ?? Date.now(); recordChunkPerf('world.runtime-chunk-record-db', dbRequestEnd - memRequestStart, { diff --git a/src/world/WorldBlockDataStore.js b/src/world/WorldBlockDataStore.js index f081629..afd26c0 100644 --- a/src/world/WorldBlockDataStore.js +++ b/src/world/WorldBlockDataStore.js @@ -195,22 +195,22 @@ export class WorldBlockDataStore { * @param {number} cz * @param {Map} blockData - 新的 Map 实例 */ - replaceChunkSlice(cx, cz, blockData) { + replaceChunkSlice(cx, cz, blockData, _callSource = 'unknown') { const key = this.chunkKey(cx, cz); // guard:已 attach 的 slice 不允许直接 replace if (this._attached.has(key)) { console.warn( - `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) called on attached slice. ` + - 'Use detach -> replace -> reattach protocol.' + `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) called on attached slice ` + + `(source: ${_callSource}). Use detach -> replace -> reattach protocol.` ); return; } if (!(blockData instanceof Map)) { console.warn( - `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) received non-Map input. ` + - 'Runtime authority must use Map.' + `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) received non-Map input ` + + `(source: ${_callSource}). Runtime authority must use Map.` ); return; } @@ -222,6 +222,8 @@ export class WorldBlockDataStore { this._versions.set(key, currentVersion + 1); this.stats.totalWrites++; + this.stats._replaceCallSources = this.stats._replaceCallSources || {}; + this.stats._replaceCallSources[_callSource] = (this.stats._replaceCallSources[_callSource] || 0) + 1; } // ============================================================ @@ -323,7 +325,30 @@ export class WorldBlockDataStore { writes: this.stats.totalWrites, mutations: this.stats.totalMutations, hits: this.stats.hits, - misses: this.stats.misses + misses: this.stats.misses, + replaceCallSources: { ...this.stats._replaceCallSources } }; } + + /** + * 开发期断言:验证 chunk slice 未被意外清空 + * 在关键状态转换点调用,检测是否存在绕过受控入口的 clear() 调用 + * @param {number} cx + * @param {number} cz + * @param {string} caller - 调用点标识 + * @returns {boolean} + */ + _verifySliceIntegrity(cx, cz, caller = 'unknown') { + const key = this.chunkKey(cx, cz); + const slice = this._slices.get(key); + if (!slice) { + console.warn(`[WorldBlockDataStore] _verifySliceIntegrity(${cx},${cz}) from ${caller}: slice is null/missing`); + return false; + } + if (!(slice instanceof Map)) { + console.warn(`[WorldBlockDataStore] _verifySliceIntegrity(${cx},${cz}) from ${caller}: slice is not a Map`); + return false; + } + return true; + } } diff --git a/src/world/WorldGenerationService.js b/src/world/WorldGenerationService.js index 271198b..4e18d81 100644 --- a/src/world/WorldGenerationService.js +++ b/src/world/WorldGenerationService.js @@ -15,7 +15,6 @@ */ import { worldStore } from './WorldStore.js'; import { WorldBlockDataStore } from './WorldBlockDataStore.js'; -// MemoryWorldStore 通过 this._world.memoryWorldStore 访问,不直接导入 // worldWorkerPool 预留,供未来批量生成优化使用 // import { worldWorkerPool } from '../workers/WorldWorkerPool.js'; import { WORLD_CONFIG } from '../utils/MathUtils.js'; @@ -347,9 +346,9 @@ export class WorldGenerationService { return; } - // 直写到内存权威层(运行期主路径) - if (this._world?.memoryWorldStore) { - this._writeRegionToMemoryStore(rx, rz, regionRecord); + // 直写到运行时权威层 + if (this._world?.worldBlockDataStore) { + this._writeRegionToAuthorityStores(rx, rz, regionRecord); } // 收集跨 region overflow blocks @@ -376,26 +375,22 @@ export class WorldGenerationService { } /** - * 将 region 中的每个 chunk 写入 runtime authority(新旧双写过渡期) - * - 新路径:WorldBlockDataStore + WorldChunkPayloadRegistry + WorldChunkRegistry - * - 旧路径:MemoryWorldStore(兼容过渡,后续移除) + * 将 region 中的每个 chunk 写入运行时权威层 */ - _writeRegionToMemoryStore(rx, rz, regionRecord) { + _writeRegionToAuthorityStores(rx, rz, regionRecord) { const world = this._world; if (!world) return; const blockDataStore = world.worldBlockDataStore; const payloadRegistry = world.worldChunkPayloadRegistry; const chunkRegistry = world.worldChunkRegistry; - const memoryStore = world.memoryWorldStore; for (const [chunkKey, chunkData] of Object.entries(regionRecord.chunks)) { const [cx, cz] = chunkKey.split(',').map(Number); - // --- 新 authority 路径 --- if (blockDataStore) { const blockDataMap = WorldBlockDataStore.deserializeBlockData(chunkData.blockData || {}); - blockDataStore.replaceChunkSlice(cx, cz, blockDataMap); + blockDataStore.replaceChunkSlice(cx, cz, blockDataMap, 'WorldGenerationService._writeRegionToAuthorityStores'); } if (payloadRegistry) { @@ -410,17 +405,6 @@ export class WorldGenerationService { generatorVersion: regionRecord.generatorVersion }); } - - // --- 旧 MemoryWorldStore 路径(兼容过渡,后续移除)--- - if (memoryStore) { - memoryStore.createOrReplaceChunkRecord(cx, cz, { - blockData: chunkData.blockData || {}, - staticEntities: chunkData.staticEntities || [], - runtimeSeedData: chunkData.runtimeSeedData || {}, - runtimeEntities: chunkData.runtimeEntities || { turrets: [], zombieNests: [], minecarts: [] }, - generatorVersion: regionRecord.generatorVersion - }); - } } } diff --git a/src/world/WorldRuntime.js b/src/world/WorldRuntime.js index 17b03d0..568ef5c 100644 --- a/src/world/WorldRuntime.js +++ b/src/world/WorldRuntime.js @@ -45,6 +45,12 @@ export class WorldRuntime { lastElapsedMs: 0, lastProcessedAt: 0 }; + // 迁移期调用点统计 + this._callStats = { + recordBlockMutation: 0, + flushChunk: 0, + flushBeforeUnload: 0 + }; } /** @@ -173,10 +179,10 @@ export class WorldRuntime { * @param {string|object|null} typeOrEntry */ recordBlockMutation(cx, cz, x, y, z, typeOrEntry) { + this._callStats.recordBlockMutation++; const dirtyEntry = this._ensureDirtyChunkEntry(cx, cz); dirtyEntry.dirty = true; // blockDataSnapshot 已不再构造,authority 由 WorldBlockDataStore 持有 - // 旧 snapshot 字段保留为 null 用于向后兼容,消费者应迁移到 authority } /** @@ -210,6 +216,7 @@ export class WorldRuntime { * 保留此方法仅供未来手动保存时导出到 IndexedDB。 */ async flushChunk(cx, cz, blockDataSnapshot = null) { + this._callStats.flushChunk++; const key = this._chunkKey(cx, cz); const dirtyEntry = this._dirtyChunks.get(key); if (!dirtyEntry) return; @@ -388,6 +395,7 @@ export class WorldRuntime { * @param {object|null} entitiesSnapshot */ async flushBeforeUnload(cx, cz, blockDataSnapshot, entitiesSnapshot) { + this._callStats.flushBeforeUnload++; const startedAt = globalThis.performance?.now?.() ?? Date.now(); const key = this._chunkKey(cx, cz); const chunk = this._world?.chunks?.get(key) || null; diff --git a/src/world/WorldStore.js b/src/world/WorldStore.js index 84559a4..6c43fd3 100644 --- a/src/world/WorldStore.js +++ b/src/world/WorldStore.js @@ -2,14 +2,11 @@ /** * WorldStore — 旧存档导入/导出工具 * - * 运行期权威数据源已迁移到 MemoryWorldStore(内存)。 + * 运行期权威数据源已迁移到 WorldBlockDataStore(内存)。 * 本类保留 IndexedDB 的读写能力,仅用于: * - 旧存档一次性导入内存 * - 未来手动保存时从内存导出到 IndexedDB * - * 运行期主链路不应直接用它取 chunk record, - * 应通过 World.memoryWorldStore 获取。 - * * 数据层级: * - WorldMeta: 世界级元数据(边界、种子、生成状态) * - RegionRecord: region 级存储单元(8x8 chunk),IndexedDB 物理存储粒度 From 05eb9216679564300dd333cb116e7b9b88e4159c Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Sun, 10 May 2026 14:29:20 +0800 Subject: [PATCH 10/17] =?UTF-8?q?docs:=20=E5=8A=A0=E5=85=A5=20blockData=20?= =?UTF-8?q?authority=20unification=20=E8=A1=A5=E5=85=85=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=8C=E8=A6=86=E7=9B=96=E9=A6=96=E8=BD=AE?= =?UTF-8?q?=E9=81=97=E7=95=99=E7=9A=84=E5=86=99=E5=85=A5=E5=8F=A3=E6=94=B6?= =?UTF-8?q?=E7=BC=96=E3=80=81cold=20import=20=E7=BB=9F=E4=B8=80=E3=80=81sc?= =?UTF-8?q?atter=20=E6=9C=AA=E5=8A=A0=E8=BD=BD=20chunk=20=E5=86=99?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=20flush=20=E9=93=BE=E8=B7=AF=E9=80=80?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- ...lockdata-authority-unification-followup.md | 607 ++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-blockdata-authority-unification-followup.md diff --git a/docs/superpowers/plans/2026-05-10-blockdata-authority-unification-followup.md b/docs/superpowers/plans/2026-05-10-blockdata-authority-unification-followup.md new file mode 100644 index 0000000..9f53da4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-blockdata-authority-unification-followup.md @@ -0,0 +1,607 @@ +# BlockData Authority Unification Follow-up 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. + +**Goal:** 补齐 `blockData authority unification` 首轮实施后仍然悬空的 runtime 权威闭环,彻底收编剩余写入口、冷导入入口、跨 chunk patch 入口,并让 deprecated flush/save 链路退出热路径。 + +**Architecture:** `WorldBlockDataStore` 继续作为 world-level `blockData` 唯一权威,`Chunk.blockData` 只保留共享视图语义。所有 runtime 写操作必须统一收敛到 store mutation primitive;所有 cold import / scatter / unloaded-target patch 必须先入 authority,再由 loaded chunk attach / rebuild 派生层。`WorldRuntime.flush*` / `ChunkPersistence.saveDebounced()` 只允许保留 deferred shell 或显式 no-op,不再在玩家编辑热路径中执行。 + +**Tech Stack:** JavaScript, Three.js, Web Workers, ESLint, 浏览器内测试页面 `src/tests/index.html` + +--- + +## Follow-up Scope + +本补充计划只处理首轮 authority unification 之后仍未收口的缺口,不重写原设计目标: + +- 收拢剩余 blockData 写入口到统一 mutation primitive +- 让 scatter / cross-chunk patch 在目标 chunk 未加载时也先写入 world-level authority +- 让 cold import / `loadFromRecord()` 路径先入 authority,再 attach / rebuild +- 让 deprecated flush/save 链路退出 runtime 热路径 +- 增加 shared authority guardrail、观测与退出型测试 + +本补充计划**不**在本轮交付: + +- 新的 IndexedDB 持久化架构 +- 手动存档/手动读档的完整 authority-based rewrite +- TypedArray authority 存储 +- AO / mesh / face culling 的新一轮性能优化 + +## 当前缺口清单 + +1. `Chunk.js` 中 `assembleRuntimeHydratePhase()` 存在双重定义;前一个 authority 版本已成死代码,当前 authority attach 路径依赖“`record-ready` 但 `_pendingChunkRecord` 为空”的隐式巧合工作 +2. `Chunk._updateBlockState()` 仍直接写 shared `Map` 并手动递增 `store._versions` +3. `Chunk.acceptScatteredBlocks()` / `appendScatteredBlocks()` 仍存在绕过 store mutation primitive 的路径,且 scatter 写入后没有统一 authority version bump +4. `BlockScatterManager` 对未加载目标 chunk 只保留 pending buffer,没有先写 world-level authority +5. `WorldRuntime.ensureChunkData()` / `Chunk.loadFromRecord()` 冷导入链路还没有先写 `WorldBlockDataStore` / `WorldChunkPayloadRegistry` / `WorldChunkRegistry` +6. `markChunkDirty() -> _scheduleFlush() -> flushChunk()` 链路仍在热路径实际执行 +7. `ChunkPersistence.saveDebounced()` 仍触发 `flushChunk()` +8. shared authority 模式下 guardrail 不足,仍缺少针对 live attached slice 的开发期保护 +9. 测试主要是兼容旧语义,缺少 authority 新约定的直接覆盖 + +## 文件结构与职责 + +### 重点修改文件 + +- Modify: `src/world/WorldBlockDataStore.js` + - 补齐唯一合法 mutation primitive + - 提供 attach-aware patch API、authority version 统一递增、开发期 guardrail +- Modify: `src/world/Chunk.js` + - 把 `_updateBlockState()`、`acceptScatteredBlocks()`、`appendScatteredBlocks()` 收缩为派生层同步 helper / store 编排调用方 + - 避免直接操作 shared authority slice +- Modify: `src/world/BlockScatterManager.js` + - 把未加载目标 chunk 的 scatter / deferred patch 先写入 world-level authority + - 保留 buffer 仅作为派生层/渲染补刷协调结构 +- Modify: `src/world/World.js` + - runtime chunk attach 路径与 cold import 路径统一为“先入 authority,再 attach/rebuild” +- Modify: `src/world/WorldRuntime.js` + - 退出 `markChunkDirty()` 的自动 flush 调度 + - 收缩 `flushChunk()` / `flushBeforeUnload()` / `flushAllDirty()` 到 deferred shell +- Modify: `src/world/ChunkPersistence.js` + - 退出 runtime 自动保存职责,改为 no-op / dirty marker shell / 明确 deprecated +- Modify: `src/core/Game.js` + - 明确 `collectSnapshot()` / `applySaveData()` 的本阶段命运与注释边界 +- Modify: `src/tests/test-world-runtime.js` + - 重写为“旧 flush 退出热路径,但 shell 仍可单测”的测试语义 +- Modify: `src/tests/test-world.js` + - 增加 world-level authority attach / unload / reload / scatter 行为覆盖 +- Modify: `src/tests/test-runtime-session-persistence.js` + - 把 `loadFromRecord()` 相关测试迁移为 authority import 语义 +- Modify: `src/tests/test-block-scatter-manager.js` + - 增加未加载目标 chunk 先写 authority 的测试 + +### 需要重点核查但不必默认改动的文件 + +- Inspect: `src/world/ChunkConsolidation.js` +- Inspect: `src/world/ChunkRenderUtils.js` +- Inspect: `src/world/WorldGenerationService.js` +- Inspect: `src/world/WorldChunkPayloadRegistry.js` +- Inspect: `src/world/WorldChunkRegistry.js` + +## 验收矩阵 + +- 所有 runtime blockData mutation 都通过 `WorldBlockDataStore` 的公开原语完成 +- loaded chunk 不再手动碰 `store._versions` +- scatter / cross-chunk patch 命中未加载目标 chunk 时,authority 先更新,目标 chunk 未来加载时可直接 attach/rebuild +- cold import / `ensureChunkData()` / `loadFromRecord()` 不再把 plain object 直接注入 chunk-local truth +- 玩家单块编辑后,不再自动调度 `flushChunk()` / IndexedDB 写入 +- `ChunkPersistence.saveDebounced()` 不再在 runtime 热路径承担正确性或保存职责 +- old deferred shell 即使保留,也不能再把 `RegionCache.blockData` 当 live truth 回写覆盖 authority +- 至少有退出型测试证明:关闭 `flushChunk()` 后,edit -> unload -> reload 在当前会话内仍正确 + +--- + +### Task 0: 固定补充阶段边界与收尾目标 + +**Files:** +- Modify: `docs/superpowers/plans/2026-05-10-blockdata-authority-unification-followup.md` +- Modify: `docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md` + +- [ ] **Step 1: 在设计文档中追加 follow-up 边界说明** + +要求: + +- 明确本补充阶段是首轮 authority unification 的收尾收口 +- 明确重点是“入口统一”和“旧热路径退出”,不是新持久化方案 +- 明确本轮仍允许保留 deferred shell,但不允许继续实际参与 runtime 热路径 + +- [ ] **Step 2: 写清 cold import / scatter / unloaded-target patch 的统一语义** + +要求: + +- cold import 必须先入 authority,再 attach/rebuild +- scatter 对未加载目标 chunk 也必须先写 authority +- pending buffer 只能表示“派生层/渲染补刷未完成”,不能表示“truth 尚未写入” + +- [ ] **Step 3: 写清 deprecated shell 的允许边界** + +要求: + +- `flushChunk()` / `flushBeforeUnload()` / `flushAllDirty()` 若保留,只能服务 future export / manual save +- `saveDebounced()` 不能再驱动 runtime 正确性 +- `RegionCache.blockData` 不能再被 shell 当作 live truth fallback + +--- + +### Task 0.5: 清理 runtime hydrate 状态机与 authority-path 死代码 + +**Files:** +- Modify: `src/world/Chunk.js` +- Modify: `src/world/World.js` +- Test: `src/tests/test-runtime-session-persistence.js` +- Test: `src/tests/test-world.js` + +- [ ] **Step 1: 写失败测试,固定 hydrate 状态机契约** + +测试目标: + +- `Chunk` 上只保留一个 `assembleRuntimeHydratePhase()` 定义 +- authority attach 路径与 cold import 路径都经过同一套明确的 hydrate 状态机 +- 不允许继续依赖“`loadState = record-ready` 但 `_pendingChunkRecord = null`”的隐式巧合 + +- [ ] **Step 2: 运行测试,确认当前实现存在双定义与死代码问题** + +Run: + +- 浏览器测试页运行 `test-runtime-session-persistence.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- 新增测试先失败,显示 authority attach 路径与 cold import 路径的 hydrate 约定不清晰 + +- [ ] **Step 3: 合并两个 `assembleRuntimeHydratePhase()`** + +要求: + +- 删除死代码定义,只保留一个 runtime hydrate 入口 +- 明确区分两类输入: + - authority attach 后只需 rebuild/补尾部 payload 的路径 + - cold import record 需要先建立 authority 再进入的路径 +- 不允许同名方法靠后定义覆盖前定义 + +- [ ] **Step 4: 收紧 `record-ready` 的状态语义** + +要求: + +- 明确 `record-ready` 是否必须伴随 `_pendingChunkRecord` +- 若 authority attach 路径不需要 `_pendingChunkRecord`,则应使用显式标志或拆分状态,而不是依赖空数据快速路径 +- `World._requestRuntimeChunkRecord()` 的注释与真实执行语义一致 + +- [ ] **Step 5: 明确 `_injectBlockData*` / `_clearForBlockInjection` 的命运** + +要求: + +- `_injectBlockData()`:收缩为“从 cold input 重建派生层”的 helper,或删除 +- `_injectBlockDataBatch()`:若保留,只允许做分帧派生层 rebuild,不再承担 truth injection +- `_clearForBlockInjection()`:确认实现与注释一致,不得触碰 authority slice +- 这些 helper 的注释必须明确自己是否还参与 runtime 主链路 + +- [ ] **Step 6: 运行 hydrate 相关测试** + +Run: + +- 浏览器测试页运行 `test-runtime-session-persistence.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- authority attach 与 cold import 的 hydrate 语义清晰且通过测试 + +--- + +### Task 1: 建立唯一合法 mutation primitive,并收编 chunk 写入口 + +**Files:** +- Modify: `src/world/WorldBlockDataStore.js` +- Modify: `src/world/Chunk.js` +- Test: `src/tests/test-world.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定“Chunk 不得直接修改 shared Map”约束** + +测试目标: + +- `_updateBlockState()` 触发后,authority version 来自 store 公开 API,而不是手动碰私有字段 +- `acceptScatteredBlocks()` / `appendScatteredBlocks()` 写入后 authority version 正确变化 +- scatter / deferred cross-chunk patch 写入后 authority version 必须统一 bump +- `WorldBlockDataStore.stats.mutations` 能反映这些 runtime mutation + +- [ ] **Step 2: 运行相关测试,确认当前实现仍绕过 store 原语** + +Run: + +- 浏览器测试页运行 `test-world.js` +- 浏览器测试页运行 `test-world-runtime.js` + +Expected: + +- 新增测试先失败,显示 `_updateBlockState()` / scatter 路径未走 store mutation primitive + +- [ ] **Step 3: 扩展 `WorldBlockDataStore` 公开 mutation API** + +要求: + +- 提供单块 set/delete 原语 +- 复用或扩展现有 `applyChunkPatch(cx, cz, patches)` 作为 chunk-level patch 原语 +- 原语内部统一完成: + - entry 规范化 + - version 递增 + - 统计计数 + - attached / missing slice guard + +- [ ] **Step 4: 把 `Chunk._updateBlockState()` 改为 store 原语调用方** + +要求: + +- `Chunk` 不再直接 `this.blockData.set/delete` +- `Chunk` 不再直接写 `store._versions` +- `Chunk` 只负责派生层同步: + - `visibleKeys` + - `solidBlocks` + - `lightSourceCoords` + - `blockDataArray` + - `solidBlockIds` + - AO / tombstone / render delta + +- [ ] **Step 5: 把 `acceptScatteredBlocks()` / `appendScatteredBlocks()` 改为 store 原语调用方** + +要求: + +- 首次接收 scatter 时不直接改 shared Map +- 增量 patch 时也不直接改 shared Map +- scatter 的批量写入默认走 `WorldBlockDataStore.applyChunkPatch()` +- scatter / deferred patch 不允许遗漏 authority version bump +- 允许一次 patch 后再重建/增量同步派生层 + +- [ ] **Step 6: 运行测试确认写入口已经统一** + +Run: + +- 浏览器测试页运行 `test-world.js` +- 浏览器测试页运行 `test-world-runtime.js` + +Expected: + +- 新增 authority mutation 测试通过 +- 旧功能测试不回退 + +- [ ] **Step 7: 运行 lint** + +Run: + +- `npm run lint` + +Expected: + +- 无新增 error + +--- + +### Task 2: 让 scatter / cross-chunk patch 在未加载目标 chunk 上先入 authority + +**Files:** +- Modify: `src/world/BlockScatterManager.js` +- Modify: `src/world/Chunk.js` +- Modify: `src/world/World.js` +- Test: `src/tests/test-block-scatter-manager.js` +- Test: `src/tests/test-world.js` + +- [ ] **Step 1: 写失败测试,固定“未加载目标 chunk 也先写 authority”约束** + +测试目标: + +- `BlockScatterManager.scatter()` 命中未加载目标 chunk 时,`WorldBlockDataStore` 立刻有对应 slice / entry +- 目标 chunk 未来加载时,不依赖 source chunk 再次生成也能 attach/rebuild +- pending buffer 只表示待补刷的 render/derived work,而不是待写 truth + +- [ ] **Step 2: 运行测试确认当前实现仍只保留 pending buffer** + +Run: + +- 浏览器测试页运行 `test-block-scatter-manager.js` + +Expected: + +- 新增测试先失败,显示未加载目标 chunk 时 authority 未建立 + +- [ ] **Step 3: 重构 `BlockScatterManager` 的数据流** + +要求: + +- own chunk routing 结果:先写 authority,再通知 ready chunk 消费 +- overflow / deferred cross-chunk patch:目标 chunk 无论是否已加载,都先写 authority +- 不能仅以 `pendingCrossChunkPatchBuffers` 充当 truth 挂起区 +- `chunkBuffers` / `pendingCrossChunkPatchBuffers` 改为“派生层待装配队列” + +- [ ] **Step 4: 对齐 `Chunk.acceptScatteredBlocks()` / `appendDeferredCrossChunkPatch()` 的职责** + +要求: + +- loaded chunk 消费 scatter 时,默认 authority 已经存在 +- chunk 方法只做 attach 后的 rebuild / incremental derived sync +- 不再承担“顺便建立 truth”的职责 + +- [ ] **Step 5: 运行 scatter / world 测试** + +Run: + +- 浏览器测试页运行 `test-block-scatter-manager.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- 未加载目标 chunk authority 测试通过 +- 现有 scatter 行为不回退 + +--- + +### Task 3: 统一 cold import 路径,先入 authority 再 attach / rebuild + +**Files:** +- Modify: `src/world/World.js` +- Modify: `src/world/WorldRuntime.js` +- Modify: `src/world/Chunk.js` +- Modify: `src/world/WorldChunkRegistry.js` +- Modify: `src/world/WorldChunkPayloadRegistry.js` +- Test: `src/tests/test-runtime-session-persistence.js` +- Test: `src/tests/test-world.js` + +- [ ] **Step 1: 写失败测试,固定 imported chunk 与 generated chunk 的统一 authority 语义** + +测试目标: + +- `ensureChunkData()` 返回 chunkRecord 后,会先写 `WorldBlockDataStore` +- 同时写入 `WorldChunkPayloadRegistry` +- 同时标记 `WorldChunkRegistry.markChunkImported()` +- chunk reload 后从 authority attach/rebuild,而不是再次把 plain object 注入 chunk-local truth + +- [ ] **Step 2: 运行测试确认当前 imported 路径仍未进入 authority store** + +Run: + +- 浏览器测试页运行 `test-runtime-session-persistence.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- 新增测试先失败,显示 imported chunk 仍只活在 live chunk / chunkRecord 注入链路 + +- [ ] **Step 3: 在 `WorldRuntime.ensureChunkData()` 或等价编排点建立 authority import helper** + +要求: + +- plain object `blockData` 先反序列化为 `Map` +- 写入 `WorldBlockDataStore` +- 写入 payload registry +- 标记 chunk registry imported +- 再把“如何 attach/rebuild loaded chunk”交给 `World` + +- [ ] **Step 4: 收缩 `Chunk.loadFromRecord()` 的职责** + +要求: + +- 若保留此命名,只允许它作为冷边界编排/兼容壳层 +- 不再直接承担“把 record.blockData 注入当前 chunk truth” +- 优先走 attach existing authority slice + rebuild derived indexes + +- [ ] **Step 5: 运行 cold import 相关测试** + +Run: + +- 浏览器测试页运行 `test-runtime-session-persistence.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- imported / generated 两条路径的 authority 语义一致 + +- [ ] **Step 6: 运行 lint** + +Run: + +- `npm run lint` + +Expected: + +- 无新增 error + +--- + +### Task 4: 让 deprecated flush/save 链路彻底退出 runtime 热路径 + +**Files:** +- Modify: `src/world/WorldRuntime.js` +- Modify: `src/world/ChunkPersistence.js` +- Modify: `src/world/World.js` +- Modify: `src/core/Game.js` +- Test: `src/tests/test-world-runtime.js` +- Test: `src/tests/test-world.js` + +- [ ] **Step 1: 写失败测试,固定“玩家编辑后不再自动调度 flushChunk()”约束** + +测试目标: + +- `markChunkDirty()` 不再调 `_scheduleFlush()` +- `saveDebounced()` 不再调 `flushChunk()` +- `flushChunk()` 若保留,只能显式调用 + +- [ ] **Step 2: 运行测试确认旧 flush 调度链仍活跃** + +Run: + +- 浏览器测试页运行 `test-world-runtime.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- 新增测试先失败,显示自动 flush 仍被调度 + +- [ ] **Step 3: 收缩 `markChunkDirty()` / `_scheduleFlush()`** + +要求: + +- runtime dirty 只用于观测、导出标记或 future save hook +- 默认不再自动发起 `flushChunk()` +- 若保留 `_scheduleFlush()`,必须从 runtime 热路径解绑 + +- [ ] **Step 4: 收缩 `ChunkPersistence.saveDebounced()`** + +要求: + +- 明确标记 `@deprecated` +- 改为 no-op、dirty marker shell 或显式告警壳层 +- 不允许继续实际写 `WorldStore` + +- [ ] **Step 5: 为 `flushChunk()` / `flushBeforeUnload()` / `flushAllDirty()` 定义 shell 语义** + +要求: + +- 明确它们不再从 `RegionCache.blockData` 推导 live truth +- 若需要保留测试/未来导出能力,必须要求显式 snapshot / export source +- 不允许 silent fallback 到旧缓存数据覆盖 authority + +- [ ] **Step 6: 对齐 `collectSnapshot()` / `applySaveData()` 注释与边界** + +要求: + +- 若本轮不重写,必须显式标注 deferred / compatibility-only +- 不允许文档与实现继续处于语义模糊状态 + +- [ ] **Step 7: 运行退出型测试** + +Run: + +- 浏览器测试页运行 `test-world-runtime.js` +- 浏览器测试页运行 `test-world.js` + +Expected: + +- 玩家编辑、unload、reload 的当前会话闭环不依赖 flush/save + +- [ ] **Step 8: 运行 lint** + +Run: + +- `npm run lint` + +Expected: + +- 无新增 error + +--- + +### Task 5: 增加 shared authority guardrail 与迁移期观测 + +**Files:** +- Modify: `src/world/WorldBlockDataStore.js` +- Modify: `src/world/Chunk.js` +- Modify: `src/world/WorldRuntime.js` +- Test: `src/tests/test-world-runtime.js` + +- [ ] **Step 1: 写失败测试,固定 live attached slice 的防护约束** + +测试目标: + +- attached slice 上禁止整块 replace +- attached slice 上禁止通过旧 helper 执行 `clear + reinject` +- 直接改写 `Chunk.blockData = new Map()` 只允许发生在 detach/dispose 边界 + +- [ ] **Step 2: 在 store 中补 guardrail** + +要求: + +- 对 live attached slice 的整块 replace 保持显式告警或抛错 +- 增加开发期断言 helper,检查 authority slice identity 和 attach 状态 +- 记录 mutation / replace / fallback 路径统计 + +- [ ] **Step 3: 在 chunk 侧补 guardrail** + +要求: + +- 保留 `detachAuthoritySlice()` 作为唯一合法脱钩入口 +- 对旧 helper 增加注释或重命名,避免再次被误用为 truth injection + +- [ ] **Step 4: 在 runtime 侧补观测** + +要求: + +- 统计 `flushChunk()`、`flushBeforeUnload()`、`saveDebounced()`、`RegionCache` fallback 命中次数 +- 日志区分 authority mutation、cold import、derived rebuild、deferred export shell + +- [ ] **Step 5: 运行 guardrail 相关测试** + +Run: + +- 浏览器测试页运行 `test-world-runtime.js` + +Expected: + +- guardrail 生效,旧误用路径可被检测 + +--- + +### Task 6: 重写 authority 语义测试并完成最终回归 + +**Files:** +- Modify: `src/tests/test-world-runtime.js` +- Modify: `src/tests/test-world.js` +- Modify: `src/tests/test-runtime-session-persistence.js` +- Modify: `src/tests/test-block-scatter-manager.js` + +- [ ] **Step 1: 删除或改写仍然把旧 flush 行为当作“推荐语义”的测试** + +要求: + +- 不再把“dirty 时优先复用 region cache blockData”写成推荐行为 +- shell 相关测试若保留,必须明确它们只验证 deferred/export 兼容边界 + +- [ ] **Step 2: 增加 authority 新约定测试矩阵** + +至少覆盖: + +- 单块编辑走 store primitive +- scatter 写入会 bump authority version +- 未加载目标 chunk 的 patch 先入 authority +- imported chunk 先入 authority,再 attach/rebuild +- 关闭 flush/save 后 runtime unload/reload 仍正确 + +- [ ] **Step 3: 运行浏览器内测试全套回归** + +Run: + +- 启动 `npm run start` +- 访问 `http://localhost:8080/src/tests/index.html` +- 点击“运行所有测试” + +Expected: + +- 所有相关测试通过 + +- [ ] **Step 4: 手动验证 runtime-only 闭环** + +验证点: + +- 玩家改单块后 500ms 内不再自动出现 flush/save 热路径写盘 +- 卸载后 reload 能从 world-level authority 恢复 +- 跨 chunk scatter / deferred patch 不会因目标 chunk 未加载而丢失 +- 特殊实体行为不回退 + +- [ ] **Step 5: 运行 lint 作为最终门禁** + +Run: + +- `npm run lint` + +Expected: + +- 无新增 error + +- [ ] **Step 6: 记录最终结论** + +结论中必须明确: + +- 哪些入口已经完全统一到 authority mutation primitive +- 哪些 deprecated shell 还保留、但已退出热路径 +- 哪些 deferred 能力仍未重写 +- 为下一步性能优化留下了哪些可直接利用的前提 From 756ef595bba927e8b947223c5ef44349c20cf012 Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Sun, 10 May 2026 17:45:32 +0800 Subject: [PATCH 11/17] =?UTF-8?q?refactor(world):=20=E5=AE=8C=E6=88=90=20b?= =?UTF-8?q?lockData=20authority=20=E7=BB=9F=E4=B8=80=E6=94=B6=E5=B0=BE=20?= =?UTF-8?q?=E2=80=94=20=E5=86=99=E5=85=A5=E5=8F=A3=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E3=80=81cold=20import=20=E5=85=88=E5=85=A5=20authority?= =?UTF-8?q?=E3=80=81flush/save=20=E9=80=80=E5=87=BA=E7=83=AD=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorldBlockDataStore: setBlockEntry/deleteBlockEntry/applyChunkPatch 作为唯一合法 mutation primitive,统一 entry 规范化、version 递增与统计 - Chunk: _updateBlockState/acceptScatteredBlocks/appendScatteredBlocks 收敛为 store 编排调用方,不再直接操作 authority slice;hydrate 状态机清理死代码,_isAuthorityAttached 显式标志区分路径 - World: cold import 路径统一为 plain object → deserializeBlockData → replaceChunkSlice → payload registry → chunk registry → attach + rebuild - BlockScatterManager: scatter() 对未加载目标 chunk 先写入 authority,再保留 buffer 作为派生层队列 - WorldRuntime: markChunkDirty 不再自动调度 _scheduleFlush,deprecated flush 链路退出热路径 - ChunkPersistence: saveDebounced 退化为 no-op shell - 新增 guardrail: replaceChunkSlice attached 保护、_verifySliceIntegrity/_assertNotAttached 开发期断言、_callStats 迁移期路径命中计数 - 测试: 更新 flush/save 退出型测试,新增 4 个 guardrail 测试 + 6 个 authority 语义测试 Co-Authored-By: Claude Opus 4.7 --- ...-blockdata-authority-unification-design.md | 42 +++ src/core/Game.js | 3 + src/tests/test-chunk.js | 8 +- src/tests/test-world-runtime.js | 125 ++++++- src/tests/test-world.js | 110 ++++++ src/world/BlockScatterManager.js | 37 +- src/world/Chunk.js | 348 +++++++++++------- src/world/ChunkPersistence.js | 21 +- src/world/World.js | 85 ++++- src/world/WorldBlockDataStore.js | 52 ++- src/world/WorldRuntime.js | 23 +- 11 files changed, 671 insertions(+), 183 deletions(-) diff --git a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md index 2169ece..a37e2ad 100644 --- a/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md +++ b/docs/superpowers/specs/2026-05-07-blockdata-authority-unification-design.md @@ -2038,3 +2038,45 @@ shared authority `Map` 是本次改造中最容易“代码能跑,但语义又 7. 持久化层即使暂时缺席,也不影响 runtime 正确性 8. `Chunk` 的 attach / hydrate / rebuild 语义明确,不再把“复制 blockData 到 chunk 本地”误当成权威接入 9. 所有热路径写入都经过统一 mutation 原语,不再允许隐式直写共享 `Map` + +## 15. Follow-up Phase: 入口统一与旧热路径退出 + +> 本补充阶段(2026-05-10 follow-up)是首轮 authority unification 的收尾收口。首轮已建立 world-level authority 容器与共享 Map 装配协议,但仍有部分写入口、cold import 入口、scatter 未加载 chunk 写入路径未完全收编,且 deprecated flush/save 链路仍在热路径实际执行。 + +### 15.1 Follow-up 阶段边界 + +**重点:** +- 收拢剩余 blockData 写入口到统一 mutation primitive +- 让 scatter / cross-chunk patch 在目标 chunk 未加载时也先写入 world-level authority +- 让 cold import / `loadFromRecord()` 路径先入 authority,再 attach / rebuild +- 让 deprecated flush/save 链路退出 runtime 热路径 +- 增加 shared authority guardrail、观测与退出型测试 + +**不交付:** +- 新的 IndexedDB 持久化架构 +- 手动存档/手动读档的完整 authority-based rewrite +- TypedArray authority 存储 +- AO / mesh / face culling 的新一轮性能优化 + +本轮仍允许保留 deferred shell,但不允许继续实际参与 runtime 热路径。 + +### 15.2 Cold Import / Scatter / Unloaded-Target Patch 统一语义 + +**cold import 路径:** +- cold import 必须先入 authority,再 attach/rebuild +- `ensureChunkData()` / `loadFromRecord()` 返回的 chunkRecord 不再直接注入 chunk-local truth +- plain object `blockData` 先走 codec 转换为 `Map`,写入 `WorldBlockDataStore`,同时写入 `WorldChunkPayloadRegistry`,标记 `WorldChunkRegistry.markChunkImported()` + +**scatter / cross-chunk patch 路径:** +- scatter 对未加载目标 chunk 也必须先写 authority +- pending buffer 只能表示"派生层/渲染补刷未完成",不能表示"truth 尚未写入" +- `pendingCrossChunkPatchBuffers` 改为 authority patch 调度容器或派生层装配延迟队列 +- 目标 chunk 未来加载时,不依赖 source chunk 再次生成也能 attach/rebuild + +### 15.3 Deprecated Shell 允许边界 + +- `flushChunk()` / `flushBeforeUnload()` / `flushAllDirty()` 若保留,只能服务 future export / manual save,且必须要求显式调用来源 +- `saveDebounced()` 不能再驱动 runtime 正确性,改为 no-op 或 dirty marker shell +- `RegionCache.blockData` 不能再被 shell 当作 live truth fallback +- 旧 shell 即使保留,也不能再把 `RegionCache.blockData` 当 live truth 回写覆盖 authority +- 关闭 `flushChunk()` 后,edit -> unload -> reload 在当前会话内仍需保持正确 diff --git a/src/core/Game.js b/src/core/Game.js index 365994d..8f6ff10 100644 --- a/src/core/Game.js +++ b/src/core/Game.js @@ -788,6 +788,9 @@ export class Game { /** * 将保存的快照数据应用到当前游戏实例 + * @deferred 手动读档链路延后恢复。当前从 worldStore/IndexedDB 读取, + * 后续应迁移到基于 WorldBlockDataStore authority 的重新注入。 + * 本方法不是当前 runtime correctness 的组成部分。 */ async applySaveData(saveData) { if (!saveData) return; diff --git a/src/tests/test-chunk.js b/src/tests/test-chunk.js index 57d8c0d..44e779b 100644 --- a/src/tests/test-chunk.js +++ b/src/tests/test-chunk.js @@ -559,7 +559,7 @@ describe('Chunk 真实类测试', (test) => { teardownEnvironment(); }); - test('saveDebounced - bootstrapping 下应通过 worldRuntime 写回而不是旧 saveChunkData', async () => { + test('saveDebounced - bootstrapping 下已退化为 no-op,不再触发任何写回', async () => { setupEnvironment(); await new Promise(resolve => setTimeout(resolve, 650)); mockPersistenceService.calls = []; @@ -581,10 +581,8 @@ describe('Chunk 真实类测试', (test) => { call.method === 'saveChunkData' ); - assertEqual(saveCall, undefined, 'bootstrapping 下不应再通过旧 saveChunkData 写 world_deltas'); - assertEqual(flushCalls.length, 1, 'bootstrapping 下应通过 worldRuntime.flushChunk 写回'); - assertEqual(flushCalls[0].cx, 0, 'flushChunk 应写回正确 cx'); - assertEqual(flushCalls[0].cz, 0, 'flushChunk 应写回正确 cz'); + assertEqual(saveCall, undefined, 'saveDebounced 不再写旧 saveChunkData'); + assertEqual(flushCalls.length, 0, 'saveDebounced 退化为 no-op,不应触发 worldRuntime.flushChunk'); teardownEnvironment(); }); diff --git a/src/tests/test-world-runtime.js b/src/tests/test-world-runtime.js index 181dc02..7cc0a87 100644 --- a/src/tests/test-world-runtime.js +++ b/src/tests/test-world-runtime.js @@ -2,6 +2,7 @@ import { describe } from './runner.js'; import { assertDeepEqual, assertEqual, assertFalse, assertNotEqual, assertTrue } from './assert.js'; import { WorldRuntime } from '../world/WorldRuntime.js'; +import { WorldBlockDataStore } from '../world/WorldBlockDataStore.js'; import { encodeCoord } from '../utils/CoordEncoding.js'; describe('WorldRuntime 运行时工作集测试', (test) => { @@ -139,7 +140,7 @@ describe('WorldRuntime 运行时工作集测试', (test) => { globalThis._worldStore = originalWorldStore; }); - test('markChunkDirty - 应触发防抖 flush 并清除脏标记', async () => { + test('markChunkDirty - 仅标记 dirty 状态,不再自动触发防抖 flush', async () => { const originalWorldStore = globalThis._worldStore; const flushCalls = []; globalThis._worldStore = { @@ -165,22 +166,16 @@ describe('WorldRuntime 运行时工作集测试', (test) => { await new Promise((resolve) => setTimeout(resolve, 650)); - assertEqual(flushCalls.length, 1, '防抖 flush 应写回一次'); - assertEqual(flushCalls[0].cx, 0, 'flush 应写回正确的 cx'); - assertEqual(flushCalls[0].cz, 0, 'flush 应写回正确的 cz'); - assertFalse(runtime.isChunkDirty(0, 0), 'flush 后应清除 dirty 状态'); + assertEqual(flushCalls.length, 0, 'markChunkDirty 不再触发防抖 flush,flush 应由 authority 层管控'); + assertTrue(runtime.isChunkDirty(0, 0), 'dirty 状态保留,由 authority 层在持久化时清除'); globalThis._worldStore = originalWorldStore; }); - test('markChunkDirty - 已调度的 flush 不应受后续全局 worldStore 替换影响', async () => { + test('markChunkDirty - 仅标记 dirty,不涉及 worldStore 调度绑定', async () => { const originalWorldStore = globalThis._worldStore; - const storeACalls = []; - const storeBCalls = []; - globalThis._worldStore = { putChunkRecord: async (cx, cz, record) => { - storeACalls.push({ cx, cz, record }); return true; } }; @@ -197,9 +192,9 @@ describe('WorldRuntime 运行时工作集测试', (test) => { }); runtimeA.markChunkDirty(0, 0); + // 替换全局 worldStore globalThis._worldStore = { putChunkRecord: async (cx, cz, record) => { - storeBCalls.push({ cx, cz, record }); return true; } }; @@ -216,16 +211,112 @@ describe('WorldRuntime 运行时工作集测试', (test) => { }); runtimeB.markChunkDirty(1, 0); - await new Promise((resolve) => setTimeout(resolve, 650)); - - assertEqual(storeACalls.length, 1, 'runtimeA 应继续写回创建时绑定的 worldStore'); - assertEqual(storeACalls[0].cx, 0, 'runtimeA 应写回正确 chunk'); - assertEqual(storeBCalls.length, 1, 'runtimeB 应只写回到新的 worldStore'); - assertEqual(storeBCalls[0].cx, 1, 'runtimeB 应写回正确 chunk'); + // markChunkDirty 不再调度 flush,dirty 标志不受 worldStore 替换影响 + assertTrue(runtimeA.isChunkDirty(0, 0), 'runtimeA 的 dirty 状态独立保留'); + assertTrue(runtimeB.isChunkDirty(1, 0), 'runtimeB 的 dirty 状态独立保留'); globalThis._worldStore = originalWorldStore; }); + test('callStats - 迁移期 deprecated shell 调用计数正确递增', () => { + const runtime = new WorldRuntime(); + runtime.setWorld({ + chunks: new Map([ + ['0,0', { + blockData: new Map([[123, { type: 'stone', orientation: 0 }]]), + staticEntities: [], + runtimeSeedData: {} + }] + ]) + }); + + // 初始状态:所有调用计数为 0 + const initial = runtime.getStats().callStats; + assertEqual(initial.recordBlockMutation, 0, '初始 recordBlockMutation'); + assertEqual(initial.flushChunk, 0, '初始 flushChunk'); + assertEqual(initial.flushAllDirty, 0, '初始 flushAllDirty'); + assertEqual(initial.flushBeforeUnload, 0, '初始 flushBeforeUnload'); + assertEqual(initial.scheduleFlush, 0, '初始 scheduleFlush'); + + // markChunkDirty 不应再触发 scheduleFlush + runtime.markChunkDirty(0, 0); + const afterMarkDirty = runtime.getStats().callStats; + assertEqual(afterMarkDirty.scheduleFlush, 0, + 'markChunkDirty 不应触发 _scheduleFlush(已退出热路径)'); + + // 显式调用 deprecated shell 应递增计数 + runtime._scheduleFlush(0, 0, 1); + const afterSchedule = runtime.getStats().callStats; + assertEqual(afterSchedule.scheduleFlush, 1, + '显式 _scheduleFlush 应递增 scheduleFlush 计数'); + }); + + test('store guardrail - replaceChunkSlice 对 attached slice 拒绝写入并递增计数器', () => { + const store = new WorldBlockDataStore(); + const slice = store.ensureChunkSlice(0, 0); + store.markAttached(0, 0); + + // 保存告警以便恢复 + const warnCalls = []; + const originalWarn = console.warn; + console.warn = (...args) => { warnCalls.push(args); }; + + const newSlice = new Map([[123, { type: 'dirt', orientation: 0 }]]); + store.replaceChunkSlice(0, 0, newSlice, 'test-guardrail'); + + console.warn = originalWarn; + + // replace 被拒绝,slice 未变化 + assertTrue(store._slices.get('0,0') === slice, + 'attached slice 不应被 replace(身份不变)'); + assertEqual(store._callStats.replaceOnAttached, 1, + 'replaceOnAttached 计数应递增'); + assertTrue(warnCalls.length >= 1, + '应输出 guardrail 告警'); + }); + + test('store guardrail - replaceChunkSlice 对 non-Map 输入拒绝并递增计数器', () => { + const store = new WorldBlockDataStore(); + store.ensureChunkSlice(0, 0); + + const warnCalls = []; + const originalWarn = console.warn; + console.warn = (...args) => { warnCalls.push(args); }; + + store.replaceChunkSlice(0, 0, { 123: 'stone' }, 'test-nonmap'); + + console.warn = originalWarn; + + assertEqual(store._callStats.nonMapReplace, 1, + 'nonMapReplace 计数应递增'); + assertTrue(warnCalls.length >= 1, + '应输出 non-Map 告警'); + }); + + test('store _verifySliceIntegrity - 异常切片触发 integrityWarn 计数', () => { + const store = new WorldBlockDataStore(); + + // 缺失 slice + const ok1 = store._verifySliceIntegrity(0, 0, 'test-integrity'); + assertTrue(!ok1, '缺失切片应返回 false'); + assertEqual(store._callStats.integrityWarn, 1, + '缺失切片应触发 integrityWarn'); + + // 非 Map slice(模拟异常状态) + store._slices.set('0,0', { foo: 'bar' }); + const ok2 = store._verifySliceIntegrity(0, 0, 'test-integrity'); + assertTrue(!ok2, '非 Map 切片应返回 false'); + assertEqual(store._callStats.integrityWarn, 2, + '非 Map 切片应再次触发 integrityWarn'); + + // 正常 slice + store._slices.set('0,0', new Map()); + const ok3 = store._verifySliceIntegrity(0, 0, 'test-integrity'); + assertTrue(ok3, '正常切片应返回 true'); + assertEqual(store._callStats.integrityWarn, 2, + '正常切片不应再递增 integrityWarn'); + }); + test('recordBlockMutation - 只标记 runtime dirty,不再构造 blockDataSnapshot', () => { const baseCode = encodeCoord(1, 2, 3); const runtime = new WorldRuntime(); diff --git a/src/tests/test-world.js b/src/tests/test-world.js index ed787e0..ce2842e 100644 --- a/src/tests/test-world.js +++ b/src/tests/test-world.js @@ -18,6 +18,7 @@ import { PERSISTENCE_CONFIG } from '../constants/PersistenceConfig.js'; import { mockFaceCullingSystem, mockMaterials, mockBlockData } from './test-mocks.js'; import { Chunk } from '../world/Chunk.js'; import { ChunkAssemblyScheduler } from '../world/ChunkAssemblyScheduler.js'; +import { WorldBlockDataStore } from '../world/WorldBlockDataStore.js'; // ============================================ // Worker 模拟 - 在导入 World 之前设置 @@ -1523,4 +1524,113 @@ describe('World 真实类测试', (test) => { assertEqual(Math.floor(-16 / CHUNK_SIZE), -1, 'x=-16 在区块 -1'); }); + // ============================================================ + // authority 语义测试:WorldBlockDataStore 作为 blockData 唯一权威 + // ============================================================ + + test('authority - deserializeBlockData / serializeBlockData codec 往返', () => { + const plain = { '123': 'stone', '456': { type: 'dirt', orientation: 2 }, '789': 'air' }; + const map = WorldBlockDataStore.deserializeBlockData(plain); + assertTrue(map instanceof Map, 'deserialize 应返回 Map'); + assertEqual(map.size, 3, '应包含 3 个 entry'); + assertEqual(map.get(123).type, 'stone', 'string value 应规范化为 entry'); + assertEqual(map.get(123).orientation, 0, 'string value 默认 orientation=0'); + assertEqual(map.get(456).type, 'dirt', 'object entry 保留 type'); + assertEqual(map.get(456).orientation, 2, 'object entry 保留 orientation'); + assertEqual(map.get(789).type, 'air', 'air 类型也保留'); + + const roundtrip = WorldBlockDataStore.serializeBlockData(map); + assertEqual(typeof roundtrip, 'object', 'serialize 应返回 plain object'); + assertEqual(roundtrip['123'].type, 'stone', '往返后 type 保留'); + assertEqual(roundtrip['456'].orientation, 2, '往返后 orientation 保留'); + + // 空输入 + const emptyMap = WorldBlockDataStore.deserializeBlockData(null); + assertEqual(emptyMap.size, 0, 'null 应返回空 Map'); + const emptyObj = WorldBlockDataStore.serializeBlockData(null); + assertEqual(Object.keys(emptyObj).length, 0, 'null 应返回空 object'); + }); + + test('authority - setBlockEntry / deleteBlockEntry 递增 version', () => { + const store = new WorldBlockDataStore(); + + assertEqual(store.getAuthorityVersion(0, 0), 0, '未创建 slice 时 version=0'); + + store.setBlockEntry(0, 0, 123, 'stone'); + assertEqual(store.getAuthorityVersion(0, 0), 1, '首次 set 后 version=1'); + assertEqual(store.peekChunkSlice(0, 0).get(123).type, 'stone', 'entry 已写入'); + + store.deleteBlockEntry(0, 0, 123); + assertEqual(store.getAuthorityVersion(0, 0), 2, 'delete 后 version=2'); + assertTrue(!store.peekChunkSlice(0, 0).has(123), 'entry 已删除'); + + assertEqual(store.stats.totalMutations, 2, 'mutations 统计正确'); + }); + + test('authority - applyChunkPatch 批量写入只递增一次 version', () => { + const store = new WorldBlockDataStore(); + const patches = new Map([ + [111, 'stone'], + [222, { type: 'dirt', orientation: 1 }], + [333, null] // null 表示删除(在尚未创建 slice 时不执行删除) + ]); + + store.applyChunkPatch(0, 0, patches); + assertEqual(store.getAuthorityVersion(0, 0), 1, '批量 patch 只递增一次 version'); + assertEqual(store.peekChunkSlice(0, 0).size, 2, '2 个有效写入(null 不写入)'); + assertEqual(store.peekChunkSlice(0, 0).get(111).type, 'stone', 'patch 1 写入'); + assertEqual(store.peekChunkSlice(0, 0).get(222).orientation, 1, 'patch 2 写入'); + assertEqual(store.stats.totalMutations, 1, 'batch mutation 计数为 1'); + }); + + test('authority - attach/detach 生命周期与身份一致性', () => { + const store = new WorldBlockDataStore(); + const slice = store.ensureChunkSlice(0, 0); + slice.set(123, { type: 'stone', orientation: 0 }); + + assertFalse(store.isAttached(0, 0), '初始未 attach'); + + store.markAttached(0, 0); + assertTrue(store.isAttached(0, 0), 'markAttached 后应返回 true'); + + // attached 状态下 replace 被拒绝 + const newSlice = new Map([[456, { type: 'dirt', orientation: 0 }]]); + store.replaceChunkSlice(0, 0, newSlice, 'test-attach-guard'); + assertTrue(store.peekChunkSlice(0, 0) === slice, 'attached 状态下 replace 被拒绝,slice 身份不变'); + + store.markDetached(0, 0); + assertFalse(store.isAttached(0, 0), 'markDetached 后应返回 false'); + + // detach 后 replace 允许 + store.replaceChunkSlice(0, 0, newSlice, 'test-attach'); + assertTrue(store.peekChunkSlice(0, 0) === newSlice, 'detach 后 replace 生效'); + }); + + test('authority - stats 统计完整反映 authority 生命周期', () => { + const store = new WorldBlockDataStore(); + + store.ensureChunkSlice(0, 0); + store.ensureChunkSlice(1, 0); + store.peekChunkSlice(2, 0); // miss + + const stats = store.getStats(); + assertEqual(stats.sliceCount, 2, '2 个 slice'); + assertEqual(stats.writes, 2, '2 次 ensure 写入'); + assertEqual(stats.misses, 1, '1 次 peek miss'); + assertEqual(stats.attachedCount, 0, '无 attach'); + }); + + test('authority - _verifySliceIntegrity 检测异常切片状态', () => { + const store = new WorldBlockDataStore(); + + // 缺失 + assertFalse(store._verifySliceIntegrity(0, 0, 'test'), '缺失 slice 返回 false'); + assertEqual(store._callStats.integrityWarn, 1, '缺失触发告警计数'); + + // 正常 + store.ensureChunkSlice(0, 0); + assertTrue(store._verifySliceIntegrity(0, 0, 'test'), '正常 slice 返回 true'); + assertEqual(store._callStats.integrityWarn, 1, '正常不递增告警计数'); + }); + }); diff --git a/src/world/BlockScatterManager.js b/src/world/BlockScatterManager.js index 16635c1..58f7ec2 100644 --- a/src/world/BlockScatterManager.js +++ b/src/world/BlockScatterManager.js @@ -190,6 +190,9 @@ export class BlockScatterManager { // 记录本次触达的 chunk keys,替代原先 touchedBuffers 的反向查找 const touchedKeys = new Set(); + // 收集跨 chunk 未加载目标的 authority patches + const unloadedCrossChunkPatches = new Map(); // chunkKey -> Map + // 1. 遍历所有逻辑方块,按坐标分发 for (const block of blockDataBlocks) { const chunkCx = Math.floor(block.x / CHUNK_SIZE); @@ -224,9 +227,38 @@ export class BlockScatterManager { if (visibleKeysSet?.has(blockKey)) { buffer.visibleBlockKeys.add(blockKey); } + + // 跨 chunk 且目标未加载:同时收集到 authority patch Map + if (!isOwnChunk) { + const existingChunk = this.world.chunks.get(chunkKey); + if (!existingChunk || !existingChunk.isReady) { + const code = encodeCoord(block.x, block.y, block.z); + const entry = block.orientation !== 0 ? { type: block.type, orientation: block.orientation } : block.type; + let chunkPatches = unloadedCrossChunkPatches.get(chunkKey); + if (!chunkPatches) { + chunkPatches = new Map(); + unloadedCrossChunkPatches.set(chunkKey, chunkPatches); + } + chunkPatches.set(code, entry); + } + } } const t1 = globalThis.performance?.now?.() ?? Date.now(); + // 对未加载跨 chunk 目标,先写 authority(buffer 仍保留供未来渲染补刷) + const blockStore = this.world?.worldBlockDataStore; + if (blockStore && unloadedCrossChunkPatches.size > 0) { + for (const [chunkKey, patches] of unloadedCrossChunkPatches) { + const [tCx, tCz] = chunkKey.split(',').map(Number); + blockStore.applyChunkPatch(tCx, tCz, patches); + // 标记 chunk registry 为 known(若非空) + if (this.world.worldChunkRegistry && !this.world.worldChunkRegistry.hasKnownChunk(tCx, tCz)) { + this.world.worldChunkRegistry.markChunkKnown(tCx, tCz, { source: 'scatter' }); + } + } + } + const t1b = globalThis.performance?.now?.() ?? Date.now(); + // 确保发起 Worker 的 chunk 即使没有方块,也有 buffer 承载 ready 状态和结构中心。 const ownKey = `${cx},${cz}`; const ownBuffer = this._getOrCreateBuffer(this.chunkBuffers, ownKey); @@ -250,7 +282,8 @@ export class BlockScatterManager { recordChunkPerf('block-scatter.scatter', t2 - t0, { sourceChunkKey: `${cx},${cz}`, distributeMs: t1 - t0, - flushReadyChunksMs: t2 - t1, + authorityWriteMs: t1b - t1, + flushReadyChunksMs: t2 - t1b, blockDataBlocks: blockDataBlocks.length, scatteredBlocks: scatteredBlocks.length, touchedKeys: touchedKeys.size, @@ -412,7 +445,7 @@ export class BlockScatterManager { if (!buffer) continue; if (!chunk) { - // 目标 chunk 尚未加载,保留 pending buffer 等待加载 + // 目标 chunk 尚未加载:authority 已在 scatter() 中写入,buffer 保留供未来渲染补刷 continue; } if (chunk.disposed) { diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 84fd140..2e58c05 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -306,6 +306,7 @@ export class Chunk { /** * 将 Chunk.blockData 挂接到 WorldBlockDataStore 的共享 authority slice * 此后 this.blockData 与 store 内部 slice 是同一个 Map 实例 + * 设置 _isAuthorityAttached 标志,供 assembleRuntimeHydratePhase 区分路径 * @returns {boolean} 是否成功 attach */ attachAuthoritySlice() { @@ -322,6 +323,10 @@ export class Chunk { // 递增 assembly epoch,使旧异步回包失效 this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; + // 显式标记 authority 已挂载,供 runtime-hydrate 阶段区分路径: + // authority 路径不需要 _pendingChunkRecord,也不执行 blockData 注入 + this._isAuthorityAttached = true; + return true; } @@ -496,8 +501,17 @@ export class Chunk { } /** - * 从缓存的 chunkRecord 中提取数据并注入到 chunk 数据结构 - * 供细粒度 runtime-hydrate stage 调用 + * 运行期 hydration 阶段(可中断) + * 职责:将 blockData 注入 chunk 数据结构并重建派生索引 + * + * 两条路径通过 _isAuthorityAttached 标志区分: + * - authority attach 路径:World._requestRuntimeChunkRecord 已调用 attachAuthoritySlice() + + * rebuildDerivedIndexesFromAuthority() + 恢复 non-block payload,此处只需处理 + * runtimeEntities 迁移并跳过 blockData 注入 + * - cold import 路径:_pendingChunkRecord 中有完整的 chunkRecord,需分批注入 blockData + * 并重建派生索引 + * + * @returns {'done' | 'continue' | boolean} */ assembleRuntimeHydratePhase() { if (this.loadState !== 'record-ready') { @@ -505,9 +519,105 @@ export class Chunk { this.loadState === 'entities-built' || this.loadState === 'finalized'; } - this._loadFromCachedRecord(); - this.loadState = 'hydrated'; - return true; + // --- 路径 A:authority attach 路径 --- + // World._requestRuntimeChunkRecord 已调用 attachAuthoritySlice() + + // rebuildDerivedIndexesFromAuthority() + 恢复 staticEntities/structureCenters, + // 此处只需处理 runtimeEntities 迁移,无需注入 blockData + if (this._isAuthorityAttached) { + this._isAuthorityAttached = false; + + // 合并/标记 runtimeEntities(对齐 cold import 路径的尾部逻辑) + const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + const hasLiveShadowEntities = ( + liveShadowEntities.turrets?.length > 0 || + liveShadowEntities.zombieNests?.length > 0 || + liveShadowEntities.minecarts?.length > 0 + ); + if (!hasLiveShadowEntities) { + specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { + turrets: [], zombieNests: [], minecarts: [] + }); + } + this._needsEntityMigration = false; + this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + + this.pendingSnapshot = null; + this._isPureLoadPath = true; + this._pendingChunkRecord = null; + this.loadState = 'hydrated'; + return 'done'; + } + + // --- 路径 B:cold import 路径 --- + // _pendingChunkRecord 中有完整的 chunkRecord(来自 loadFromRecord 或 cold boundary), + // 需要分批注入 blockData 并重建派生索引 + const chunkRecord = this._pendingChunkRecord; + const effectiveBlockData = chunkRecord?.blockData || {}; + + // 首次调用:初始化 + if (!this._assemblyProgress?.hydrate) { + if (Object.keys(effectiveBlockData).length > 0) { + this._clearForBlockInjection(effectiveBlockData); + } else { + // 没有 blockData 需要注入,直接跳到尾部逻辑 + if (chunkRecord?.staticEntities?.length > 0) { + this._injectStaticEntities(chunkRecord.staticEntities); + } + if (chunkRecord?.runtimeSeedData?.structureCenters) { + this.structureCenters = chunkRecord.runtimeSeedData.structureCenters; + } + this.pendingSnapshot = null; + this._isPureLoadPath = true; + this._pendingChunkRecord = null; + this.loadState = 'hydrated'; + return 'done'; + } + } + + const result = this._injectBlockDataBatch(3); + if (result === 'done') { + if (chunkRecord?.staticEntities?.length > 0) { + this._injectStaticEntities(chunkRecord.staticEntities); + } + if (chunkRecord?.runtimeSeedData?.structureCenters) { + this.structureCenters = chunkRecord.runtimeSeedData.structureCenters; + } + this.pendingSnapshot = null; + this._isPureLoadPath = true; + + const hasRuntimeEntities = chunkRecord?.runtimeEntities && ( + chunkRecord.runtimeEntities.turrets?.length > 0 || + chunkRecord.runtimeEntities.zombieNests?.length > 0 || + chunkRecord.runtimeEntities.minecarts?.length > 0 + ); + + if (hasRuntimeEntities) { + specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, chunkRecord.runtimeEntities); + this._needsEntityMigration = false; + } else { + const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + const hasLiveShadowEntities = ( + liveShadowEntities.turrets?.length > 0 || + liveShadowEntities.zombieNests?.length > 0 || + liveShadowEntities.minecarts?.length > 0 + ); + if (!hasLiveShadowEntities) { + specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { + turrets: [], + zombieNests: [], + minecarts: [] + }); + } + this._needsEntityMigration = false; + } + + this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + this._pendingChunkRecord = null; + this._assemblyProgress = null; + this.loadState = 'hydrated'; + } + + return result; } /** @@ -527,7 +637,8 @@ export class Chunk { /** * 可中断装配:清空派生索引并初始化 hydrate 游标 - * 注意:shared authority view 下不再清空 this.blockData + * 注意:shared authority view 下严禁清空 this.blockData,只清空派生索引层 + * 本方法仅用于 cold import 路径,不参与 authority attach 路径 * @param {object} blockData - 原始 blockData 对象(仅用于计算游标进度) */ _clearForBlockInjection(blockData) { @@ -551,7 +662,9 @@ export class Chunk { } /** - * 可中断装配:分批注入 blockData + * 可中断装配:从 cold import plain object 分批重建派生索引 + * 职责:将 blockData 条目写入 this.blockData(shared Map)并同步更新派生索引, + * 仅供 cold import 路径的 assembleRuntimeHydratePhase 调用 * @param {number} maxMs - 时间预算(毫秒) * @returns {'done' | 'continue'} */ @@ -604,87 +717,9 @@ export class Chunk { } /** - * 运行期 hydration 阶段(可中断) - * @returns {'done' | 'continue' | boolean} - */ - assembleRuntimeHydratePhase() { - if (this.loadState !== 'record-ready') { - return this.loadState === 'hydrated' || this.loadState === 'terrain-built' || - this.loadState === 'entities-built' || this.loadState === 'finalized'; - } - - const chunkRecord = this._pendingChunkRecord; - const effectiveBlockData = chunkRecord?.blockData || {}; - - // 首次调用:初始化 - if (!this._assemblyProgress?.hydrate) { - if (Object.keys(effectiveBlockData).length > 0) { - this._clearForBlockInjection(effectiveBlockData); - } else { - // 没有 blockData 需要注入,直接跳到尾部逻辑 - if (chunkRecord?.staticEntities?.length > 0) { - this._injectStaticEntities(chunkRecord.staticEntities); - } - if (chunkRecord?.runtimeSeedData?.structureCenters) { - this.structureCenters = chunkRecord.runtimeSeedData.structureCenters; - } - this.pendingSnapshot = null; - this._isPureLoadPath = true; - this._pendingChunkRecord = null; - this.loadState = 'hydrated'; - return 'done'; - } - } - - const result = this._injectBlockDataBatch(3); - if (result === 'done') { - // 执行 _loadFromCachedRecord 的尾部逻辑 - if (chunkRecord?.staticEntities?.length > 0) { - this._injectStaticEntities(chunkRecord.staticEntities); - } - if (chunkRecord?.runtimeSeedData?.structureCenters) { - this.structureCenters = chunkRecord.runtimeSeedData.structureCenters; - } - this.pendingSnapshot = null; - this._isPureLoadPath = true; - - const hasRuntimeEntities = chunkRecord?.runtimeEntities && ( - chunkRecord.runtimeEntities.turrets?.length > 0 || - chunkRecord.runtimeEntities.zombieNests?.length > 0 || - chunkRecord.runtimeEntities.minecarts?.length > 0 - ); - - if (hasRuntimeEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, chunkRecord.runtimeEntities); - this._needsEntityMigration = false; - } else { - const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); - const hasLiveShadowEntities = ( - liveShadowEntities.turrets?.length > 0 || - liveShadowEntities.zombieNests?.length > 0 || - liveShadowEntities.minecarts?.length > 0 - ); - if (!hasLiveShadowEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { - turrets: [], - zombieNests: [], - minecarts: [] - }); - } - this._needsEntityMigration = false; - } - - this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); - this._pendingChunkRecord = null; - this._assemblyProgress = null; - this.loadState = 'hydrated'; - } - - return result; - } - - /** - * 从 _pendingChunkRecord 中提取数据并注入(旧版同步路径,供非 scheduler 场景使用) + * 从 _pendingChunkRecord 中提取数据并注入(同步路径,供非 scheduler 场景使用) + * 仅用于 loadFromRecord 的孤立/测试同步降级路径, + * 在正常 runtime 中由 assembleRuntimeHydratePhase 分帧执行 */ _loadFromCachedRecord() { const chunkRecord = this._pendingChunkRecord; @@ -737,8 +772,13 @@ export class Chunk { } /** - * 从 plain object 注入 blockData 并重建所有派生结构 + * 从 plain object 重建 this.blockData 条目并重建所有派生索引 * 注意:shared authority view 下不清空 this.blockData,只写入/更新条目并重建派生索引 + * + * 职责边界: + * - 同步路径冷导入 helper:供 _loadFromCachedRecord / loadFromRecord 同步降级路径使用 + * - 不承担 runtime 主链路中分帧注入 blockData 的职责(由 _injectBlockDataBatch 承担) + * - 不参与 authority attach 路径(authority 路径由 attachAuthoritySlice + rebuildDerivedIndexesFromAuthority 完成) */ _injectBlockData(blockData) { const t0 = globalThis.performance?.now?.() ?? Date.now(); @@ -1205,27 +1245,44 @@ export class Chunk { const code = Chunk.encodeCoord(x, y, z); this.world?.scatterManager?.invalidatePendingBlock?.(x, y, z); - // === blockData(权威存储,shared authority view 下直接命中 world-level store) === + // === blockData 权威存储:优先通过 store mutation primitive === + const blockStore = this.world?.worldBlockDataStore; + if (blockStore) { + // 延迟 attach:确保 this.blockData 指向 store 的共享 slice + if (!blockStore.isAttached(this.cx, this.cz)) { + this.blockData = blockStore.ensureChunkSlice(this.cx, this.cz); + blockStore.markAttached(this.cx, this.cz); + this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; + } + // 通过 store 原语写 authority(内部统一处理 entry 规范化 + version 递增 + 统计) + if (type === 'air') { + blockStore.deleteBlockEntry(this.cx, this.cz, code); + } else { + blockStore.setBlockEntry(this.cx, this.cz, code, entry); + } + } else { + // 降级路径:无 store 时直接写 this.blockData(测试夹具/无 authority 环境兼容) + if (type === 'air') { + this.blockData.delete(code); + } else { + this.blockData.set(code, entry); + } + } + if (type === 'air') { this.deletedBlockTombstones.add(code); - this.blockData.delete(code); this.visibleKeys.delete(code); // 同步到 AO Worker 副本 aoBridge.enqueueDelete(`${this.cx},${this.cz}`, code); } else { this.deletedBlockTombstones.delete(code); - this.blockData.set(code, entry); this.visibleKeys.add(code); // 同步到 AO Worker 副本 aoBridge.enqueueSet(`${this.cx},${this.cz}`, code, entry); } - // authority version:shared view 模式下通过 store 递增 - const blockStore = this.world?.worldBlockDataStore; - if (blockStore?.isAttached(this.cx, this.cz)) { - blockStore._versions?.set(blockStore.chunkKey(this.cx, this.cz), - (blockStore.getAuthorityVersion(this.cx, this.cz) || 0) + 1); - } + // 注意:authority version 在 store 路径下已由 setBlockEntry/deleteBlockEntry 内部递增, + // 降级路径(无 store)无 version 递增需求 // 更新碰撞体集合 const props = getBlockProps(type); @@ -3429,10 +3486,11 @@ export class Chunk { if (!this.solidBlocks) this.solidBlocks = new Set(); if (!this.lightSourceCoords) this.lightSourceCoords = new Set(); + // 收集 patches,通过 store mutation primitive 批量写入 authority + const patches = new Map(); for (const block of scatteredBlocks) { const localX = block.x - minX; const localZ = block.z - minZ; - // 只处理属于本 chunk 范围的方块 if (localX < 0 || localX >= CHUNK_SIZE || localZ < 0 || localZ >= CHUNK_SIZE) { continue; } @@ -3440,21 +3498,42 @@ export class Chunk { const code = Chunk.encodeCoord(block.x, block.y, block.z); if (this.deletedBlockTombstones.has(code)) continue; - // 写入 blockData(唯一真相源) const entry = block.orientation !== 0 ? { type: block.type, orientation: block.orientation } : block.type; - this.blockData.set(code, entry); + patches.set(code, entry); + } - // 写入 solidBlocks - const props = getBlockProps(block.type); - if (props.isSolid) { - this.solidBlocks.add(code); + // 批量写入 authority(优先通过 store mutation primitive) + const blockStore = this.world?.worldBlockDataStore; + if (blockStore) { + // 延迟 attach:确保 this.blockData 指向 store 的共享 slice + if (!blockStore.isAttached(this.cx, this.cz)) { + this.blockData = blockStore.ensureChunkSlice(this.cx, this.cz); + blockStore.markAttached(this.cx, this.cz); + this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; } - // 同步光源索引 - this.lightSourceCoords.delete(code); - if (props.isLightSource) { - this.lightSourceCoords.add(code); + if (patches.size > 0) { + blockStore.applyChunkPatch(this.cx, this.cz, patches); + } + } else if (patches.size > 0) { + // 降级路径:无 store 时直接写 this.blockData + for (const [code, entry] of patches) { + this.blockData.set(code, entry); } } + + // 从 authority 重建派生索引(_initArrayStorageFromBlockData 负责 blockDataArray/blockPalette/solidBlockIds) + this._initArrayStorageFromBlockData(); + + // 重建 solidBlocks 与 lightSourceCoords(_initArrayStorageFromBlockData 不处理这两个 Set) + this.solidBlocks.clear(); + this.lightSourceCoords.clear(); + for (const [code, entry] of this.blockData) { + const type = typeof entry === 'string' ? entry : (entry?.type || ''); + if (type === 'air') continue; + const props = getBlockProps(type); + if (props.isSolid) this.solidBlocks.add(code); + if (props.isLightSource) this.lightSourceCoords.add(code); + } const t1 = globalThis.performance?.now?.() ?? Date.now(); // 构建可见方块编码集合,供 mesh 构建时过滤隐藏方块 @@ -3482,9 +3561,7 @@ export class Chunk { } const t3 = globalThis.performance?.now?.() ?? Date.now(); - // 初始化数组存储 - this._initArrayStorageFromBlockData(); - const t4 = globalThis.performance?.now?.() ?? Date.now(); + // _initArrayStorageFromBlockData 已在 authority patch 写入后调用,此处不需要重复 // 优先消费 Worker 预构建的 meshData,主线程仅保留回退转换路径。 const meshData = Array.isArray(workerMeshData) @@ -3508,10 +3585,10 @@ export class Chunk { blockDataSize: this.blockData.size, meshGroups: meshData?.length || 0, writeBlockDataMs: t1 - t0, + // t1 包含了 authority patch 写入 + _initArrayStorageFromBlockData visibleKeysMs: t2 - t1, mergeStructureCentersMs: t3 - t2, - initArrayStorageMs: t4 - t3, - convertMeshDataMs: t5 - t4, + convertMeshDataMs: t5 - t3, buildMeshesMs: t6 - t5, notifyWorldMs: t7 - t6 }); @@ -3538,12 +3615,13 @@ export class Chunk { this._mergeStructureCenters(structureCenters); } + // 收集 patches,通过 store mutation primitive 批量写入 authority + const patches = new Map(); let appendedCount = 0; for (const block of scatteredBlocks) { const localX = block.x - minX; const localZ = block.z - minZ; - // 只处理属于本 chunk 范围的方块 if (localX < 0 || localX >= CHUNK_SIZE || localZ < 0 || localZ >= CHUNK_SIZE) { continue; } @@ -3554,22 +3632,40 @@ export class Chunk { // 跳过已存在的方块,尊重玩家修改或已有数据 if (this.blockData.has(code)) continue; - // 写入 blockData const entry = block.orientation !== 0 ? { type: block.type, orientation: block.orientation } : block.type; - this.blockData.set(code, entry); + patches.set(code, entry); + appendedCount++; + } - // 写入 solidBlocks - const props = getBlockProps(block.type); - if (props.isSolid) { - this.solidBlocks.add(code); + // 批量写入 authority(优先通过 store mutation primitive) + const blockStore = this.world?.worldBlockDataStore; + if (blockStore) { + // 延迟 attach:确保 this.blockData 指向 store 的共享 slice + if (!blockStore.isAttached(this.cx, this.cz)) { + this.blockData = blockStore.ensureChunkSlice(this.cx, this.cz); + blockStore.markAttached(this.cx, this.cz); + this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; } - // 同步光源索引 - this.lightSourceCoords.delete(code); - if (props.isLightSource) { - this.lightSourceCoords.add(code); + if (patches.size > 0) { + blockStore.applyChunkPatch(this.cx, this.cz, patches); + } + } else if (patches.size > 0) { + // 降级路径:无 store 时直接写 this.blockData + for (const [code, entry] of patches) { + this.blockData.set(code, entry); } + } - appendedCount++; + // 从 patches 增量更新派生索引 solidBlocks 与 lightSourceCoords + if (patches.size > 0) { + for (const [code] of patches) { + const entry = this.blockData.get(code); + const type = typeof entry === 'string' ? entry : (entry?.type || ''); + if (type === 'air') continue; + const props = getBlockProps(type); + if (props.isSolid) this.solidBlocks.add(code); + if (props.isLightSource) this.lightSourceCoords.add(code); + } } // 从 visibleBlockKeys 追加可见标记 diff --git a/src/world/ChunkPersistence.js b/src/world/ChunkPersistence.js index 1ef9276..8ab4e03 100644 --- a/src/world/ChunkPersistence.js +++ b/src/world/ChunkPersistence.js @@ -1,21 +1,14 @@ export function extendChunk(Chunk) { /** - * 防抖保存区块数据 + * @deprecated runtime 自动持久化已退出热路径,authority 由 WorldBlockDataStore 持有。 + * 保留为 no-op shell,仅供未来手动保存/导出入口复用。 + * 不再实际写 IndexedDB / WorldStore。 */ Chunk.prototype.saveDebounced = function() { - if (this.saveTimeout) clearTimeout(this.saveTimeout); - this.saveTimeout = setTimeout(() => { - const runtime = this.world?.worldRuntime; - if (runtime?.flushChunk) { - runtime.flushChunk(this.cx, this.cz).catch((error) => { - console.warn(`[ChunkPersistence] flushChunk failed for ${this.cx},${this.cz}:`, error); - }); - this.saveTimeout = null; - return; - } - - console.warn(`[ChunkPersistence] Missing worldRuntime for ${this.cx},${this.cz}, skip legacy save path`); + // no-op:runtime 正确性不再依赖防抖保存 + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); this.saveTimeout = null; - }, 500); + } }; } diff --git a/src/world/World.js b/src/world/World.js index cb873d1..8a05020 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -324,7 +324,19 @@ export class World { if (this.worldChunkRegistry?.hasKnownChunk(chunk.cx, chunk.cz)) { const hasAuthoritySlice = this.worldBlockDataStore?.hasChunkSlice(chunk.cx, chunk.cz); if (hasAuthoritySlice && !chunk.disposed) { - // 新 authority 流程:attach + rebuild + restore payload + // 新 authority 流程: + // 1. attachAuthoritySlice() → this.blockData = store shared Map + // + 设置 _isAuthorityAttached = true + // 2. rebuildDerivedIndexesFromAuthority() → 重建 blockDataArray/blockPalette/solidBlocks 等 + // 3. 恢复 non-block payload(staticEntities, runtimeSeedData) + // 4. 设 record-ready 入调度管线: + // runtime-hydrate(assembleRuntimeHydratePhase 通过 _isAuthorityAttached 识别 + // 此路径,跳过 blockData 注入,仅处理 runtimeEntities 迁移) + // → hydrated → runtime-build-mesh → terrain-built → runtime-finalize → finalize + // + // 注意:authority 路径不设置 _pendingChunkRecord,assembleRuntimeHydratePhase + // 通过显式 _isAuthorityAttached 标志区分路径,不再依赖 _pendingChunkRecord 为 null + // 的隐式巧合。 chunk.awaitingStoreRecord = false; chunk.needsStoreRetry = false; @@ -342,11 +354,6 @@ export class World { } } - // authority slice 已 attach,派生索引已重建 - // 设置 record-ready 状态,走标准 runtime-hydrate 管线: - // record-ready → runtime-hydrate(assembleRuntimeHydratePhase) - // → hydrated → runtime-build-mesh(assembleRuntimeBuildMeshPhase) - // → terrain-built → runtime-finalize → finalize chunk.loadState = 'record-ready'; chunk.isReady = false; @@ -368,6 +375,7 @@ export class World { } // 新 authority 未命中,回退到 WorldRuntime 的 IndexedDB 路径(旧存档导入场景) + // cold import 路径:plain object → authority → attach → rebuild this.worldRuntime.ensureChunkData(chunk.cx, chunk.cz).then((result) => { const dbRequestEnd = globalThis.performance?.now?.() ?? Date.now(); recordChunkPerf('world.runtime-chunk-record-db', dbRequestEnd - memRequestStart, { @@ -380,7 +388,70 @@ export class World { if (!result || chunk.disposed) return; if (result.status === 'ready' && result.chunkRecord) { - chunk.loadFromRecord(result.chunkRecord); + // cold import:先将 plain object 转为 authority,再 attach/rebuild + const { blockData: rawBlockData, staticEntities, runtimeSeedData, ...restRecord } = result.chunkRecord; + + // 1. plain object → Map,写入 WorldBlockDataStore + if (rawBlockData && Object.keys(rawBlockData).length > 0) { + const blockDataMap = WorldBlockDataStore.deserializeBlockData(rawBlockData); + this.worldBlockDataStore?.replaceChunkSlice(chunk.cx, chunk.cz, blockDataMap, 'cold-import'); + } else { + // 空 blockData 也建立 slice,标记 chunk 为 known empty + this.worldBlockDataStore?.ensureChunkSlice(chunk.cx, chunk.cz); + } + + // 2. 写入 payload registry + if (staticEntities?.length > 0 || runtimeSeedData) { + this.worldChunkPayloadRegistry?.mergeChunkPayload(chunk.cx, chunk.cz, { + staticEntities: staticEntities || [], + runtimeSeedData: runtimeSeedData || {} + }); + } + + // 3. 标记 chunk registry 为 imported + this.worldChunkRegistry?.markChunkKnown(chunk.cx, chunk.cz, { + source: 'cold-import', + ...restRecord + }); + + // 4. attach + rebuild + restore payload(复用 authority 路径) + chunk.attachAuthoritySlice(); + chunk.rebuildDerivedIndexesFromAuthority(); + + const payload = this.worldChunkPayloadRegistry?.getChunkPayload(chunk.cx, chunk.cz); + if (payload) { + if (payload.staticEntities?.length > 0) { + chunk._injectStaticEntities(payload.staticEntities); + } + if (payload.runtimeSeedData?.structureCenters) { + chunk.structureCenters = payload.runtimeSeedData.structureCenters; + } + } + + // 5. runtimeEntities 通过 _pendingChunkRecord 传递(供 assembleRuntimeHydratePhase 消费) + if (result.chunkRecord.runtimeEntities) { + chunk._pendingChunkRecord = result.chunkRecord; + chunk._isAuthorityAttached = false; // 走 cold import 路径让 assembleRuntimeHydratePhase 处理 entities + } + + chunk.awaitingStoreRecord = false; + chunk.needsStoreRetry = false; + chunk.loadState = 'record-ready'; + chunk.isReady = false; + + this.chunkAssemblyScheduler.enqueue( + chunk, + 'runtime-hydrate', + this._computeChunkAssemblyPriority(chunk) + ); + + const importEnd = globalThis.performance?.now?.() ?? Date.now(); + recordChunkPerf('world.cold-import-to-authority', importEnd - dbRequestEnd, { + chunkKey, + blockDataSize: rawBlockData ? Object.keys(rawBlockData).length : 0, + hasStaticEntities: staticEntities?.length > 0, + hasRuntimeSeedData: !!runtimeSeedData + }); return; } diff --git a/src/world/WorldBlockDataStore.js b/src/world/WorldBlockDataStore.js index afd26c0..9b99b64 100644 --- a/src/world/WorldBlockDataStore.js +++ b/src/world/WorldBlockDataStore.js @@ -43,6 +43,13 @@ export class WorldBlockDataStore { hits: 0, misses: 0 }; + + // 迁移期调用点统计:追踪 deprecated / fallback 路径命中次数 + this._callStats = { + replaceOnAttached: 0, // 对 attached slice 的 replace 尝试 + nonMapReplace: 0, // 非 Map 类型的 replace 尝试 + integrityWarn: 0 // _verifySliceIntegrity 触发的告警 + }; } // ============================================================ @@ -122,8 +129,7 @@ export class WorldBlockDataStore { */ setBlockEntry(cx, cz, code, entry) { const key = this.chunkKey(cx, cz); - const slice = this._slices.get(key); - if (!slice) return; + const slice = this.ensureChunkSlice(cx, cz); const normalized = typeof entry === 'string' ? { type: entry, orientation: 0 } : { ...entry }; slice.set(code, normalized); @@ -143,8 +149,7 @@ export class WorldBlockDataStore { */ deleteBlockEntry(cx, cz, code) { const key = this.chunkKey(cx, cz); - const slice = this._slices.get(key); - if (!slice) return; + const slice = this.ensureChunkSlice(cx, cz); slice.delete(code); @@ -163,8 +168,7 @@ export class WorldBlockDataStore { */ applyChunkPatch(cx, cz, patches) { const key = this.chunkKey(cx, cz); - const slice = this._slices.get(key); - if (!slice) return; + const slice = this.ensureChunkSlice(cx, cz); for (const [code, entry] of patches) { if (entry === null || entry === undefined) { @@ -200,6 +204,7 @@ export class WorldBlockDataStore { // guard:已 attach 的 slice 不允许直接 replace if (this._attached.has(key)) { + this._callStats.replaceOnAttached++; console.warn( `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) called on attached slice ` + `(source: ${_callSource}). Use detach -> replace -> reattach protocol.` @@ -208,6 +213,7 @@ export class WorldBlockDataStore { } if (!(blockData instanceof Map)) { + this._callStats.nonMapReplace++; console.warn( `[WorldBlockDataStore] replaceChunkSlice(${cx},${cz}) received non-Map input ` + `(source: ${_callSource}). Runtime authority must use Map.` @@ -342,13 +348,47 @@ export class WorldBlockDataStore { const key = this.chunkKey(cx, cz); const slice = this._slices.get(key); if (!slice) { + this._callStats.integrityWarn++; console.warn(`[WorldBlockDataStore] _verifySliceIntegrity(${cx},${cz}) from ${caller}: slice is null/missing`); return false; } if (!(slice instanceof Map)) { + this._callStats.integrityWarn++; console.warn(`[WorldBlockDataStore] _verifySliceIntegrity(${cx},${cz}) from ${caller}: slice is not a Map`); return false; } return true; } + + /** + * 开发期断言:检查 chunk slice 当前是否处于 attached 状态 + * 在直接操作 _attached Map 前验证调用方是否理解当前生命周期阶段 + * @param {number} cx + * @param {number} cz + * @param {string} caller - 调用点标识 + * @returns {boolean} true 表示处于 attached 状态(需走 detach -> 操作 -> reattach) + */ + _assertNotAttached(cx, cz, caller = 'unknown') { + const key = this.chunkKey(cx, cz); + if (this._attached.has(key)) { + console.warn( + `[WorldBlockDataStore] _assertNotAttached(${cx},${cz}) from ${caller}: ` + + `slice is currently attached. Direct mutation outside store primitives is unsafe.` + ); + return false; + } + return true; + } + + /** + * 获取迁移期调用点统计(含 deprecated 路径命中次数) + * @returns {object} + */ + getCallStats() { + return { + ...this._callStats, + sliceCount: this._slices.size, + attachedCount: this._attached.size + }; + } } diff --git a/src/world/WorldRuntime.js b/src/world/WorldRuntime.js index 568ef5c..854477f 100644 --- a/src/world/WorldRuntime.js +++ b/src/world/WorldRuntime.js @@ -45,11 +45,14 @@ export class WorldRuntime { lastElapsedMs: 0, lastProcessedAt: 0 }; - // 迁移期调用点统计 + // 迁移期调用点统计:追踪 authority mutation / deprecated shell / fallback 路径命中次数 this._callStats = { - recordBlockMutation: 0, - flushChunk: 0, - flushBeforeUnload: 0 + recordBlockMutation: 0, // block 变更记录(标记 runtime dirty) + flushChunk: 0, // deprecated flush 单 chunk 调用 + flushAllDirty: 0, // deprecated flush 全量调用 + flushBeforeUnload: 0, // deprecated flush unload 前调用 + scheduleFlush: 0, // deprecated _scheduleFlush 调度次数 + regionCacheFallback: 0 // RegionCache 作为 live truth fallback 的命中次数 }; } @@ -159,12 +162,13 @@ export class WorldRuntime { /** * 标记 chunk 为脏(玩家放置/破坏方块后调用) + * runtime dirty 仅用于观测和导出标记,不再自动调度 flush/save * @param {number} cx * @param {number} cz */ markChunkDirty(cx, cz) { this._ensureDirtyChunkEntry(cx, cz).dirty = true; - this._scheduleFlush(cx, cz); + // flush 已退出 runtime 热路径,不再自动调度 } /** @@ -263,6 +267,7 @@ export class WorldRuntime { * 保留此方法仅供未来手动保存时导出到 IndexedDB。 */ async flushAllDirty() { + this._callStats.flushAllDirty++; const dirtyKeys = this.getDirtyChunkKeys(); const pendingUnloadKeys = Array.from(this.pendingUnloadFlushQueue.keys()); if (dirtyKeys.length === 0 && pendingUnloadKeys.length === 0) return; @@ -981,7 +986,12 @@ export class WorldRuntime { return null; } + /** + * @deprecated 已从 runtime 热路径解绑,仅保留供显式调用场景(retry on error、future manual save)。 + * markChunkDirty() 不再自动调度此方法。 + */ _scheduleFlush(cx, cz, delayMs = 500) { + this._callStats.scheduleFlush++; const key = this._chunkKey(cx, cz); this._clearScheduledFlush(cx, cz); const timer = globalThis.setTimeout(() => { @@ -1046,7 +1056,8 @@ export class WorldRuntime { return { cachedRegions: this._regionCache.size, dirtyChunks: this._dirtyChunks.size, - maxRegions: this._regionCache._maxRegions + maxRegions: this._regionCache._maxRegions, + callStats: { ...this._callStats } }; } } From ecae1fe65691521da4df698d6650007b311ee37b Mon Sep 17 00:00:00 2001 From: lijing00333 Date: Sun, 10 May 2026 18:03:56 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix(world):=20=E4=BF=AE=E5=A4=8D=20cold?= =?UTF-8?q?=20import=20/=20scatter=20=E6=96=B0=E5=A2=9E=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E4=B8=AA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorldChunkRegistry: 新增 markChunkKnown() 通用标记方法,cold-import → imported,scatter → generated,已标记则跳过 - World.js: cold import 存在 runtimeEntities 时直接注入 shadow store,不再设 _isAuthorityAttached=false 退回旧注入分支,避免 blockData/staticEntities 重复写入 - Chunk.js: acceptScatteredBlocks 添加 blockData.has(code) 守卫,scatter 先写 authority 后不再重复 applyChunkPatch,防止 version bump 与 stats 放大 Co-Authored-By: Claude Opus 4.7 --- src/world/Chunk.js | 4 ++++ src/world/World.js | 6 +++--- src/world/WorldChunkRegistry.js | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 2e58c05..3edd65d 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -3498,6 +3498,10 @@ export class Chunk { const code = Chunk.encodeCoord(block.x, block.y, block.z); if (this.deletedBlockTombstones.has(code)) continue; + // 若 authority 中已存在该方块(如 scatter 先写了未加载目标 chunk), + // 跳过 store mutation 防止重复 version bump 与 stats 放大 + if (this.blockData.has(code)) continue; + const entry = block.orientation !== 0 ? { type: block.type, orientation: block.orientation } : block.type; patches.set(code, entry); } diff --git a/src/world/World.js b/src/world/World.js index 8a05020..40b57d7 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -428,10 +428,10 @@ export class World { } } - // 5. runtimeEntities 通过 _pendingChunkRecord 传递(供 assembleRuntimeHydratePhase 消费) + // 5. runtimeEntities 直接注入 shadow store,不再回退到旧 cold-import 注入分支 + // 避免重新执行 blockData 注入和 staticEntities 重复恢复 if (result.chunkRecord.runtimeEntities) { - chunk._pendingChunkRecord = result.chunkRecord; - chunk._isAuthorityAttached = false; // 走 cold import 路径让 assembleRuntimeHydratePhase 处理 entities + specialEntitiesShadowStore.deserializeAndMerge(chunk.cx, chunk.cz, result.chunkRecord.runtimeEntities); } chunk.awaitingStoreRecord = false; diff --git a/src/world/WorldChunkRegistry.js b/src/world/WorldChunkRegistry.js index f1caf2a..d8d2c79 100644 --- a/src/world/WorldChunkRegistry.js +++ b/src/world/WorldChunkRegistry.js @@ -62,6 +62,25 @@ export class WorldChunkRegistry { }); } + /** + * 通用标记 chunk 为已知存在,根据 source 自动选择 state + * scatter 写入 → 'generated';cold-import → 'imported' + * 若已标记过则跳过,避免覆盖已有 state + * @param {number} cx + * @param {number} cz + * @param {object} [meta] + */ + markChunkKnown(cx, cz, meta = {}) { + const key = this.chunkKey(cx, cz); + if (this._entries.has(key)) return; // 已标记过,不覆盖 + const state = meta.source === 'cold-import' ? 'imported' : 'generated'; + this._entries.set(key, { + state, + generatedAt: meta.generatedAt || Date.now(), + generatorVersion: meta.generatorVersion || 0 + }); + } + /** * 获取 chunk 的 presence state * @param {number} cx From c8dc2a170036b3b852aef3423cc1ef33c697bb0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Mon, 11 May 2026 23:01:34 +0800 Subject: [PATCH 13/17] =?UTF-8?q?feat(world):=20O=E9=94=AEAO=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20=E2=80=94=20MaterialManager/World/Chunk=20=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=94=AF=E6=8C=81=EF=BC=8C=E5=85=B3=E9=97=AD?= =?UTF-8?q?AO=E6=97=B6=E8=B7=B3=E8=BF=87=E8=AE=A1=E7=AE=97=E4=BB=A5?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaterialManager: 重构 toggleAO/setAOEnabled,提取 _syncAOShaderState 和 _notifyAOSettingChanged,开关变更时通知 World 系统 - World: 新增 onAOSettingChanged,AO关闭清理全部chunk的脏状态和定时器,AO开启触发已就绪chunk的全量刷新 - Chunk: _refreshAOFromStableSource/_executeAORefresh/_applyAOResults 入口处检查AO开关,关闭时跳过fullSync/flush/attribute覆写 - Chunk: _buildMeshFromExistingBlockDataIncremental 新增分阶段性能打点(iterate/convert/visible/build-mesh),便于定位瓶颈 - 测试: 覆盖AO关闭跳过fullSync、关闭不覆写attribute、关闭清理脏状态、开启触发刷新四个场景 Co-Authored-By: Claude Sonnet 4.6 --- src/core/MaterialManager.js | 50 +++---- src/tests/test-chunk.js | 78 +++++++++++ src/tests/test-mocks.js | 10 ++ src/tests/test-world.js | 77 +++++++++++ src/world/Chunk.js | 257 +++++++++++++++++++++++++++++++++--- src/world/World.js | 25 ++++ 6 files changed, 458 insertions(+), 39 deletions(-) diff --git a/src/core/MaterialManager.js b/src/core/MaterialManager.js index b4e9713..02e1aa7 100644 --- a/src/core/MaterialManager.js +++ b/src/core/MaterialManager.js @@ -472,13 +472,10 @@ export class MaterialManager { } /** - * 切换 AO 着色开关 + * 同步 AO 开关到所有材质 shader + * @param {boolean} enabled - 是否启用 AO */ - toggleAO() { - this.aoEnabled = !this.aoEnabled; - const enabled = this.aoEnabled; - console.log(`[MaterialManager] AO 着色已${enabled ? '开启' : '关闭'}`); - + _syncAOShaderState(enabled) { // 更新合批材质(ShaderMaterial) for (const mat of this.batchedMaterials.values()) { if (mat && mat.uniforms && mat.uniforms.uAoEnabled) { @@ -497,6 +494,28 @@ export class MaterialManager { } } + /** + * 通知世界系统:AO 开关已变化 + * @param {boolean} enabled - 是否启用 AO + */ + _notifyAOSettingChanged(enabled) { + const world = globalThis.game?.world; + if (world && typeof world.onAOSettingChanged === 'function') { + world.onAOSettingChanged(enabled); + } + } + + /** + * 切换 AO 着色开关 + */ + toggleAO() { + this.aoEnabled = !this.aoEnabled; + const enabled = this.aoEnabled; + console.log(`[MaterialManager] AO 着色已${enabled ? '开启' : '关闭'}`); + this._syncAOShaderState(enabled); + this._notifyAOSettingChanged(enabled); + } + /** * 设置 AO 着色状态 * @param {boolean} enabled - 是否启用 AO @@ -505,23 +524,8 @@ export class MaterialManager { if (this.aoEnabled === enabled) return; this.aoEnabled = enabled; console.log(`[MaterialManager] AO 着色已${enabled ? '开启' : '关闭'}`); - - // 更新合批材质(ShaderMaterial) - for (const mat of this.batchedMaterials.values()) { - if (mat && mat.uniforms && mat.uniforms.uAoEnabled) { - mat.uniforms.uAoEnabled.value = enabled ? 1.0 : 0.0; - } - } - - // 更新普通材质(MeshStandardMaterial)- 通过存储的 shader 引用 - for (const matOrMats of this.materials.values()) { - const mats = Array.isArray(matOrMats) ? matOrMats : [matOrMats]; - for (const mat of mats) { - if (mat && mat._aoShader && mat._aoShader.uniforms) { - mat._aoShader.uniforms.uAoEnabled.value = enabled ? 1.0 : 0.0; - } - } - } + this._syncAOShaderState(enabled); + this._notifyAOSettingChanged(enabled); } /** diff --git a/src/tests/test-chunk.js b/src/tests/test-chunk.js index 44e779b..aa030ca 100644 --- a/src/tests/test-chunk.js +++ b/src/tests/test-chunk.js @@ -1627,6 +1627,84 @@ describe('Chunk 真实类测试', (test) => { } }); + test('_refreshAOFromStableSource 在 AO 关闭时应跳过 fullSync 与 flush', () => { + setupEnvironment(); + + mockMaterials.setAOEnabled(false); + + const mockFullSyncCalls = []; + const mockFlushCalls = []; + const originalAoBridge = globalThis._aoBridge; + globalThis._aoBridge = { + fullSync(chunkKey, blockData) { + mockFullSyncCalls.push({ chunkKey, blockDataSize: blockData?.size }); + }, + flush() { + mockFlushCalls.push(true); + } + }; + + try { + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + chunk.worldY = 0; + chunk.addBlockDynamic(5, 5, 5, 'stone', 0); + chunk.isReady = true; + + chunk._refreshAOFromStableSource({ fullRefresh: true }); + + assertEqual(mockFullSyncCalls.length, 0, 'AO 关闭时不应 fullSync 到 Worker'); + assertEqual(mockFlushCalls.length, 0, 'AO 关闭时不应发送 AO flush'); + assertEqual(chunk.dirtyAOPositions.size, 0, 'AO 关闭时应清空当前 chunk 的脏 AO'); + } finally { + mockMaterials.setAOEnabled(true); + if (originalAoBridge === undefined) delete globalThis._aoBridge; + else globalThis._aoBridge = originalAoBridge; + teardownEnvironment(); + } + }); + + test('_applyAOResults 在 AO 关闭时不应覆写 attribute', () => { + setupEnvironment(); + + mockMaterials.setAOEnabled(false); + + try { + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + chunk.isReady = true; + + const code = Chunk.encodeCoord(4, 5, 6); + chunk.blockData.set(code, 'stone'); + chunk.instanceIndexMap = { + stone: new Map([[code, 0]]) + }; + + const aoLowArray = new Float32Array([11]); + const aoHighArray = new Float32Array([22]); + const geometry = new THREE.InstancedBufferGeometry(); + geometry.setAttribute('aAoLow', new THREE.InstancedBufferAttribute(aoLowArray, 1)); + geometry.setAttribute('aAoHigh', new THREE.InstancedBufferAttribute(aoHighArray, 1)); + chunk.group = { + children: [{ + isInstancedMesh: true, + userData: { type: 'stone' }, + geometry + }] + }; + + chunk.dirtyAOPositions.add(code); + chunk._applyAOResults([{ x: 4, y: 5, z: 6, aoLow: 99, aoHigh: 77 }], new Set([code])); + + assertEqual(aoLowArray[0], 11, 'AO 关闭时不应改写 aoLow'); + assertEqual(aoHighArray[0], 22, 'AO 关闭时不应改写 aoHigh'); + assertEqual(chunk.dirtyAOPositions.size, 0, 'AO 关闭时应清理本次已发送的脏标记'); + } finally { + mockMaterials.setAOEnabled(true); + teardownEnvironment(); + } + }); + test('_applyConsolidateResult 应保留 dirty 可见方块的旧 AO,避免合并后闪烁', () => { setupEnvironment(); diff --git a/src/tests/test-mocks.js b/src/tests/test-mocks.js index f2bc1da..cb7f3e3 100644 --- a/src/tests/test-mocks.js +++ b/src/tests/test-mocks.js @@ -16,6 +16,7 @@ export const mockFaceCullingSystem = { }; export const mockMaterials = { + aoEnabled: true, getMaterial: () => { const material = { clone: () => ({ ...material }), @@ -23,6 +24,15 @@ export const mockMaterials = { }; return material; }, + toggleAO() { + this.aoEnabled = !this.aoEnabled; + }, + setAOEnabled(enabled) { + this.aoEnabled = enabled; + }, + isAOEnabled() { + return this.aoEnabled; + }, dispose: () => {} }; diff --git a/src/tests/test-world.js b/src/tests/test-world.js index ce2842e..c85d576 100644 --- a/src/tests/test-world.js +++ b/src/tests/test-world.js @@ -866,6 +866,83 @@ describe('World 真实类测试', (test) => { teardownEnvironment(); }); + test('onAOSettingChanged(false) 应清理已加载 chunk 的 AO 脏状态与定时器', () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + let clearedTimer = 0; + const chunkA = { + dirtyAOPositions: new Set([1, 2]), + aoRefreshTimer: { id: 'a' } + }; + const chunkB = { + dirtyAOPositions: new Set([3]), + aoRefreshTimer: null + }; + + const originalClearTimeout = globalThis.clearTimeout; + globalThis.clearTimeout = () => { clearedTimer++; }; + + try { + world.chunks.set('0,0', chunkA); + world.chunks.set('1,0', chunkB); + + world.onAOSettingChanged(false); + + assertEqual(chunkA.dirtyAOPositions.size, 0, '关闭 AO 时应清空 chunkA 的脏 AO'); + assertEqual(chunkB.dirtyAOPositions.size, 0, '关闭 AO 时应清空 chunkB 的脏 AO'); + assertEqual(chunkA.aoRefreshTimer, null, '关闭 AO 时应清掉 chunkA 的 AO 定时器引用'); + assertEqual(clearedTimer, 1, '关闭 AO 时应清理已有 AO 定时器'); + } finally { + globalThis.clearTimeout = originalClearTimeout; + teardownEnvironment(); + } + }); + + test('onAOSettingChanged(true) 应对已就绪且非合并中的 chunk 触发全量 AO 刷新', () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + let readyRefreshes = 0; + let consolidatingRefreshes = 0; + let notReadyRefreshes = 0; + + world.chunks.set('0,0', { + isReady: true, + isConsolidating: false, + _refreshAOFromStableSource(options = {}) { + readyRefreshes++; + assertTrue(options.fullRefresh, '重新开启 AO 时应触发 fullRefresh'); + } + }); + world.chunks.set('1,0', { + isReady: true, + isConsolidating: true, + _refreshAOFromStableSource() { + consolidatingRefreshes++; + } + }); + world.chunks.set('2,0', { + isReady: false, + isConsolidating: false, + _refreshAOFromStableSource() { + notReadyRefreshes++; + } + }); + + world.onAOSettingChanged(true); + + assertEqual(readyRefreshes, 1, '已就绪且非合并中的 chunk 应全量刷新 AO'); + assertEqual(consolidatingRefreshes, 0, '合并中的 chunk 不应立即刷新 AO'); + assertEqual(notReadyRefreshes, 0, '未就绪 chunk 不应立即刷新 AO'); + + teardownEnvironment(); + }); + test('延迟 finalize 队列 - 流式加载活跃时不应执行纯新 runtime chunk 的后置任务', () => { setupEnvironment(); diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 3edd65d..1157fda 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -884,6 +884,16 @@ export class Chunk { const cz = this.cz; const minX = cx * CHUNK_SIZE; const minZ = cz * CHUNK_SIZE; + const chunkKey = `${cx},${cz}`; + const stageThresholds = { + iteratePassMs: 2, + convertInitMs: 1, + convertBatchMs: 1, + visiblePassMs: 1, + buildMeshesMs: 2, + invocationMs: Math.max(2, maxMs), + budgetExhaustedMs: Math.max(2, maxMs) + }; // 首次调用:初始化 progress if (!this._assemblyProgress) { @@ -901,30 +911,127 @@ export class Chunk { groupCursor: 0, groupInnerCursor: 0, // 临时状态 - _currentGroup: null + _currentGroup: null, + metrics: { + invocationCount: 0, + slowInvocationCount: 0, + budgetExhaustedCount: 0, + iteratePasses: 0, + iterateSnapshotMs: 0, + iterateLoopMs: 0, + iterateBlocks: 0, + iterateSlowPasses: 0, + iterateMaxMs: 0, + convertInitMs: 0, + convertInitCount: 0, + convertInitSlowCount: 0, + convertInitMaxMs: 0, + convertBatches: 0, + convertBatchMs: 0, + convertBlocks: 0, + convertBatchSlowCount: 0, + convertBatchMaxMs: 0, + visiblePasses: 0, + visibleMs: 0, + visibleBlocks: 0, + visibleSlowPasses: 0, + visibleMaxMs: 0, + buildMeshesMs: 0, + buildMeshesCount: 0, + buildMeshesSlowCount: 0, + buildMeshesMaxMs: 0 + } }; } const p = this._assemblyProgress.buildMesh; + p.metrics.invocationCount++; + const invocationIndex = p.metrics.invocationCount; + const finishInvocation = (result, extra = {}) => { + const invocationMs = (globalThis.performance?.now?.() ?? Date.now()) - start; + const isDone = result === 'done'; + const isSlowInvocation = invocationMs >= stageThresholds.invocationMs; + const isBudgetExhausted = extra.exitReason === 'budget-exhausted'; + if (isSlowInvocation) { + p.metrics.slowInvocationCount++; + } + if (isBudgetExhausted) { + p.metrics.budgetExhaustedCount++; + } + if (isDone || isSlowInvocation || isBudgetExhausted) { + recordChunkPerf('chunk.build-mesh.increment.summary', invocationMs, { + chunkKey, + invocationIndex, + result, + subStage: p.subStage, + cursor: p.cursor, + groupCursor: p.groupCursor, + groupInnerCursor: p.groupInnerCursor, + blocksBuffered: p.blocks.length, + meshGroups: p.meshData?.length || 0, + metrics: { ...p.metrics }, + ...extra + }, { thresholdMs: 0 }); + } + return result; + }; while ((globalThis.performance?.now?.() ?? Date.now()) - start < maxMs) { switch (p.subStage) { case 'iterate': { + const iterateStartedAt = globalThis.performance?.now?.() ?? Date.now(); + const snapshotStartedAt = globalThis.performance?.now?.() ?? Date.now(); const entries = [...this.blockData.entries()]; + const snapshotMs = (globalThis.performance?.now?.() ?? Date.now()) - snapshotStartedAt; + const cursorStart = p.cursor; const end = Math.min(p.cursor + 128, entries.length); + let emittedBlocks = 0; + let outOfRangeCount = 0; + let airCount = 0; + const iterateLoopStartedAt = globalThis.performance?.now?.() ?? Date.now(); for (let i = p.cursor; i < end; i++) { const [code, entry] = entries[i]; const decoded = Chunk.decodeCoord(Number(code)); const localX = decoded.x - minX; const localZ = decoded.z - minZ; - if (localX < 0 || localX >= CHUNK_SIZE || localZ < 0 || localZ >= CHUNK_SIZE) continue; + if (localX < 0 || localX >= CHUNK_SIZE || localZ < 0 || localZ >= CHUNK_SIZE) { + outOfRangeCount++; + continue; + } const type = typeof entry === 'string' ? entry : entry.type; - if (type === 'air') continue; + if (type === 'air') { + airCount++; + continue; + } const orientation = typeof entry === 'object' ? (entry.orientation || 0) : 0; p.blocks.push({ x: decoded.x, y: decoded.y, z: decoded.z, type, orientation }); + emittedBlocks++; } + const iterateLoopMs = (globalThis.performance?.now?.() ?? Date.now()) - iterateLoopStartedAt; p.cursor = end; - if (p.cursor < entries.length) return 'continue'; + p.metrics.iteratePasses++; + p.metrics.iterateSnapshotMs += snapshotMs; + p.metrics.iterateLoopMs += iterateLoopMs; + p.metrics.iterateBlocks += emittedBlocks; + const iterateMs = (globalThis.performance?.now?.() ?? Date.now()) - iterateStartedAt; + p.metrics.iterateMaxMs = Math.max(p.metrics.iterateMaxMs, iterateMs); + if (iterateMs >= stageThresholds.iteratePassMs) { + p.metrics.iterateSlowPasses++; + recordChunkPerf('chunk.build-mesh.iterate-pass', iterateMs, { + chunkKey, + invocationIndex, + cursorStart, + cursorEnd: end, + entriesLength: entries.length, + scannedEntries: end - cursorStart, + emittedBlocks, + outOfRangeCount, + airCount, + snapshotMs, + iterateLoopMs + }, { thresholdMs: 0 }); + } + if (p.cursor < entries.length) return finishInvocation('continue', { exitReason: 'iterate-partial' }); p.subStage = 'convert-group'; p.cursor = 0; break; @@ -933,6 +1040,7 @@ export class Chunk { case 'convert-group': { // 首次:按 type 分组 if (!p.groupedByType) { + const initStartedAt = globalThis.performance?.now?.() ?? Date.now(); p.groupedByType = {}; for (const block of p.blocks) { const type = block.type; @@ -941,6 +1049,19 @@ export class Chunk { } p.groupKeys = Object.keys(p.groupedByType); p.meshData = []; + const initMs = (globalThis.performance?.now?.() ?? Date.now()) - initStartedAt; + p.metrics.convertInitCount++; + p.metrics.convertInitMs += initMs; + p.metrics.convertInitMaxMs = Math.max(p.metrics.convertInitMaxMs, initMs); + if (initMs >= stageThresholds.convertInitMs) { + p.metrics.convertInitSlowCount++; + recordChunkPerf('chunk.build-mesh.convert-group.init', initMs, { + chunkKey, + invocationIndex, + blocksBuffered: p.blocks.length, + groupCount: p.groupKeys.length + }, { thresholdMs: 0 }); + } } // 逐组处理 @@ -969,17 +1090,22 @@ export class Chunk { const group = p._currentGroup; const batchSize = 128; + const batchStartedAt = globalThis.performance?.now?.() ?? Date.now(); const gEnd = Math.min(p.groupInnerCursor + batchSize, count); + let matrixCopyCount = 0; + let matrixComputeCount = 0; for (let i = p.groupInnerCursor; i < gEnd; i++) { const b = blocks[i]; if (b.matrix) { group.matrices.set(b.matrix, i * 16); + matrixCopyCount++; } else { const mx = b.x + 0.5; const my = b.y + 0.5; const mz = b.z + 0.5; const rot = getRotationAngle(b.orientation || 0); Chunk._computeTransformMatrix(mx, my, mz, rot, group.matrices, i * 16); + matrixComputeCount++; } group.aoLow[i] = b.aoLow ?? 1; group.aoHigh[i] = b.aoHigh ?? 1; @@ -987,9 +1113,32 @@ export class Chunk { const code = Chunk.encodeCoord(b.x, b.y, b.z); group.instanceIndexMap[code] = i; } + const batchMs = (globalThis.performance?.now?.() ?? Date.now()) - batchStartedAt; + p.metrics.convertBatches++; + p.metrics.convertBatchMs += batchMs; + p.metrics.convertBlocks += gEnd - p.groupInnerCursor; + p.metrics.convertBatchMaxMs = Math.max(p.metrics.convertBatchMaxMs, batchMs); + if (batchMs >= stageThresholds.convertBatchMs) { + p.metrics.convertBatchSlowCount++; + recordChunkPerf('chunk.build-mesh.convert-group.batch', batchMs, { + chunkKey, + invocationIndex, + type, + groupCursor: p.groupCursor, + groupInnerCursorStart: p.groupInnerCursor, + groupInnerCursorEnd: gEnd, + groupSize: count, + processedBlocks: gEnd - p.groupInnerCursor, + matrixCopyCount, + matrixComputeCount + }, { thresholdMs: 0 }); + } p.groupInnerCursor = gEnd; - if (p.groupInnerCursor < count) return 'continue'; + if (p.groupInnerCursor < count) return finishInvocation('continue', { + exitReason: 'convert-group-partial', + activeType: type + }); // 组完成 p.meshData.push(group); @@ -1004,40 +1153,78 @@ export class Chunk { } case 'visible': { + const visibleStartedAt = globalThis.performance?.now?.() ?? Date.now(); + const cursorStart = p.cursor; const end = Math.min(p.cursor + 256, p.blocks.length); for (let i = p.cursor; i < end; i++) { const block = p.blocks[i]; this.visibleKeys.add(Chunk.encodeCoord(block.x, block.y, block.z)); } + const visibleMs = (globalThis.performance?.now?.() ?? Date.now()) - visibleStartedAt; p.cursor = end; - if (p.cursor < p.blocks.length) return 'continue'; + p.metrics.visiblePasses++; + p.metrics.visibleMs += visibleMs; + p.metrics.visibleBlocks += end - cursorStart; + p.metrics.visibleMaxMs = Math.max(p.metrics.visibleMaxMs, visibleMs); + if (visibleMs >= stageThresholds.visiblePassMs) { + p.metrics.visibleSlowPasses++; + recordChunkPerf('chunk.build-mesh.visible-pass', visibleMs, { + chunkKey, + invocationIndex, + cursorStart, + cursorEnd: end, + processedBlocks: end - cursorStart, + totalBlocks: p.blocks.length + }, { thresholdMs: 0 }); + } + if (p.cursor < p.blocks.length) return finishInvocation('continue', { exitReason: 'visible-partial' }); p.subStage = 'build-mesh'; break; } case 'build-mesh': { + const buildMeshesStartedAt = globalThis.performance?.now?.() ?? Date.now(); this.buildMeshes(p.meshData); + const buildMeshesMs = (globalThis.performance?.now?.() ?? Date.now()) - buildMeshesStartedAt; + p.metrics.buildMeshesCount++; + p.metrics.buildMeshesMs += buildMeshesMs; + p.metrics.buildMeshesMaxMs = Math.max(p.metrics.buildMeshesMaxMs, buildMeshesMs); + if (buildMeshesMs >= stageThresholds.buildMeshesMs) { + p.metrics.buildMeshesSlowCount++; + recordChunkPerf('chunk.build-mesh.build-mesh', buildMeshesMs, { + chunkKey, + invocationIndex, + meshGroups: p.meshData?.length || 0, + blocksBuffered: p.blocks.length + }, { thresholdMs: 0 }); + } p.subStage = 'done'; break; } case 'done': - return 'done'; + return finishInvocation('done'); } } // 预算耗尽 if (p.subStage !== 'done') { - recordChunkPerf('chunk.build-mesh-increment.partial', (globalThis.performance?.now?.() ?? Date.now()) - start, { - chunkKey: `${cx},${cz}`, - subStage: p.subStage, - cursor: p.cursor, - groupCursor: p.groupCursor, - blocksProcessed: p.blocks.length - }, { thresholdMs: 0 }); - return 'continue'; + const elapsedMs = (globalThis.performance?.now?.() ?? Date.now()) - start; + if (elapsedMs >= stageThresholds.budgetExhaustedMs) { + recordChunkPerf('chunk.build-mesh-increment.partial', elapsedMs, { + chunkKey, + subStage: p.subStage, + cursor: p.cursor, + groupCursor: p.groupCursor, + blocksProcessed: p.blocks.length + }, { thresholdMs: 0 }); + } + return finishInvocation('continue', { + exitReason: 'budget-exhausted', + elapsedMs + }); } - return 'done'; + return finishInvocation('done'); } /** @@ -1683,6 +1870,25 @@ export class Chunk { // 因为方块操作后需要先 consolidation 生成 InstancedMesh,才能写入 AO attribute。 } + _isAOSystemEnabled() { + const activeMaterials = getMaterials(); + if (activeMaterials && typeof activeMaterials.isAOEnabled === 'function') { + return activeMaterials.isAOEnabled(); + } + return activeMaterials?.aoEnabled !== false; + } + + _clearPendingAOState() { + if (this.aoRefreshTimer) { + clearTimeout(this.aoRefreshTimer); + this.aoRefreshTimer = null; + } + this.dirtyAOPositions?.clear?.(); + if (Array.isArray(this._aoOperationQueue)) { + this._aoOperationQueue.length = 0; + } + } + /** * 将单个坐标添加到脏集(自动处理跨 chunk) * @private @@ -1722,6 +1928,11 @@ export class Chunk { let markMs = 0; let fullSyncMs = 0; + if (!this._isAOSystemEnabled()) { + this._clearPendingAOState(); + return; + } + if (options.fullRefresh) { const markStartedAt = performance.now(); this._markAllBlocksDirtyAO(); @@ -1991,6 +2202,11 @@ export class Chunk { * 执行 AO 刷新:先处理操作队列,再收集脏集发送给 AOWorker */ _executeAORefresh() { + if (!this._isAOSystemEnabled()) { + this._clearPendingAOState(); + return; + } + // 先处理操作队列:基于最新 blockData 重新计算所有操作的邻居脏位 this._flushAOOperationQueue(); @@ -2064,6 +2280,15 @@ export class Chunk { _applyAOResults(results, sentKeys) { const applyStartedAt = performance.now(); + if (!this._isAOSystemEnabled()) { + if (sentKeys) { + for (const key of sentKeys) { + this.dirtyAOPositions.delete(key); + } + } + return; + } + if (!results || results.length === 0) { // 即使无结果,也要清除已发送的脏标记 if (sentKeys) { diff --git a/src/world/World.js b/src/world/World.js index 40b57d7..66cb143 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -818,6 +818,31 @@ export class World { this.chunkAssemblyScheduler.enqueue(chunk, 'finalize', this._computeChunkAssemblyPriority(chunk)); } + onAOSettingChanged(enabled) { + for (const chunk of this.chunks.values()) { + if (!chunk) continue; + + if (!enabled) { + if (chunk.aoRefreshTimer) { + clearTimeout(chunk.aoRefreshTimer); + chunk.aoRefreshTimer = null; + } + chunk.dirtyAOPositions?.clear?.(); + if (Array.isArray(chunk._aoOperationQueue)) { + chunk._aoOperationQueue.length = 0; + } + continue; + } + + if (chunk.isReady && !chunk.isConsolidating) { + chunk._refreshAOFromStableSource?.({ + fullRefresh: true, + reason: 'ao-toggle-enabled' + }); + } + } + } + onChunkAOSourceStable(chunk, options = {}) { if (!chunk) return; From 59218688607ff68750614e38f22c1edb3a6ef73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Mon, 11 May 2026 23:42:38 +0800 Subject: [PATCH 14/17] =?UTF-8?q?perf(world):=20chunk=20=E8=A3=85=E9=85=8D?= =?UTF-8?q?=20entries=20=E5=BF=AB=E7=85=A7=E7=BC=93=E5=AD=98=20+=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9C=BA=E7=BA=A7=20=5FassemblyEpoch=20?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存 blockData entries 数组到 _cachedEntries,避免 iterate 阶段每次 invocation 重新展开整个 Map(大 chunk 从 ~50 次快照降到 1 次) - 新增 resetBuildMeshProgressState():当 _sourceEpoch 与当前 _assemblyEpoch 不一致时,整段 buildMesh 进度作废从头开始,避免 convert-group/visible 继续消费旧数据 - _updateBlockState 中补充 _assemblyEpoch 递增,使运行时 blockData 变更能正确触发装配快照失效 - 失效重启时同步清理 this.visibleKeys,防止过期可见性数据污染 face culling - 新增 invocationProfile / group.perf / staleRestartCount 等诊断埋点,iterate-pass、convert-group.batch、visible-pass 日志现在携带增量/总量/剩余计数器 Co-Authored-By: Claude Sonnet 4.6 --- src/world/Chunk.js | 118 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 4 deletions(-) diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 1157fda..4c04695 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -894,11 +894,47 @@ export class Chunk { invocationMs: Math.max(2, maxMs), budgetExhaustedMs: Math.max(2, maxMs) }; + const pushTopSlowItem = (list, item, limit = 3) => { + list.push(item); + list.sort((a, b) => b.durationMs - a.durationMs); + if (list.length > limit) list.length = limit; + }; + const createInvocationStats = () => ({ + stageAtStart: null, + iterateScannedEntries: 0, + iterateEmittedBlocks: 0, + convertBatchCount: 0, + convertBlocksProcessed: 0, + convertCompletedGroups: 0, + convertCompletedTypes: [], + convertSlowGroups: [], + visiblePassCount: 0, + visibleBlocksProcessed: 0, + visibleSlowPasses: [], + visibleMaxPassMs: 0, + buildMeshesMs: 0 + }); + const resetBuildMeshProgressState = (progress, sourceEpoch) => { + progress.subStage = 'iterate'; + progress.cursor = 0; + progress.blocks.length = 0; + progress.meshData = null; + progress.groupedByType = null; + progress.groupKeys = null; + progress.groupCursor = 0; + progress.groupInnerCursor = 0; + progress._cachedEntries = null; + progress._cachedEntriesEpoch = -1; + progress._cachedEntriesSize = -1; + progress._currentGroup = null; + progress._sourceEpoch = sourceEpoch; + }; // 首次调用:初始化 progress if (!this._assemblyProgress) { this._assemblyProgress = {}; } + const currentEpoch = this._assemblyEpoch || 0; if (!this._assemblyProgress.buildMesh) { this._assemblyProgress.buildMesh = { subStage: 'iterate', @@ -911,10 +947,16 @@ export class Chunk { groupCursor: 0, groupInnerCursor: 0, // 临时状态 + _cachedEntries: null, + _cachedEntriesEpoch: -1, + _cachedEntriesSize: -1, _currentGroup: null, + _sourceEpoch: currentEpoch, + _invocationStats: createInvocationStats(), metrics: { invocationCount: 0, slowInvocationCount: 0, + staleRestartCount: 0, budgetExhaustedCount: 0, iteratePasses: 0, iterateSnapshotMs: 0, @@ -945,8 +987,15 @@ export class Chunk { } const p = this._assemblyProgress.buildMesh; + if (p._sourceEpoch !== currentEpoch) { + p.metrics.staleRestartCount++; + resetBuildMeshProgressState(p, currentEpoch); + this.visibleKeys.clear(); + } p.metrics.invocationCount++; const invocationIndex = p.metrics.invocationCount; + p._invocationStats = createInvocationStats(); + p._invocationStats.stageAtStart = p.subStage; const finishInvocation = (result, extra = {}) => { const invocationMs = (globalThis.performance?.now?.() ?? Date.now()) - start; const isDone = result === 'done'; @@ -969,6 +1018,7 @@ export class Chunk { groupInnerCursor: p.groupInnerCursor, blocksBuffered: p.blocks.length, meshGroups: p.meshData?.length || 0, + invocationProfile: { ...p._invocationStats }, metrics: { ...p.metrics }, ...extra }, { thresholdMs: 0 }); @@ -981,7 +1031,17 @@ export class Chunk { case 'iterate': { const iterateStartedAt = globalThis.performance?.now?.() ?? Date.now(); const snapshotStartedAt = globalThis.performance?.now?.() ?? Date.now(); - const entries = [...this.blockData.entries()]; + const iterateEpoch = this._assemblyEpoch || 0; + const currentSize = this.blockData?.size || 0; + if (!p._cachedEntries || p._cachedEntriesEpoch !== iterateEpoch || p._cachedEntriesSize !== currentSize) { + // 快照已失效,整段 iterate 必须重启,避免旧快照前半段和新快照后半段混用 + p.cursor = 0; + p.blocks.length = 0; + p._cachedEntries = [...this.blockData.entries()]; + p._cachedEntriesEpoch = iterateEpoch; + p._cachedEntriesSize = currentSize; + } + const entries = p._cachedEntries; const snapshotMs = (globalThis.performance?.now?.() ?? Date.now()) - snapshotStartedAt; const cursorStart = p.cursor; const end = Math.min(p.cursor + 128, entries.length); @@ -1013,6 +1073,8 @@ export class Chunk { p.metrics.iterateSnapshotMs += snapshotMs; p.metrics.iterateLoopMs += iterateLoopMs; p.metrics.iterateBlocks += emittedBlocks; + p._invocationStats.iterateScannedEntries += end - cursorStart; + p._invocationStats.iterateEmittedBlocks += emittedBlocks; const iterateMs = (globalThis.performance?.now?.() ?? Date.now()) - iterateStartedAt; p.metrics.iterateMaxMs = Math.max(p.metrics.iterateMaxMs, iterateMs); if (iterateMs >= stageThresholds.iteratePassMs) { @@ -1034,6 +1096,7 @@ export class Chunk { if (p.cursor < entries.length) return finishInvocation('continue', { exitReason: 'iterate-partial' }); p.subStage = 'convert-group'; p.cursor = 0; + p._cachedEntries = null; // 释放快照数组,节省内存 break; } @@ -1084,7 +1147,14 @@ export class Chunk { aoLow: new Float32Array(count), aoHigh: new Float32Array(count), orientation: new Float32Array(count), - instanceIndexMap: {} + instanceIndexMap: {}, + perf: { + startedAt: globalThis.performance?.now?.() ?? Date.now(), + processedBlocks: 0, + batchCount: 0, + totalBatchMs: 0, + maxBatchMs: 0 + } }; } @@ -1118,6 +1188,12 @@ export class Chunk { p.metrics.convertBatchMs += batchMs; p.metrics.convertBlocks += gEnd - p.groupInnerCursor; p.metrics.convertBatchMaxMs = Math.max(p.metrics.convertBatchMaxMs, batchMs); + p._invocationStats.convertBatchCount++; + p._invocationStats.convertBlocksProcessed += gEnd - p.groupInnerCursor; + group.perf.processedBlocks += gEnd - p.groupInnerCursor; + group.perf.batchCount++; + group.perf.totalBatchMs += batchMs; + group.perf.maxBatchMs = Math.max(group.perf.maxBatchMs, batchMs); if (batchMs >= stageThresholds.convertBatchMs) { p.metrics.convertBatchSlowCount++; recordChunkPerf('chunk.build-mesh.convert-group.batch', batchMs, { @@ -1129,6 +1205,11 @@ export class Chunk { groupInnerCursorEnd: gEnd, groupSize: count, processedBlocks: gEnd - p.groupInnerCursor, + groupProcessedTotal: group.perf.processedBlocks, + groupRemaining: count - gEnd, + groupBatchCount: group.perf.batchCount, + groupTotalBatchMs: group.perf.totalBatchMs, + groupMaxBatchMs: group.perf.maxBatchMs, matrixCopyCount, matrixComputeCount }, { thresholdMs: 0 }); @@ -1141,6 +1222,20 @@ export class Chunk { }); // 组完成 + const groupElapsedMs = (globalThis.performance?.now?.() ?? Date.now()) - group.perf.startedAt; + const groupSummary = { + type, + blockCount: count, + batchCount: group.perf.batchCount, + totalBatchMs: group.perf.totalBatchMs, + maxBatchMs: group.perf.maxBatchMs, + durationMs: groupElapsedMs + }; + p._invocationStats.convertCompletedGroups++; + if (p._invocationStats.convertCompletedTypes.length < 6) { + p._invocationStats.convertCompletedTypes.push(type); + } + pushTopSlowItem(p._invocationStats.convertSlowGroups, groupSummary); p.meshData.push(group); p._currentGroup = null; p.groupCursor++; @@ -1165,16 +1260,27 @@ export class Chunk { p.metrics.visiblePasses++; p.metrics.visibleMs += visibleMs; p.metrics.visibleBlocks += end - cursorStart; + p._invocationStats.visiblePassCount++; + p._invocationStats.visibleBlocksProcessed += end - cursorStart; + p._invocationStats.visibleMaxPassMs = Math.max(p._invocationStats.visibleMaxPassMs, visibleMs); p.metrics.visibleMaxMs = Math.max(p.metrics.visibleMaxMs, visibleMs); if (visibleMs >= stageThresholds.visiblePassMs) { p.metrics.visibleSlowPasses++; + pushTopSlowItem(p._invocationStats.visibleSlowPasses, { + cursorStart, + cursorEnd: end, + processedBlocks: end - cursorStart, + durationMs: visibleMs + }); recordChunkPerf('chunk.build-mesh.visible-pass', visibleMs, { chunkKey, invocationIndex, cursorStart, cursorEnd: end, processedBlocks: end - cursorStart, - totalBlocks: p.blocks.length + totalBlocks: p.blocks.length, + visibleProcessedTotal: p._invocationStats.visibleBlocksProcessed, + visibleRemaining: p.blocks.length - end }, { thresholdMs: 0 }); } if (p.cursor < p.blocks.length) return finishInvocation('continue', { exitReason: 'visible-partial' }); @@ -1186,6 +1292,7 @@ export class Chunk { const buildMeshesStartedAt = globalThis.performance?.now?.() ?? Date.now(); this.buildMeshes(p.meshData); const buildMeshesMs = (globalThis.performance?.now?.() ?? Date.now()) - buildMeshesStartedAt; + p._invocationStats.buildMeshesMs = buildMeshesMs; p.metrics.buildMeshesCount++; p.metrics.buildMeshesMs += buildMeshesMs; p.metrics.buildMeshesMaxMs = Math.max(p.metrics.buildMeshesMaxMs, buildMeshesMs); @@ -1216,7 +1323,8 @@ export class Chunk { subStage: p.subStage, cursor: p.cursor, groupCursor: p.groupCursor, - blocksProcessed: p.blocks.length + blocksProcessed: p.blocks.length, + invocationProfile: { ...p._invocationStats } }, { thresholdMs: 0 }); } return finishInvocation('continue', { @@ -1455,6 +1563,8 @@ export class Chunk { this.blockData.set(code, entry); } } + // 任何运行时 blockData 变更都必须使进行中的装配快照失效 + this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; if (type === 'air') { this.deletedBlockTombstones.add(code); From 31e189c95d4a82cc1cee42badb5a7a98cd6a5331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Wed, 13 May 2026 09:27:38 +0800 Subject: [PATCH 15/17] =?UTF-8?q?refactor(world):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=95=B4=E7=90=86=20=E2=80=94=20=E6=B6=88=E9=99=A4=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E3=80=81=E6=8F=90=E5=8D=87=20perf=20=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E5=88=B0=20module=20level=E3=80=81=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 出现了地表空洞后的首次代码整理,by opus 4.6 - 提取 _ensureAuthorityAttached() 消除 3 处延迟 attach 重复代码 - 提取 _migrateRuntimeEntities() 消除 4 处 entity 迁移重复逻辑 - World.onAOSettingChanged(false) 复用 Chunk._clearPendingAOState() - buildMesh perf 辅助(stageThresholds/pushTopSlowItem/createInvocationStats/ resetBuildMeshProgressState)提升到 module level,避免每次调用重建闭包 - 修复 _updateBlockState 中 attach 路径的双重 _assemblyEpoch 递增 - acceptScatteredBlocks 从全量重建 solidBlocks 改为增量更新 Co-Authored-By: Claude Opus 4.6 --- src/tests/test-world.js | 25 ++-- src/world/Chunk.js | 276 ++++++++++++++++------------------------ src/world/World.js | 9 +- 3 files changed, 125 insertions(+), 185 deletions(-) diff --git a/src/tests/test-world.js b/src/tests/test-world.js index c85d576..9be5606 100644 --- a/src/tests/test-world.js +++ b/src/tests/test-world.js @@ -873,14 +873,23 @@ describe('World 真实类测试', (test) => { world = new World(scene); let clearedTimer = 0; - const chunkA = { - dirtyAOPositions: new Set([1, 2]), - aoRefreshTimer: { id: 'a' } - }; - const chunkB = { - dirtyAOPositions: new Set([3]), - aoRefreshTimer: null - }; + const makeMockChunk = (dirtyAO, timer) => ({ + dirtyAOPositions: dirtyAO, + aoRefreshTimer: timer, + _aoOperationQueue: [], + _clearPendingAOState() { + if (this.aoRefreshTimer) { + clearTimeout(this.aoRefreshTimer); + this.aoRefreshTimer = null; + } + this.dirtyAOPositions?.clear?.(); + if (Array.isArray(this._aoOperationQueue)) { + this._aoOperationQueue.length = 0; + } + } + }); + const chunkA = makeMockChunk(new Set([1, 2]), { id: 'a' }); + const chunkB = makeMockChunk(new Set([3]), null); const originalClearTimeout = globalThis.clearTimeout; globalThis.clearTimeout = () => { clearedTimer++; }; diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 4c04695..30ce70b 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -39,6 +39,55 @@ const getAOBridge = () => globalThis._aoBridge || aoBridge; // 获取方块属性函数 - 优先使用测试环境的模拟 const getBlockProps = createBlockPropsResolver(getBlockProperties); +// === buildMesh perf 辅助(module level,避免每次调用重建) === +const BUILD_MESH_STAGE_THRESHOLDS = { + iteratePassMs: 2, + convertInitMs: 1, + convertBatchMs: 1, + visiblePassMs: 1, + buildMeshesMs: 2, +}; + +function pushTopSlowItem(list, item, limit = 3) { + list.push(item); + list.sort((a, b) => b.durationMs - a.durationMs); + if (list.length > limit) list.length = limit; +} + +function createInvocationStats() { + return { + stageAtStart: null, + iterateScannedEntries: 0, + iterateEmittedBlocks: 0, + convertBatchCount: 0, + convertBlocksProcessed: 0, + convertCompletedGroups: 0, + convertCompletedTypes: [], + convertSlowGroups: [], + visiblePassCount: 0, + visibleBlocksProcessed: 0, + visibleSlowPasses: [], + visibleMaxPassMs: 0, + buildMeshesMs: 0 + }; +} + +function resetBuildMeshProgressState(progress, sourceEpoch) { + progress.subStage = 'iterate'; + progress.cursor = 0; + progress.blocks.length = 0; + progress.meshData = null; + progress.groupedByType = null; + progress.groupKeys = null; + progress.groupCursor = 0; + progress.groupInnerCursor = 0; + progress._cachedEntries = null; + progress._cachedEntriesEpoch = -1; + progress._cachedEntriesSize = -1; + progress._currentGroup = null; + progress._sourceEpoch = sourceEpoch; +} + /** * 区块类 - 负责单个区块的生成、管理和渲染 * 采用 InstancedMesh 架构:相同类型的方块在同一个区块内仅通过一次绘制调用(Draw Call)渲染 @@ -367,6 +416,49 @@ export class Chunk { this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; } + /** + * 延迟 attach:确保 this.blockData 指向 store 的共享 slice + * 若已 attach 则为 no-op;否则挂接 slice 并递增 epoch + * @returns {boolean} 是否执行了新 attach + */ + _ensureAuthorityAttached() { + const store = this.world?.worldBlockDataStore; + if (!store) return false; + if (store.isAttached(this.cx, this.cz)) return false; + this.blockData = store.ensureChunkSlice(this.cx, this.cz); + store.markAttached(this.cx, this.cz); + return true; + } + + /** + * 统一处理 runtimeEntities 的 shadow store 迁移 + * @param {object|null} runtimeEntities - chunkRecord 中的 runtimeEntities + */ + _migrateRuntimeEntities(runtimeEntities) { + const hasEntities = runtimeEntities && ( + runtimeEntities.turrets?.length > 0 || + runtimeEntities.zombieNests?.length > 0 || + runtimeEntities.minecarts?.length > 0 + ); + if (hasEntities) { + specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, runtimeEntities); + } else { + const live = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + const hasLive = ( + live.turrets?.length > 0 || + live.zombieNests?.length > 0 || + live.minecarts?.length > 0 + ); + if (!hasLive) { + specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { + turrets: [], zombieNests: [], minecarts: [] + }); + } + } + this._needsEntityMigration = false; + this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + } + /** * 从权威 ChunkRecord 装载数据(纯装载,不生成地形) * @@ -458,38 +550,10 @@ export class Chunk { // 5. 恢复运行时实体数据 const tEntitiesStart = globalThis.performance?.now?.() ?? Date.now(); - const hasRuntimeEntities = chunkRecord.runtimeEntities && ( - chunkRecord.runtimeEntities.turrets?.length > 0 || - chunkRecord.runtimeEntities.zombieNests?.length > 0 || - chunkRecord.runtimeEntities.minecarts?.length > 0 - ); - - if (hasRuntimeEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, chunkRecord.runtimeEntities); - this._needsEntityMigration = false; - } else { - const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); - const hasLiveShadowEntities = ( - liveShadowEntities.turrets?.length > 0 || - liveShadowEntities.zombieNests?.length > 0 || - liveShadowEntities.minecarts?.length > 0 - ); - - if (!hasLiveShadowEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { - turrets: [], - zombieNests: [], - minecarts: [] - }); - } - this._needsEntityMigration = false; - } - - this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + this._migrateRuntimeEntities(chunkRecord.runtimeEntities); const tEntitiesEnd = globalThis.performance?.now?.() ?? Date.now(); recordChunkPerf('chunk.load-from-record.entity-restore', tEntitiesEnd - tEntitiesStart, { - chunkKey: `${this.cx},${this.cz}`, - hasRuntimeEntities + chunkKey: `${this.cx},${this.cz}` }); // 同步路径:直接装配 @@ -525,21 +589,7 @@ export class Chunk { // 此处只需处理 runtimeEntities 迁移,无需注入 blockData if (this._isAuthorityAttached) { this._isAuthorityAttached = false; - - // 合并/标记 runtimeEntities(对齐 cold import 路径的尾部逻辑) - const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); - const hasLiveShadowEntities = ( - liveShadowEntities.turrets?.length > 0 || - liveShadowEntities.zombieNests?.length > 0 || - liveShadowEntities.minecarts?.length > 0 - ); - if (!hasLiveShadowEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { - turrets: [], zombieNests: [], minecarts: [] - }); - } - this._needsEntityMigration = false; - this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + this._migrateRuntimeEntities(null); this.pendingSnapshot = null; this._isPureLoadPath = true; @@ -584,34 +634,8 @@ export class Chunk { } this.pendingSnapshot = null; this._isPureLoadPath = true; + this._migrateRuntimeEntities(chunkRecord?.runtimeEntities); - const hasRuntimeEntities = chunkRecord?.runtimeEntities && ( - chunkRecord.runtimeEntities.turrets?.length > 0 || - chunkRecord.runtimeEntities.zombieNests?.length > 0 || - chunkRecord.runtimeEntities.minecarts?.length > 0 - ); - - if (hasRuntimeEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, chunkRecord.runtimeEntities); - this._needsEntityMigration = false; - } else { - const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); - const hasLiveShadowEntities = ( - liveShadowEntities.turrets?.length > 0 || - liveShadowEntities.zombieNests?.length > 0 || - liveShadowEntities.minecarts?.length > 0 - ); - if (!hasLiveShadowEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { - turrets: [], - zombieNests: [], - minecarts: [] - }); - } - this._needsEntityMigration = false; - } - - this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); this._pendingChunkRecord = null; this._assemblyProgress = null; this.loadState = 'hydrated'; @@ -740,34 +764,7 @@ export class Chunk { this.pendingSnapshot = null; this._isPureLoadPath = true; - - const hasRuntimeEntities = chunkRecord.runtimeEntities && ( - chunkRecord.runtimeEntities.turrets?.length > 0 || - chunkRecord.runtimeEntities.zombieNests?.length > 0 || - chunkRecord.runtimeEntities.minecarts?.length > 0 - ); - - if (hasRuntimeEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, chunkRecord.runtimeEntities); - this._needsEntityMigration = false; - } else { - const liveShadowEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); - const hasLiveShadowEntities = ( - liveShadowEntities.turrets?.length > 0 || - liveShadowEntities.zombieNests?.length > 0 || - liveShadowEntities.minecarts?.length > 0 - ); - if (!hasLiveShadowEntities) { - specialEntitiesShadowStore.deserializeAndMerge(this.cx, this.cz, { - turrets: [], - zombieNests: [], - minecarts: [] - }); - } - this._needsEntityMigration = false; - } - - this.pendingRuntimeEntities = specialEntitiesShadowStore.getAllEntitiesInChunk(this.cx, this.cz); + this._migrateRuntimeEntities(chunkRecord.runtimeEntities); this._pendingChunkRecord = null; } @@ -885,50 +882,8 @@ export class Chunk { const minX = cx * CHUNK_SIZE; const minZ = cz * CHUNK_SIZE; const chunkKey = `${cx},${cz}`; - const stageThresholds = { - iteratePassMs: 2, - convertInitMs: 1, - convertBatchMs: 1, - visiblePassMs: 1, - buildMeshesMs: 2, - invocationMs: Math.max(2, maxMs), - budgetExhaustedMs: Math.max(2, maxMs) - }; - const pushTopSlowItem = (list, item, limit = 3) => { - list.push(item); - list.sort((a, b) => b.durationMs - a.durationMs); - if (list.length > limit) list.length = limit; - }; - const createInvocationStats = () => ({ - stageAtStart: null, - iterateScannedEntries: 0, - iterateEmittedBlocks: 0, - convertBatchCount: 0, - convertBlocksProcessed: 0, - convertCompletedGroups: 0, - convertCompletedTypes: [], - convertSlowGroups: [], - visiblePassCount: 0, - visibleBlocksProcessed: 0, - visibleSlowPasses: [], - visibleMaxPassMs: 0, - buildMeshesMs: 0 - }); - const resetBuildMeshProgressState = (progress, sourceEpoch) => { - progress.subStage = 'iterate'; - progress.cursor = 0; - progress.blocks.length = 0; - progress.meshData = null; - progress.groupedByType = null; - progress.groupKeys = null; - progress.groupCursor = 0; - progress.groupInnerCursor = 0; - progress._cachedEntries = null; - progress._cachedEntriesEpoch = -1; - progress._cachedEntriesSize = -1; - progress._currentGroup = null; - progress._sourceEpoch = sourceEpoch; - }; + const invocationThresholdMs = Math.max(2, maxMs); + const stageThresholds = BUILD_MESH_STAGE_THRESHOLDS; // 首次调用:初始化 progress if (!this._assemblyProgress) { @@ -999,7 +954,7 @@ export class Chunk { const finishInvocation = (result, extra = {}) => { const invocationMs = (globalThis.performance?.now?.() ?? Date.now()) - start; const isDone = result === 'done'; - const isSlowInvocation = invocationMs >= stageThresholds.invocationMs; + const isSlowInvocation = invocationMs >= invocationThresholdMs; const isBudgetExhausted = extra.exitReason === 'budget-exhausted'; if (isSlowInvocation) { p.metrics.slowInvocationCount++; @@ -1317,7 +1272,7 @@ export class Chunk { // 预算耗尽 if (p.subStage !== 'done') { const elapsedMs = (globalThis.performance?.now?.() ?? Date.now()) - start; - if (elapsedMs >= stageThresholds.budgetExhaustedMs) { + if (elapsedMs >= invocationThresholdMs) { recordChunkPerf('chunk.build-mesh-increment.partial', elapsedMs, { chunkKey, subStage: p.subStage, @@ -1543,13 +1498,7 @@ export class Chunk { // === blockData 权威存储:优先通过 store mutation primitive === const blockStore = this.world?.worldBlockDataStore; if (blockStore) { - // 延迟 attach:确保 this.blockData 指向 store 的共享 slice - if (!blockStore.isAttached(this.cx, this.cz)) { - this.blockData = blockStore.ensureChunkSlice(this.cx, this.cz); - blockStore.markAttached(this.cx, this.cz); - this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; - } - // 通过 store 原语写 authority(内部统一处理 entry 规范化 + version 递增 + 统计) + this._ensureAuthorityAttached(); if (type === 'air') { blockStore.deleteBlockEntry(this.cx, this.cz, code); } else { @@ -3844,12 +3793,7 @@ export class Chunk { // 批量写入 authority(优先通过 store mutation primitive) const blockStore = this.world?.worldBlockDataStore; if (blockStore) { - // 延迟 attach:确保 this.blockData 指向 store 的共享 slice - if (!blockStore.isAttached(this.cx, this.cz)) { - this.blockData = blockStore.ensureChunkSlice(this.cx, this.cz); - blockStore.markAttached(this.cx, this.cz); - this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; - } + this._ensureAuthorityAttached(); if (patches.size > 0) { blockStore.applyChunkPatch(this.cx, this.cz, patches); } @@ -3863,10 +3807,9 @@ export class Chunk { // 从 authority 重建派生索引(_initArrayStorageFromBlockData 负责 blockDataArray/blockPalette/solidBlockIds) this._initArrayStorageFromBlockData(); - // 重建 solidBlocks 与 lightSourceCoords(_initArrayStorageFromBlockData 不处理这两个 Set) - this.solidBlocks.clear(); - this.lightSourceCoords.clear(); - for (const [code, entry] of this.blockData) { + // 从 patches 增量更新 solidBlocks 与 lightSourceCoords + for (const [code] of patches) { + const entry = this.blockData.get(code); const type = typeof entry === 'string' ? entry : (entry?.type || ''); if (type === 'air') continue; const props = getBlockProps(type); @@ -3979,12 +3922,7 @@ export class Chunk { // 批量写入 authority(优先通过 store mutation primitive) const blockStore = this.world?.worldBlockDataStore; if (blockStore) { - // 延迟 attach:确保 this.blockData 指向 store 的共享 slice - if (!blockStore.isAttached(this.cx, this.cz)) { - this.blockData = blockStore.ensureChunkSlice(this.cx, this.cz); - blockStore.markAttached(this.cx, this.cz); - this._assemblyEpoch = (this._assemblyEpoch || 0) + 1; - } + this._ensureAuthorityAttached(); if (patches.size > 0) { blockStore.applyChunkPatch(this.cx, this.cz, patches); } diff --git a/src/world/World.js b/src/world/World.js index 66cb143..25d0d92 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -823,14 +823,7 @@ export class World { if (!chunk) continue; if (!enabled) { - if (chunk.aoRefreshTimer) { - clearTimeout(chunk.aoRefreshTimer); - chunk.aoRefreshTimer = null; - } - chunk.dirtyAOPositions?.clear?.(); - if (Array.isArray(chunk._aoOperationQueue)) { - chunk._aoOperationQueue.length = 0; - } + chunk._clearPendingAOState?.(); continue; } From 0e38815727b5c4e19f36500ef4662990675b38fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Wed, 13 May 2026 12:59:32 +0800 Subject: [PATCH 16/17] =?UTF-8?q?docs(world):=20=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=B8=85=E7=90=86=E4=B8=8E=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BC=98=E5=8C=96=20=E2=80=94=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以"blockData 全量永驻 + 派生按距离释放"为原则,识别并规划 6 项改动: - M1: RegionCache 剥离 blockData 冗余副本(统一 helper + 6 个写入点) - M2: chunk unload 时清理 _dirtyChunks / pendingUnloadFlushQueue / _flushTimers - M3: topology 变化后从已加载 chunks 重建 _staticTreeTerrainBoostChunkKeys - M5: appendScatteredBlocks 增量更新 blockDataArray(O(n)→O(k)) - M6: _applyConsolidateResult 去除多余全量重建,前置修复 solidBlockIds 共享 id 误删 bug - M4 已撤回(WorldChunkPayloadRegistry 实为独立权威数据) Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-13-derived-data-cleanup.md | 1394 +++++++++++++++++ .../2026-05-13-derived-data-cleanup-design.md | 190 +++ 2 files changed, 1584 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-derived-data-cleanup.md create mode 100644 docs/superpowers/specs/2026-05-13-derived-data-cleanup-design.md diff --git a/docs/superpowers/plans/2026-05-13-derived-data-cleanup.md b/docs/superpowers/plans/2026-05-13-derived-data-cleanup.md new file mode 100644 index 0000000..54d7d82 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-derived-data-cleanup.md @@ -0,0 +1,1394 @@ +# 派生数据清理与增量更新优化 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. + +**Goal:** 消除 chunk unload 后的 runtime 残留(内存泄漏),清除 RegionCache 中冗余 blockData 副本,以及将 `appendScatteredBlocks` / `_applyConsolidateResult` 中不必要的全量重建改为增量更新或直接删除。 + +**Architecture:** blockData authority(`WorldBlockDataStore._slices`)全量永驻,所有派生数据在 chunk unload 时释放。`WorldChunkPayloadRegistry` 是独立权威数据,不在 unload 时清理。`_staticTreeTerrainBoostChunkKeys` 在 topology 变化后从已加载 chunks 重建。RegionCache 通过统一 helper `_stripBlockDataFromRegionRecord` 覆盖全部 6 个 `_regionCache.set` 调用点剥离 blockData;full-save 路径改为优先 patch、无 patch API 时重读完整 region,不用 stripped cache 做基底。M6 前置修复 `solidBlockIds` 共享 id 误删 bug。 + +**Tech Stack:** JavaScript (ES Modules), Three.js, Playwright headless 测试 + +**前置条件:** 实施前须在另一个终端保持 `npm run start` 运行(默认端口 8080)。`node command/run-tests.js` 通过 Playwright 访问 `127.0.0.1:8080`,不会自动启动 dev server。 + +--- + +## 文件结构 + +| 文件 | 变更类型 | 职责 | +|------|---------|------| +| `src/world/World.js` | Modify: L392-397, L1109-1113, L1118-1122 | M1 冷导入去冗余、M2 chunk unload 清理、M3 topology 变化后重建 boost | +| `src/world/WorldRuntime.js` | Modify: L88-110, L203-210, L320-330, L333-374, L524-535, L640-684, L746-761 | M1 统一 helper + 6 个 `_regionCache.set` 调用点 + full-save 路径修复 + preserveStoredBlockData 防御、M2 新增 clearChunkRuntimeResidue | +| `src/world/Chunk.js` | Modify: L1549-1574, L2624-2631, L3966-3968 | M6 前置修复 solidBlockIds 共享 id bug(_updateBlockState + _injectStaticEntities)、M5 增量更新 blockDataArray | +| `src/world/ChunkConsolidation.js` | Modify: L460-461 | M6 去除多余 _initArrayStorageFromBlockData | +| `src/tests/test-world.js` | Modify | M2/M3 新增测试 | +| `src/tests/test-world-runtime.js` | Modify | M1 新增测试 + 更新已有断言 | +| `src/tests/test-chunk.js` | Modify | M5/M6 新增测试 | + +--- + +### Task 1: M2 — chunk unload 时清理 runtime 残留 + +**Files:** +- Test: `src/tests/test-world.js` +- Modify: `src/world/WorldRuntime.js` (新增方法) +- Modify: `src/world/World.js:1109-1113` + +- [ ] **Step 1: 写失败测试 — unload 后 runtime 残留不残留** + +在 `src/tests/test-world.js` 中添加测试(在 "runtime-streaming 区块卸载时不应等待写盘完成" 测试之后): + +```javascript +test('chunk unload 后 runtime 残留(dirty/pendingUnload/flushTimer)不应残留', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + // 模拟 worldRuntime,注入 dirty / pendingUnload / flushTimer 三种残留 + // 用 stub timer id 而非真实 setTimeout,避免测试失败时挂住事件循环 + const dirtyChunks = new Map(); + dirtyChunks.set('0,0', { cx: 0, cz: 0, dirty: true }); + const pendingUnloadFlushQueue = new Map(); + pendingUnloadFlushQueue.set('0,0', { chunkKey: '0,0' }); + const flushTimers = new Map(); + const stubTimerId = setTimeout(() => {}, 0); // 立即过期的 stub + clearTimeout(stubTimerId); // 立即取消,只保留 id 用于验证 Map 行为 + flushTimers.set('0,0', stubTimerId); + + let clearTimeoutCalledWith = null; + world.worldRuntime = { + _dirtyChunks: dirtyChunks, + pendingUnloadFlushQueue, + _flushTimers: flushTimers, + _chunkKey(cx, cz) { return `${cx},${cz}`; }, + clearChunkRuntimeResidue(cx, cz) { + const key = this._chunkKey(cx, cz); + this._dirtyChunks.delete(key); + this.pendingUnloadFlushQueue.delete(key); + const timer = this._flushTimers.get(key); + if (timer !== undefined) { + clearTimeoutCalledWith = timer; + clearTimeout(timer); + this._flushTimers.delete(key); + } + }, + ensureChunkData() { + return Promise.resolve({ status: 'missing-region' }); + }, + prefetchRegions() {} + }; + + // 移动到远处触发 unload + world.update(new THREE.Vector3(200, 10, 200), 0.016); + + assertFalse(world.chunks.has('0,0'), 'chunk 应已卸载'); + assertFalse(dirtyChunks.has('0,0'), 'unload 后 _dirtyChunks 不应残留'); + assertFalse(pendingUnloadFlushQueue.has('0,0'), 'unload 后 pendingUnloadFlushQueue 不应残留'); + assertFalse(flushTimers.has('0,0'), 'unload 后 _flushTimers 不应残留'); + + teardownEnvironment(); +}); +``` + +- [ ] **Step 2: 运行测试,验证失败** + +Run: `node command/run-tests.js --verbose` +Expected: FAIL — World.js 尚未调用 `clearChunkRuntimeResidue` + +- [ ] **Step 3: 在 WorldRuntime 新增 clearChunkRuntimeResidue 方法** + +在 `src/world/WorldRuntime.js:205`(`clearChunkDirty` 方法之后)添加: + +```javascript + /** + * 统一清理 chunk 卸载后的 runtime 残留 + */ + clearChunkRuntimeResidue(cx, cz) { + const key = this._chunkKey(cx, cz); + this._dirtyChunks.delete(key); + this.pendingUnloadFlushQueue.delete(key); + const timer = this._flushTimers.get(key); + if (timer !== undefined) { + clearTimeout(timer); + this._flushTimers.delete(key); + } + } +``` + +- [ ] **Step 3.5: 写单测 — WorldRuntime.clearChunkRuntimeResidue 直接验证** + +在 `src/tests/test-world-runtime.js` 中添加: + +```javascript +test('clearChunkRuntimeResidue - 应清理 dirtyChunks / pendingUnloadFlushQueue / _flushTimers', () => { + const originalWorldStore = globalThis._worldStore; + globalThis._worldStore = { getChunkRecord: async () => null }; + + const runtime = new WorldRuntime(); + runtime._dirtyChunks.set('0,0', { cx: 0, cz: 0, dirty: true }); + runtime.pendingUnloadFlushQueue.set('0,0', { chunkKey: '0,0' }); + const stubTimerId = setTimeout(() => {}, 0); + clearTimeout(stubTimerId); + runtime._flushTimers.set('0,0', stubTimerId); + + runtime.clearChunkRuntimeResidue(0, 0); + + assertFalse(runtime._dirtyChunks.has('0,0'), '_dirtyChunks 应已清理'); + assertFalse(runtime.pendingUnloadFlushQueue.has('0,0'), 'pendingUnloadFlushQueue 应已清理'); + assertFalse(runtime._flushTimers.has('0,0'), '_flushTimers 应已清理'); + + // 不存在的 chunk 调用不应报错 + runtime.clearChunkRuntimeResidue(99, 99); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 4: 在 World.js chunk unload 循环中调用** + +在 `src/world/World.js:1109`(`chunk.dispose()` 之前)插入: + +```javascript + // 5. 清理 runtime 残留(dirty + pendingUnload + flushTimer) + this.worldRuntime?.clearChunkRuntimeResidue?.(chunk.cx, chunk.cz); +``` + +原来的注释 `// 5. 释放显存并从活动 chunk 集合移除` 改为 `// 6. 释放显存并从活动 chunk 集合移除`。 + +- [ ] **Step 5: 运行测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 6: 运行 lint** + +Run: `npm run lint` +Expected: 无新增警告 + +--- + +### Task 2: M3 — chunk topology 变化后重建 `_staticTreeTerrainBoostChunkKeys` + +**Files:** +- Test: `src/tests/test-world.js` +- Modify: `src/world/World.js:1118-1122` + +- [ ] **Step 1: 写失败测试 — topology 变化后 boost Set 仅包含已加载 chunk 的标记(含过期清除 + 多源 overlap 保留)** + +在 `src/tests/test-world.js` 中添加两个测试: + +```javascript +test('chunk topology 变化后 _staticTreeTerrainBoostChunkKeys 应从已加载 chunks 重建', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + // 手动注入一个过期 boost key(模拟已卸载 chunk 遗留) + world._staticTreeTerrainBoostChunkKeys.add('99,99'); + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('99,99'), '应已注入过期 key'); + + world.worldRuntime = { + ensureChunkData() { + return Promise.resolve({ status: 'missing-region' }); + }, + prefetchRegions() {}, + clearChunkRuntimeResidue() {} + }; + + // 移动到远处触发 unload(会触发 chunkTopologyChanged) + world.update(new THREE.Vector3(200, 10, 200), 0.016); + + assertFalse(world._staticTreeTerrainBoostChunkKeys.has('99,99'), + 'topology 变化后过期 boost key 不应残留'); + + teardownEnvironment(); +}); + +test('chunk topology 重建时多源 chunk 产生的重叠 boost key 应保留', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + // 确保两个源 chunk 都已加载(防止断言被静默跳过) + const chunk00 = world.chunks.get('0,0'); + const chunk10 = world.chunks.get('1,0'); + assertTrue(!!chunk00, '测试前提:chunk 0,0 应已加载'); + assertTrue(!!chunk10, '测试前提:chunk 1,0 应已加载'); + + // 模拟两个源 chunk 都有 static_tree,且 boost 覆盖范围重叠于 '1,0' + chunk00.structureCenters = [ + { type: 'static_tree', x: 14, y: 10, z: 8 } // 影响 chunk 0,0 和 1,0 + ]; + chunk10.structureCenters = [ + { type: 'static_tree', x: 18, y: 10, z: 8 } // 也影响 chunk 1,0 + ]; + + // 注入过期 key,然后通过生产代码 helper 重建 + world._staticTreeTerrainBoostChunkKeys.add('99,99'); + world._rebuildStaticTreeTerrainBoostChunkKeys(); + + // '1,0' 应存在(被两个源 chunk 共同标记),过期 key 应清除 + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('1,0'), + '多源 overlap 的 boost key 应在重建后保留'); + assertFalse(world._staticTreeTerrainBoostChunkKeys.has('99,99'), + '过期 boost key 应被清除'); + + teardownEnvironment(); +}); + +test('卸载一个源 chunk 后,另一个源产生的 boost key 仍应存在', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + const chunk00 = world.chunks.get('0,0'); + const chunk10 = world.chunks.get('1,0'); + assertTrue(!!chunk00, '测试前提:chunk 0,0 应已加载'); + assertTrue(!!chunk10, '测试前提:chunk 1,0 应已加载'); + + // 两个源 chunk 都标记 '1,0' 为 boost target + chunk00.structureCenters = [ + { type: 'static_tree', x: 14, y: 10, z: 8 } + ]; + chunk10.structureCenters = [ + { type: 'static_tree', x: 18, y: 10, z: 8 } + ]; + + // 通过生产代码 helper 初始重建,确认 '1,0' 存在 + world._rebuildStaticTreeTerrainBoostChunkKeys(); + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('1,0'), + '初始重建后 1,0 应存在'); + + // 模拟卸载 chunk 0,0(移除后通过生产代码 helper 重建) + world.chunks.delete('0,0'); + world._rebuildStaticTreeTerrainBoostChunkKeys(); + + // chunk 1,0 仍在,它自身的 structureCenters 仍应标记 '1,0' + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('1,0'), + '卸载一个源后,另一个源产生的 boost key 仍应存在'); + + teardownEnvironment(); +}); +``` + +- [ ] **Step 2: 运行测试,验证失败** + +Run: `node command/run-tests.js --verbose` +Expected: FAIL — `_staticTreeTerrainBoostChunkKeys` 仍包含 `99,99` + +- [ ] **Step 3: 实现 — 提取 helper 并在 topology 变化后调用** + +在 `src/world/World.js` 中新增 helper 方法(放在 `_markStaticTreeTerrainBoostFromChunk` 附近): + +```javascript + _rebuildStaticTreeTerrainBoostChunkKeys() { + this._staticTreeTerrainBoostChunkKeys.clear(); + for (const [, ch] of this.chunks) { + this._markStaticTreeTerrainBoostFromChunk(ch); + } + } +``` + +在 `chunkTopologyChanged` 分支中(约 L1118-1122,`this.clearBlockLookupCaches()` 之后),添加: + +```javascript + this._rebuildStaticTreeTerrainBoostChunkKeys(); +``` + +- [ ] **Step 4: 运行测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 5: 运行 lint** + +Run: `npm run lint` +Expected: 无新增警告 + +--- + +### Task 3: M1 — RegionCache 去除 blockData 冗余副本(统一 helper + 6 个写入点) + +**Files:** +- Test: `src/tests/test-world-runtime.js` +- Modify: `src/world/World.js:392-397` +- Modify: `src/world/WorldRuntime.js:88-110, 346-366, 524-535, 678-683, 746-761` + +**设计要点**:不逐点手工剥离,而是新增统一 helper `_stripBlockDataFromRegionRecord(region)`,在所有 `_regionCache.set(...)` 前调用。写盘路径(`saveRegionRecord`)使用原始 region,缓存用 helper 处理后的 shallow clone,避免引用污染。 + +- [ ] **Step 1: 写失败测试 — _upsertRegionCacheChunkRecord 存入后不含 blockData** + +在 `src/tests/test-world-runtime.js` 中添加测试: + +```javascript +test('_upsertRegionCacheChunkRecord - 存入 RegionCache 的 chunkRecord 不应包含 blockData', async () => { + const originalWorldStore = globalThis._worldStore; + globalThis._worldStore = { getChunkRecord: async () => null }; + + const runtime = new WorldRuntime(); + const chunkRecord = { + cx: 0, cz: 0, + blockData: { 123: 'stone', 456: 'dirt' }, + staticEntities: [{ type: 'tree' }], + runtimeSeedData: { structureCenters: [] } + }; + + runtime._upsertRegionCacheChunkRecord(0, 0, chunkRecord); + + const cachedRegion = runtime._regionCache.get('0,0'); + assertTrue(!!cachedRegion, '应已注入 region cache'); + const stored = cachedRegion.chunks['0,0']; + assertTrue( + stored.blockData === undefined, + '存入 RegionCache 的 chunkRecord 不应包含 blockData' + ); + assertDeepEqual(stored.staticEntities, [{ type: 'tree' }], 'staticEntities 应保留'); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 2: 运行测试,验证失败** + +Run: `node command/run-tests.js --verbose` +Expected: FAIL — `stored.blockData` 仍包含数据 + +- [ ] **Step 3: 实现 — 新增统一 helper `_stripBlockDataFromRegionRecord`** + +在 `src/world/WorldRuntime.js` 中(`clearChunkRuntimeResidue` 方法之后或类末尾)添加: + +```javascript + /** + * 从 region record 的各 chunk 中剥离 blockData 冗余副本。 + * 返回可安全放入 _regionCache 的浅克隆,不修改原始对象。 + */ + _stripBlockDataFromRegionRecord(region) { + if (!region?.chunks) return region; + const cleaned = { ...region, chunks: {} }; + for (const [ck, rec] of Object.entries(region.chunks)) { + if (rec && rec.blockData !== undefined) { + const { blockData: _dropped, ...rest } = rec; + cleaned.chunks[ck] = rest; + } else { + cleaned.chunks[ck] = rec; + } + } + return cleaned; + } +``` + +- [ ] **Step 4: 在 `_upsertRegionCacheChunkRecord` 中应用 helper(避免污染缓存引用)** + +`src/world/WorldRuntime.js:104` 的 `existingRegion.chunks[chunkKey] = chunkRecord;` 会通过 `_regionCache.get()` 返回的引用原地修改缓存对象,临时将带 blockData 的 chunkRecord 写入旧缓存。外部若持有旧引用则可看到冗余 blockData。 + +将 L104-L109 整体改为构建新 region 后写入 cache,不修改 `existingRegion` 引用: + +```javascript + const newRegion = this._stripBlockDataFromRegionRecord({ + ...existingRegion, + chunks: { ...existingRegion.chunks, [chunkKey]: chunkRecord }, + chunkKeys: existingRegion.chunkKeys.includes(chunkKey) + ? existingRegion.chunkKeys + : [...existingRegion.chunkKeys, chunkKey] + }); + this._regionCache.set(regionKey, newRegion); +``` + +这样:(1) `existingRegion`(旧缓存引用)不被修改;(2) `_stripBlockDataFromRegionRecord` 剥离所有 chunk 的 blockData;(3) cache 中存入的是全新对象。 + +- [ ] **Step 5: 运行测试,验证通过** + +Run: `node command/run-tests.js --verbose` +Expected: 新测试 PASS;已有测试断言 `cachedRegion.chunks['0,0'].blockData` 的用例(L46)会 FAIL + +- [ ] **Step 6: 更新已有测试断言** + +`src/tests/test-world-runtime.js` 中需要更新的断言: + +1. L46: `assertDeepEqual(cachedRegion.chunks['0,0'].blockData, ...)` → 改为 `assertTrue(cachedRegion.chunks['0,0'].blockData === undefined, 'RegionCache 不应保留 blockData 冗余副本')` + +2. 检查 L91、L132 等其他引用 `cachedRegion.chunks[...].blockData` 的断言,如断言 blockData 存在,同样更新为断言 blockData 不存在。 + +3. L910: `assertEqual(runtime._regionCache.get('0,0').chunks['0,0'].blockData, cachedBlockData, ...)` → 改为 `assertTrue(runtime._regionCache.get('0,0').chunks['0,0'].blockData === undefined, 'region cache 不应保留 blockData 冗余副本')` + +- [ ] **Step 7: 运行测试,验证全部通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 8: 写失败测试 — _updateRegionCacheChunkRecord 存入后不含 blockData** + +```javascript +test('_updateRegionCacheChunkRecord - 写入路径也应剥离 blockData', () => { + const originalWorldStore = globalThis._worldStore; + globalThis._worldStore = { getChunkRecord: async () => null }; + + const runtime = new WorldRuntime(); + runtime._regionCache.set('0,0', { + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: [], chunks: {} + }); + + runtime._updateRegionCacheChunkRecord(0, 0, { + blockData: { 789: 'wood' }, + staticEntities: [] + }); + + const stored = runtime._regionCache.get('0,0').chunks['0,0']; + assertTrue( + stored.blockData === undefined, + '_updateRegionCacheChunkRecord 也不应保留 blockData' + ); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 9: 运行测试,验证失败;实现 — _updateRegionCacheChunkRecord 剥离 blockData** + +在 `src/world/WorldRuntime.js:535`(`cachedRegion.chunks[key] = chunkRecord;`),改为 per-record 剥离: + +```javascript + const { blockData: _dropped, ...cleanRecord } = chunkRecord; + cachedRegion.chunks[key] = cleanRecord; +``` + +> **注意**:`_updateRegionCacheChunkRecord` 没有 `_regionCache.set` 调用(原地修改缓存引用),因此不能使用 `_stripBlockDataFromRegionRecord` 统一处理。per-record 剥离在此处是正确的 — 其他 chunk 的 blockData 已在入缓存时被 helper 剥离过。 + +- [ ] **Step 10: 写失败测试 — ensureRegion 读入后剥离 blockData** + +```javascript +test('ensureRegion - 从 IndexedDB 读入的 region record 应剥离各 chunk 的 blockData', async () => { + const originalWorldStore = globalThis._worldStore; + globalThis._worldStore = { + getRegionRecord: async () => ({ + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0', '1,0'], + chunks: { + '0,0': { blockData: { 1: 'stone' }, staticEntities: [] }, + '1,0': { blockData: { 2: 'dirt' }, staticEntities: [] } + } + }) + }; + + const runtime = new WorldRuntime(); + const region = await runtime.ensureRegion(0, 0); + + assertTrue(!!region, '应返回 region'); + assertTrue(region.chunks['0,0'].blockData === undefined, 'ensureRegion 后 chunk 0,0 不应保留 blockData'); + assertTrue(region.chunks['1,0'].blockData === undefined, 'ensureRegion 后 chunk 1,0 不应保留 blockData'); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 11: 运行测试,验证失败;实现 — ensureRegion 使用 helper** + +在 `src/world/WorldRuntime.js:758-764` 的 `.then((record) => {...})` 中,将: + +```javascript + if (record) { + this._regionCache.set(regionKey, record); + } + this._regionLoadPromises.delete(regionKey); + return record; +``` + +改为: + +```javascript + const cleaned = record ? this._stripBlockDataFromRegionRecord(record) : record; + if (cleaned) { + this._regionCache.set(regionKey, cleaned); + } + this._regionLoadPromises.delete(regionKey); + return cleaned; +``` + +**关键**:`return cleaned` 确保 `ensureRegion` 的调用者和 `_regionLoadPromises` resolve 拿到的都是 stripped 后的对象,与测试断言 `region.chunks[...].blockData === undefined` 一致。 + +- [ ] **Step 12: 写失败测试 — flushAllDirty 写回后 RegionCache 不含 blockData** + +```javascript +test('flushAllDirty - 写回后 RegionCache 不含 blockData 且写盘数据完整', async () => { + const originalWorldStore = globalThis._worldStore; + const savedRegions = []; + const patchedRegions = []; + + globalThis._worldStore = { + saveRegionRecord: async (rx, rz, region) => { + savedRegions.push({ rx, rz, region: JSON.parse(JSON.stringify(region)) }); + return true; + }, + getRegionRecord: async (rx, rz) => ({ + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0', '1,0'], + chunks: { + '0,0': { blockData: { 1: 'stone' }, staticEntities: [] }, + '1,0': { blockData: { 2: 'dirt', 3: 'wood' }, staticEntities: [{ type: 'tree' }] } + } + }), + applyRegionPatch: null + }; + + const runtime = new WorldRuntime(); + // M1 后 cache 中的 region 已 stripped(无 blockData) + runtime._regionCache.set('0,0', { + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0', '1,0'], + chunks: { + '0,0': { staticEntities: [] }, + '1,0': { staticEntities: [{ type: 'tree' }] } + } + }); + // 只有 chunk 0,0 是 dirty + runtime._dirtyChunks.set('0,0', { + cx: 0, cz: 0, dirty: true, pendingFlush: false, + blockDataSnapshot: null + }); + + await runtime.flushAllDirty(); + + // 写盘的 region 应保留所有 chunk 的 blockData + assertTrue(savedRegions.length > 0, '应有写盘操作'); + const saved = savedRegions[0].region; + const savedChunk00 = saved.chunks['0,0']; + assertTrue(!!savedChunk00, '写盘 region 应包含 chunk 0,0'); + + // 关键断言:未被 flush 的 chunk 1,0 的 blockData 不应丢失 + const savedChunk10 = saved.chunks['1,0']; + assertTrue(!!savedChunk10, '写盘 region 应包含 chunk 1,0'); + assertTrue(!!savedChunk10.blockData, '未 flush 的 chunk 1,0 写盘时 blockData 不应丢失'); + assertDeepEqual(savedChunk10.blockData, { 2: 'dirt', 3: 'wood' }, + '未 flush 的 chunk 1,0 写盘 blockData 应与 worldStore 原始数据一致'); + + // RegionCache 中不应保留 blockData + const cached = runtime._regionCache.get('0,0'); + assertTrue(!!cached?.chunks?.['0,0'], 'RegionCache 应包含 chunk 0,0'); + assertTrue(cached.chunks['0,0'].blockData === undefined, + 'flushAllDirty 后 RegionCache 不应保留 blockData'); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 13: 运行测试,验证失败;实现 — flushAllDirty 全部 _regionCache.set 使用 helper + 修复 full-save 路径** + +M1 剥离 cache 中的 blockData 后,`_regionCache.get(rKey)` 返回的 region 不再包含各 chunk 的 blockData。如果 `flushAllDirty` 用这个 stripped region 做 `saveRegionRecord` 的基底,region 中**未被 flush 的其他 chunk** 的 blockData 会永久丢失。 + +**修复策略**:`else if (region)` 分支(L347)也优先走 `applyRegionPatch`;无 patch API 时从 `worldStore.getRegionRecord()` 重读完整 region 再 merge。 + +在 `src/world/WorldRuntime.js` 的 `flushAllDirty` 方法中: + +**L336 `__partial` 分支**(已使用 `applyRegionPatch`):L346 的 `_regionCache.set` 改为使用 helper: + +```javascript + this._regionCache.set(rKey, this._stripBlockDataFromRegionRecord(region)); +``` + +**L347 `else if (region)` 分支**:改为也优先走 `applyRegionPatch`,无 patch API 时重读完整 region: + +```javascript + } else if (region) { + const chunkPatches = Array.from(group.chunks.entries()).map(([chunkKey, chunkRecord]) => ({ + chunkKey, + chunkRecord: this._cloneSerializable(chunkRecord, null) + })); + if (typeof this._worldStore.applyRegionPatch === 'function') { + await this._worldStore.applyRegionPatch(group.rx, group.rz, { chunkPatches }); + } else { + // 无 patch API — 从 worldStore 重读完整 region 再 merge,不用 stripped cache 做基底 + const fullRegion = await this._worldStore.getRegionRecord(group.rx, group.rz) || region; + for (const [chunkKey, chunkRecord] of group.chunks) { + // 属性合并:preserveStoredBlockData 删除了 chunkRecord.blockData 时,不会覆盖 fullRegion 已有的 blockData + fullRegion.chunks[chunkKey] = { ...fullRegion.chunks[chunkKey], ...chunkRecord }; + } + await this._worldStore.saveRegionRecord(group.rx, group.rz, fullRegion); + } + // merge 新 chunkRecord 到 region cache(更新 staticEntities/runtimeSeedData 等元数据) + for (const [chunkKey, chunkRecord] of group.chunks) { + region.chunks[chunkKey] = { ...region.chunks[chunkKey], ...chunkRecord }; + } + this._regionCache.set(rKey, this._stripBlockDataFromRegionRecord(region)); +``` + +**L354 `else` 分支**(新建 region):L366 的 `_regionCache.set` 改为使用 helper: + +```javascript + this._regionCache.set(rKey, this._stripBlockDataFromRegionRecord(newRegion)); +``` + +- [ ] **Step 14: 实现 — flushPendingUnloadQueueWithinBudget 修复 full-save 路径 + 使用 helper** + +`flushPendingUnloadQueueWithinBudget` 的 `else` fallback(L652-683)clone 了 stripped cache 做 `saveRegionRecord` 基底,与发现 1 同类问题。 + +在 `src/world/WorldRuntime.js:652` 的 `else` 分支中,将 L653 的 `this._cloneSerializable(this._regionCache.get(regionKey), {...})` 改为从 worldStore 重读完整 region: + +```javascript + } else { + // 无 patch API — 从 worldStore 重读完整 region,不用 stripped cache 做基底 + const fullRegion = await this._worldStore.getRegionRecord(rx, rz) || { + regionKey, rx, rz, + chunkKeys: [], + chunks: {}, + generatedAt: Date.now(), + generatorVersion: '1.0' + }; + if (!fullRegion.chunks) fullRegion.chunks = {}; + if (!Array.isArray(fullRegion.chunkKeys)) fullRegion.chunkKeys = []; + + for (const entry of regionEntries) { + const currentChunk = fullRegion.chunks[entry.chunkKey] || {}; + // 纯属性 merge:Step 14.5 已对 preserveStoredBlockData 的 chunkRecord 删除 blockData 字段, + // 此处 spread 自然保留 currentChunk 的 blockData;否则 entry.chunkRecord.blockData 覆盖 + fullRegion.chunks[entry.chunkKey] = { ...currentChunk, ...entry.chunkRecord }; + if (!fullRegion.chunkKeys.includes(entry.chunkKey)) { + fullRegion.chunkKeys.push(entry.chunkKey); + } + } + + await this._worldStore.saveRegionRecord(rx, rz, fullRegion); + this._regionCache.set(regionKey, this._stripBlockDataFromRegionRecord(fullRegion)); + } +``` + +**关键**:`preserveStoredBlockData` 路径从 `fullRegion`(完整 region)读 `currentChunk.blockData`,而非从 stripped cache 读,确保拿到有效数据。 + +- [ ] **Step 14.5: 防御 — preserveStoredBlockData 路径改为删除 chunkRecord.blockData** + +M1 剥离 RegionCache blockData 后,旧 flush 路径的 `preserveStoredBlockData` 逻辑(flushAllDirty L324-325、flushPendingUnloadQueueWithinBudget L673-675)会从 `cachedChunkRecord?.blockData || {}` 读到空对象。若用 `continue` 跳过,会让 entry 永久残留在队列中(flushAllDirty),或跳过写入但仍删除队列项导致静默丢数据(flushPendingUnloadQueueWithinBudget)。 + +正确做法:当 `preserveStoredBlockData === true` 时,直接删除 chunkRecord 的 blockData 字段。这样 patch 路径自然保留 IndexedDB 中已有的 blockData,full-save 路径从 `getRegionRecord()` 重读时 merge 也自然保留。 + +在 `src/world/WorldRuntime.js:324`,将: + +```javascript + if (queueRecord.preserveStoredBlockData === true) { + chunkRecord.blockData = cachedChunkRecord?.blockData || {}; + } +``` + +改为: + +```javascript + if (queueRecord.preserveStoredBlockData === true) { + // RegionCache 已剥离 blockData,不再从 cache 读取。 + // 删除 chunkRecord 的 blockData,让写盘路径自然保留 IndexedDB 中已有数据: + // - patch 路径:chunkRecord 不含 blockData,已有数据不受影响 + // - full-save 路径:从 getRegionRecord() 重读完整 region,merge 时无 blockData 属性不会覆盖 + delete chunkRecord.blockData; + } +``` + +同样在 `flushPendingUnloadQueueWithinBudget` 的 L673-675 做相同处理: + +```javascript + if (entry.preserveStoredBlockData) { + delete entry.chunkRecord.blockData; + } +``` + +同时更新 Step 13 和 Step 14 的 full-save fallback merge 逻辑,确保 merge 使用属性合并而非全量替换: + +**Step 13 full-save fallback 的 merge**(已有代码 `fullRegion.chunks[chunkKey] = chunkRecord`)改为: + +```javascript + fullRegion.chunks[chunkKey] = { ...fullRegion.chunks[chunkKey], ...chunkRecord }; +``` + +**Step 14 full-save fallback 的 merge**(已有代码 `fullRegion.chunks[entry.chunkKey] = { ...currentChunk, ...entry.chunkRecord, ... }`)不需要改 — 已经使用 spread merge。 + +- [ ] **Step 14.6: 写测试 — flushPendingUnloadQueueWithinBudget stripped cache 下写盘不丢数据** + +```javascript +test('flushPendingUnloadQueueWithinBudget - stripped cache 下写盘不应丢失其他 chunk 的 blockData', async () => { + const originalWorldStore = globalThis._worldStore; + const savedRegions = []; + + globalThis._worldStore = { + saveRegionRecord: async (rx, rz, region) => { + savedRegions.push({ rx, rz, region: JSON.parse(JSON.stringify(region)) }); + return true; + }, + getRegionRecord: async (rx, rz) => ({ + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0', '1,0'], + chunks: { + '0,0': { blockData: { 1: 'stone' }, staticEntities: [{ id: 'e1' }] }, + '1,0': { blockData: { 2: 'dirt', 3: 'wood' }, staticEntities: [] } + } + }) + }; + + const runtime = new WorldRuntime(); + // RegionCache 中存入 stripped region(无 blockData) + runtime._regionCache.set('0,0', { + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0', '1,0'], + chunks: { + '0,0': { staticEntities: [{ id: 'e1' }] }, + '1,0': { staticEntities: [] } + } + }); + + // 往 pendingUnloadFlushQueue 添加一条 chunk 0,0 的卸载记录 + runtime.pendingUnloadFlushQueue.set('0,0', { + cx: 0, cz: 0, + chunkRecord: { blockData: { 1: 'stone', 4: 'sand' }, staticEntities: [{ id: 'e1' }] }, + preserveStoredBlockData: false + }); + + await runtime.flushPendingUnloadQueueWithinBudget(1000); + + // 写盘的 region 应保留 chunk 1,0 的 blockData(从 getRegionRecord 重读) + assertTrue(savedRegions.length > 0, '应有写盘操作'); + const saved = savedRegions[0].region; + const savedChunk10 = saved.chunks['1,0']; + assertTrue(!!savedChunk10, '写盘 region 应包含 chunk 1,0'); + assertTrue(!!savedChunk10.blockData, '未 flush 的 chunk 1,0 写盘时 blockData 不应丢失'); + assertDeepEqual(savedChunk10.blockData, { 2: 'dirt', 3: 'wood' }, + '未 flush 的 chunk 1,0 写盘 blockData 应与 worldStore 原始数据一致'); + + // RegionCache 中不应保留 blockData + const cached = runtime._regionCache.get('0,0'); + assertTrue(cached.chunks['0,0'].blockData === undefined, + 'flush 后 RegionCache 不应保留 blockData'); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 14.6.1: 写测试 — flushPendingUnloadQueueWithinBudget preserveStoredBlockData: true 场景** + +```javascript +test('flushPendingUnloadQueueWithinBudget - preserveStoredBlockData: true 时保留原 blockData', async () => { + const originalWorldStore = globalThis._worldStore; + const savedRegions = []; + + const storedBlockData = { 1: 'stone', 2: 'dirt' }; + globalThis._worldStore = { + saveRegionRecord: async (rx, rz, region) => { + savedRegions.push({ rx, rz, region: JSON.parse(JSON.stringify(region)) }); + return true; + }, + getRegionRecord: async (rx, rz) => ({ + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0'], + chunks: { + '0,0': { blockData: storedBlockData, staticEntities: [{ id: 'e1' }] } + } + }) + }; + + const runtime = new WorldRuntime(); + // RegionCache 中存入 stripped region(无 blockData) + runtime._regionCache.set('0,0', { + regionKey: '0,0', rx: 0, rz: 0, + chunkKeys: ['0,0'], + chunks: { + '0,0': { staticEntities: [{ id: 'e1' }] } + } + }); + + // pendingUnloadFlushQueue 带 preserveStoredBlockData: true + // chunkRecord 中的 blockData 只有新增方块,期望 merge 后保留 store 中的原数据 + runtime.pendingUnloadFlushQueue.set('0,0', { + cx: 0, cz: 0, + chunkRecord: { blockData: { 1: 'stone', 2: 'dirt', 5: 'sand' }, staticEntities: [{ id: 'e1' }] }, + preserveStoredBlockData: true + }); + + await runtime.flushPendingUnloadQueueWithinBudget(1000); + + // Step 14.5 delete 了 chunkRecord.blockData,full-save 路径从 getRegionRecord() 重读 + // merge 后 store 中的原 blockData 应保留 + assertTrue(savedRegions.length > 0, '应有写盘操作'); + const saved = savedRegions[0].region; + const savedChunk = saved.chunks['0,0']; + assertTrue(!!savedChunk.blockData, 'preserveStoredBlockData: true 写盘后 blockData 不应丢失'); + assertDeepEqual(savedChunk.blockData, storedBlockData, + 'preserveStoredBlockData: true 应保留 store 中的原 blockData,不被 chunkRecord 覆盖'); + + // RegionCache 中不应保留 blockData + const cached = runtime._regionCache.get('0,0'); + assertTrue(cached.chunks['0,0'].blockData === undefined, + 'flush 后 RegionCache 不应保留 blockData'); + + globalThis._worldStore = originalWorldStore; +}); +``` + +- [ ] **Step 14.7: 运行测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS(实现已在 Step 14 和 Step 14.5 完成) + +- [ ] **Step 15: 实现 — cold import 路径置空 blockData** + +在 `src/world/World.js:401`(`if/else` 分支之后,即 `ensureChunkSlice` 和 `replaceChunkSlice` 两个分支结束后),添加: + +```javascript + // 释放 chunkRecord 对 rawBlockData 的引用,无论是否为空 + result.chunkRecord.blockData = null; +``` + +放在 `if/else` 之后而非只跟在 `replaceChunkSlice` 后,确保空 blockData 也一致释放引用。 + +- [ ] **Step 16: 运行全部测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 17: 运行 lint** + +Run: `npm run lint` +Expected: 无新增警告 + +--- + +### Task 4: M5 — `appendScatteredBlocks` 增量更新 `blockDataArray` + +**Files:** +- Test: `src/tests/test-chunk.js`(需在文件顶部新增 `import { WorldBlockDataStore } from '../world/WorldBlockDataStore.js';`) +- Modify: `src/world/Chunk.js:3966-3968` + +- [ ] **Step 1: 写失败测试 — 不调用全量重建 + 行为正确性** + +在 `src/tests/test-chunk.js` 中添加测试(在 "appendScatteredBlocks" 测试分组附近): + +```javascript +test('appendScatteredBlocks - 追加方块后应增量更新 blockDataArray 而非全量重建', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 预填充一些方块 + const baseCode = Chunk.encodeCoord(1, 1, 1); + chunk.blockData.set(baseCode, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const baseIndex = (1 << 8) | (1 << 4) | 1; + const baseBlockId = chunk.blockDataArray[baseIndex]; + assertTrue(baseBlockId > 0, '预填充方块应有 blockId'); + + // spy 全量重建 + let fullRebuildCalled = false; + const original = chunk._initArrayStorageFromBlockData.bind(chunk); + chunk._initArrayStorageFromBlockData = () => { + fullRebuildCalled = true; + original(); + }; + + // 追加实心方块 + const appended = chunk.appendScatteredBlocks( + [{ x: 5, y: 2, z: 5, type: 'dirt', orientation: 0 }], + new Set([Chunk.encodeCoord(5, 2, 5)]), + [], + { deferConsolidation: true } + ); + + assertEqual(appended, 1, '应追加 1 个方块'); + assertFalse(fullRebuildCalled, '不应调用全量 _initArrayStorageFromBlockData'); + + // 行为断言:新方块已写入 blockDataArray + const newIndex = (2 << 8) | (5 << 4) | 5; + assertTrue(chunk.blockDataArray[newIndex] > 0, '新方块应有 blockId'); + + // 行为断言:旧方块未被破坏 + assertEqual(chunk.blockDataArray[baseIndex], baseBlockId, '预填充方块 blockId 不应被清除'); + + // 行为断言:solidBlockIds 包含实心方块 + const newBlockId = chunk.blockDataArray[newIndex]; + assertTrue(chunk.solidBlockIds.has(newBlockId), '实心方块应加入 solidBlockIds'); + + teardownEnvironment(); +}); + +test('appendScatteredBlocks - 追加非实心方块不应加入 solidBlockIds', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + chunk._initArrayStorageFromBlockData(); + + // 追加非实心方块(glass_block 在 test-mocks 中定义为非 solid) + chunk.appendScatteredBlocks( + [{ x: 3, y: 1, z: 3, type: 'glass_block', orientation: 0 }], + new Set([Chunk.encodeCoord(3, 1, 3)]), + [], + { deferConsolidation: true } + ); + + const blockIndex = (1 << 8) | (3 << 4) | 3; + const blockId = chunk.blockDataArray[blockIndex]; + assertTrue(blockId > 0, '非实心方块也应有 blockId'); + assertFalse(chunk.solidBlockIds.has(blockId), '非实心方块不应加入 solidBlockIds'); + + teardownEnvironment(); +}); + +test('appendScatteredBlocks - 带 orientation 方块应正确写入 blockDataArray', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + chunk._initArrayStorageFromBlockData(); + + chunk.appendScatteredBlocks( + [{ x: 7, y: 3, z: 7, type: 'wood', orientation: 2 }], + new Set([Chunk.encodeCoord(7, 3, 7)]), + [], + { deferConsolidation: true } + ); + + const blockIndex = (3 << 8) | (7 << 4) | 7; + const blockId = chunk.blockDataArray[blockIndex]; + assertTrue(blockId > 0, '带 orientation 方块应有 blockId'); + + // 验证 palette 中存储了完整 entry(含 orientation) + const entry = chunk.blockPalette.get(blockId); + assertTrue(typeof entry === 'object' && entry.orientation === 2, + 'palette 应保留 orientation 信息'); + + teardownEnvironment(); +}); + +test('appendScatteredBlocks - 通过 authority store 路径追加方块后 blockDataArray 与 store slice 一致', () => { + setupEnvironment(); + + // WorldBlockDataStore 已在文件顶部 import + const store = new WorldBlockDataStore(); + + const world = createMockWorld(); + world.worldBlockDataStore = store; + const chunk = new Chunk(0, 0, world); + + // attach authority slice — 使用 ensureChunkSlice 获取或创建 slice + store.replaceChunkSlice(0, 0, new Map(), 'test-setup'); + chunk.blockData = store.ensureChunkSlice(0, 0); + chunk._initArrayStorageFromBlockData(); + + // spy 全量重建 + let fullRebuildCalled = false; + chunk._initArrayStorageFromBlockData = () => { fullRebuildCalled = true; }; + + chunk.appendScatteredBlocks( + [{ x: 4, y: 2, z: 4, type: 'stone', orientation: 0 }], + new Set([Chunk.encodeCoord(4, 2, 4)]), + [], + { deferConsolidation: true } + ); + + assertFalse(fullRebuildCalled, 'authority store 路径也不应触发全量重建'); + + // store slice 应已包含新方块 + const code = Chunk.encodeCoord(4, 2, 4); + assertTrue(chunk.blockData.has(code), 'authority store slice 应包含追加的方块'); + + // blockDataArray 应与 slice 一致 + const blockIndex = (2 << 8) | (4 << 4) | 4; + assertTrue(chunk.blockDataArray[blockIndex] > 0, 'blockDataArray 应已更新'); + + teardownEnvironment(); +}); +``` + +- [ ] **Step 2: 运行测试,验证失败** + +Run: `node command/run-tests.js --verbose` +Expected: FAIL — `fullRebuildCalled` 为 true + +- [ ] **Step 3: 实现 — 替换全量重建为增量更新** + +> **导入确认**:`parseBlockEntry` 已在 `Chunk.js:14` 从 `OrientationUtils.js` 导入,无需额外添加。 + +在 `src/world/Chunk.js:3967-3968`,将: + +```javascript + this.dirtyBlocks += appendedCount; + this._initArrayStorageFromBlockData(); +``` + +替换为: + +```javascript + this.dirtyBlocks += appendedCount; + for (const [code] of patches) { + const actualEntry = this.blockData.get(code); + if (!actualEntry) continue; + const parsed = parseBlockEntry(actualEntry); + const type = parsed.type; + if (!type || type === 'air') continue; + const { x, y, z } = Chunk.decodeCoord(code); + const lx = x - this.cx * CHUNK_SIZE; + const ly = y - this.worldY; + const lz = z - this.cz * CHUNK_SIZE; + if (lx < 0 || lx >= CHUNK_SIZE || ly < 0 || ly >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) continue; + const blockIndex = (ly << 8) | (lz << 4) | lx; + const blockId = this._getOrCreateBlockId(actualEntry); + this.blockDataArray[blockIndex] = blockId; + const props = getBlockProps(type); + if (props.isSolid) this.solidBlockIds.add(blockId); + } +``` + +- [ ] **Step 4: 运行测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 5: 运行 lint** + +Run: `npm run lint` +Expected: 无新增警告 + +--- + +### Task 5: M6 — `_applyConsolidateResult` 去除多余 `_initArrayStorageFromBlockData` + +**Files:** +- Test: `src/tests/test-chunk.js` +- Modify: `src/world/Chunk.js:1549-1574` (`_updateBlockState` solidBlockIds 修复) +- Modify: `src/world/Chunk.js:2624-2631` (`_registerSpecialEntityCollision` 同一 solidBlockIds 共享 id bug) +- Modify: `src/world/ChunkConsolidation.js:460-461` + +**前置修复**:`_updateBlockState` 中 `solidBlockIds.delete(oldId)` 存在共享 id 误删 bug — 同一 blockId 可能被多个同类型方块共享,删除一块会将共享 id 从 solidBlockIds 移除。当前 consolidation 的全量重建掩盖了此 bug,M6 移除重建后会暴露。 + +- [ ] **Step 0.1: 写失败测试 — 两块同类型方块删其一后另一块仍 solid** + +```javascript +test('_updateBlockState - 两块同类型方块共享 blockId,删除其中一块后另一块仍应在 solidBlockIds 中', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置两块 stone(共享同一 blockId) + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 1, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const idx1 = (1 << 8) | (5 << 4) | 5; + const idx2 = (1 << 8) | (6 << 4) | 6; + const stoneBlockId = chunk.blockDataArray[idx1]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertEqual(chunk.blockDataArray[idx2], stoneBlockId, '两块 stone 应共享同一 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 删除第一块 stone(设为 air) + chunk.blockData.set(code1, 'air'); + chunk._updateBlockState(5, 1, 5, 'air', 'air'); + + // 另一块 stone 仍在,其 blockId 应仍在 solidBlockIds 中 + assertTrue(chunk.solidBlockIds.has(stoneBlockId), + '删除共享 blockId 的一块后,另一块仍存在,solidBlockIds 不应移除该 id'); + + teardownEnvironment(); +}); +``` + +在同一测试文件中再添加两个替换场景测试: + +```javascript +test('_updateBlockState - 两块同类型方块共享 blockId,替换其中一块为其他类型,另一块仍 solid', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置两块 stone(共享同一 blockId) + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 1, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const idx1 = (1 << 8) | (5 << 4) | 5; + const stoneBlockId = chunk.blockDataArray[idx1]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 替换第一块 stone 为 glass_block(非实心) + chunk.blockData.set(code1, 'glass_block'); + chunk._updateBlockState(5, 1, 5, 'glass_block', 'glass_block'); + + // 另一块 stone 仍在,其 blockId 应仍在 solidBlockIds 中 + assertTrue(chunk.solidBlockIds.has(stoneBlockId), + '替换共享 blockId 的一块后,另一块仍存在,solidBlockIds 不应移除该 id'); + + teardownEnvironment(); +}); + +test('_updateBlockState - 替换唯一一块方块后旧 blockId 应从 solidBlockIds 中移除(回归)', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置一块 stone + const code1 = Chunk.encodeCoord(5, 1, 5); + chunk.blockData.set(code1, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const stoneBlockId = chunk.blockDataArray[(1 << 8) | (5 << 4) | 5]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 替换成 glass_block(非实心) + chunk.blockData.set(code1, 'glass_block'); + chunk._updateBlockState(5, 1, 5, 'glass_block', 'glass_block'); + + // stone blockId 不再被任何位置引用,应从 solidBlockIds 移除 + assertTrue(!chunk.solidBlockIds.has(stoneBlockId), + '替换后旧 stone blockId 不应继续留在 solidBlockIds 中'); + + teardownEnvironment(); +}); +``` + +在同一测试文件中再添加 `_registerSpecialEntityCollision` 的共享 id 测试(通过生产代码路径触发 bug): + +```javascript +test('_registerSpecialEntityCollision - 实体占位覆盖方块后,其他同类型方块的 blockId 仍应在 solidBlockIds 中', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置两块 stone + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 1, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const stoneBlockId = chunk.blockDataArray[(1 << 8) | (5 << 4) | 5]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 通过生产代码路径注册实体碰撞,覆盖 (5,1,5) 位置的 stone + chunk._registerSpecialEntityCollision('modGunMan', { id: 'test-entity', x: 5, y: 1, z: 5 }); + + // 另一块 stone 仍在,其 blockId 应仍在 solidBlockIds 中 + assertTrue(chunk.solidBlockIds.has(stoneBlockId), + '_registerSpecialEntityCollision 覆盖一块后,另一块同类型的 blockId 不应被误删'); + + teardownEnvironment(); +}); +``` + +- [ ] **Step 0.2: 运行测试,预期失败** + +Run: `node command/run-tests.js --verbose` +Expected: FAIL — 3 个测试飘红:(1) 删除共享 id:buggy 代码无 includes 检查,误删仍被引用的 id;(2) 替换共享 id:buggy 代码无 includes 检查,替换一块后另一块的共享 id 被误删;(3) 实体碰撞注册:`_registerSpecialEntityCollision` 直接 delete 不检查引用。单块替换回归测试在 buggy 代码上行为恰好正确,会通过 + +- [ ] **Step 0.3: 修复 — _updateBlockState 和 _registerSpecialEntityCollision 中 solidBlockIds.delete 加引用检查** + +**修复 1**:在 `src/world/Chunk.js:1550-1564`,将 `solidBlockIds.delete(oldId)` 改为扫描确认该 id 不再被其他位置使用后才 delete。 + +完整替换 `src/world/Chunk.js:1549-1574`(`if (blockIndex >= 0) {` 到对应的 `}`): + +```javascript + if (blockIndex >= 0) { + if (type === 'air') { + // 清空数组位置 + const oldId = this.blockDataArray[blockIndex]; + if (oldId !== 0) { + this.blockDataArray[blockIndex] = 0; + // 仅当该 blockId 不再被任何其他位置引用时才从 solidBlockIds 移除 + if (!this.blockDataArray.includes(oldId)) { + this.solidBlockIds.delete(oldId); + } + } + } else { + // 获取或创建 blockId + const blockId = this._getOrCreateBlockId(entry); + const oldId = this.blockDataArray[blockIndex]; + // 先写入新 id,再检查旧 id 是否还被引用 + // (必须先写,否则 includes(oldId) 会命中当前位置,永远返回 true) + this.blockDataArray[blockIndex] = blockId; + if (oldId !== 0 && oldId !== blockId) { + // 仅当旧 blockId 不再被任何其他位置引用时才从 solidBlockIds 移除 + if (!this.blockDataArray.includes(oldId)) { + this.solidBlockIds.delete(oldId); + } + } + // 如果是实心方块,加入 solid set + if (props.isSolid) { + this.solidBlockIds.add(blockId); + } else { + this.solidBlockIds.delete(blockId); + } + } + } +``` + +> **注意**:`blockDataArray.includes(oldId)` 对 4096 元素的 Uint32Array 是 O(4096) 扫描,但 `_updateBlockState` 只在单方块放置/删除时调用,不在批量路径中,开销可接受。如果后续性能分析发现瓶颈,可改为 refcount Map。 + +**修复 2**:在 `src/world/Chunk.js:2626-2629`(`_registerSpecialEntityCollision`),将: + +```javascript + const oldBlockId = this.blockDataArray[blockIndex]; + if (oldBlockId !== 0) { + this.blockDataArray[blockIndex] = 0; + this.solidBlockIds.delete(oldBlockId); + } +``` + +改为: + +```javascript + const oldBlockId = this.blockDataArray[blockIndex]; + if (oldBlockId !== 0) { + this.blockDataArray[blockIndex] = 0; + if (!this.blockDataArray.includes(oldBlockId)) { + this.solidBlockIds.delete(oldBlockId); + } + } +``` + +- [ ] **Step 0.4: 运行测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 1: 写失败测试 — consolidation 不触发全量重建 + 行为一致性** + +在 `src/tests/test-chunk.js` 中添加测试(在 "_applyConsolidateResult" 测试分组附近): + +```javascript +test('_applyConsolidateResult - 不应调用 _initArrayStorageFromBlockData 且数据保持一致', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 预填充方块并初始化 blockDataArray + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 2, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'dirt'); + chunk._initArrayStorageFromBlockData(); + chunk.solidBlocks.add(code1); + chunk.solidBlocks.add(code2); + chunk.visibleKeys.add(code1); + chunk.visibleKeys.add(code2); + + // 记录 consolidation 前的 blockDataArray 值 + const idx1 = (1 << 8) | (5 << 4) | 5; + const idx2 = (2 << 8) | (6 << 4) | 6; + const preBlockId1 = chunk.blockDataArray[idx1]; + const preBlockId2 = chunk.blockDataArray[idx2]; + assertTrue(preBlockId1 > 0, 'stone 应有 blockId'); + assertTrue(preBlockId2 > 0, 'dirt 应有 blockId'); + + // spy 全量重建 + let fullRebuildCalled = false; + chunk._initArrayStorageFromBlockData = () => { + fullRebuildCalled = true; + }; + + // 模拟 consolidation 回包 + chunk.isConsolidating = true; + chunk.dirtyBlocks = 1; + chunk._applyConsolidateResult( + { + scatteredBlocks: [ + { x: 5, y: 1, z: 5, type: 'stone', orientation: 0 }, + { x: 6, y: 2, z: 6, type: 'dirt', orientation: 0 } + ], + meshData: [], + visibleKeys: ['5,1,5', '6,2,6'], + solidBlocks: ['5,1,5', '6,2,6'], + structureCenters: [] + }, + 1, + new Set() + ); + + assertFalse(fullRebuildCalled, 'consolidation 不改 authority,不应触发全量 _initArrayStorageFromBlockData'); + + // 行为断言:blockDataArray 未被清零(因为没有调用全量重建,值应保持) + assertEqual(chunk.blockDataArray[idx1], preBlockId1, 'consolidation 后 stone 的 blockId 应保持'); + assertEqual(chunk.blockDataArray[idx2], preBlockId2, 'consolidation 后 dirt 的 blockId 应保持'); + + teardownEnvironment(); +}); +``` + +- [ ] **Step 2: 运行测试,验证失败** + +Run: `node command/run-tests.js --verbose` +Expected: FAIL — `fullRebuildCalled` 为 true,且 blockDataArray 被全量重建的 spy 清零 + +- [ ] **Step 3: 实现 — 删除多余的 _initArrayStorageFromBlockData 调用** + +在 `src/world/ChunkConsolidation.js:460-461`,删除: + +```javascript + // 重建数组存储,确保 blockDataArray 与 blockData 权威源同步 + this._initArrayStorageFromBlockData(); +``` + +- [ ] **Step 4: 运行测试,验证通过** + +Run: `node command/run-tests.js` +Expected: PASS + +- [ ] **Step 5: 运行 lint** + +Run: `npm run lint` +Expected: 无新增警告 + +--- + +### Task 6: 全量回归验证 + +**Files:** 无新增文件 + +- [ ] **Step 1: 运行全部测试** + +Run: `node command/run-tests.js --verbose` +Expected: 全部 PASS + +- [ ] **Step 2: 运行 lint 检查** + +Run: `npm run lint` +Expected: 无新增警告 + +- [ ] **Step 3: 手动验证清单** + +启动开发服务器 `npm run start`,在浏览器中验证以下场景: + +1. 放置方块 → 离开 chunk → 返回 → 方块仍在 +2. 跨 chunk 结构(树、建筑)生成正确,无缺失 +3. AO 阴影正常渲染,无回退到中性值 +4. 碰撞检测正常,不穿模 +5. 持续奔跑 1-2 分钟,观察是否有渲染异常 +6. 静态树附近 chunk 加载优先级正确(boost 生效) diff --git a/docs/superpowers/specs/2026-05-13-derived-data-cleanup-design.md b/docs/superpowers/specs/2026-05-13-derived-data-cleanup-design.md new file mode 100644 index 0000000..bcbd2d3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-derived-data-cleanup-design.md @@ -0,0 +1,190 @@ +# 派生数据清理与增量更新优化设计 + +> **前置依赖**:blockData authority unification(已完成) +> +> **核心原则**:`WorldBlockDataStore._slices` 是全量永驻的权威数据源,永不淘汰。所有为渲染和运行时服务的派生数据只需在玩家坐标附近有效,chunk unload 时应释放。 + +## 1. 问题概述 + +blockData authority 统一后,runtime 权威闭环已稳定。但以下非权威的派生数据在 chunk unload 时未释放,随玩家持续探索而无限增长;同时部分派生索引的重建策略过于粗暴,产生不必要的 CPU 开销。 + +### 1.1 内存问题 + +| 数据 | 位置 | 本质 | 释放机制 | +|------|------|------|---------| +| RegionCache 中的 blockData plain object | `WorldRuntime._regionCache` | **冗余副本** — 与 authority 重复 | LRU 淘汰(32 region),但 blockData 本身不删 | +| `_dirtyChunks` 条目 | `WorldRuntime._dirtyChunks` | **已废弃的状态残留** — flush 已旁路 | 从不清理 | +| `_staticTreeTerrainBoostChunkKeys` | `World._staticTreeTerrainBoostChunkKeys` | **派生标记** — 可重新计算,但来源是"源 chunk 的 structureCenters"而非"目标 chunk 自身" | 只 add 不 delete | +| ~~`WorldChunkPayloadRegistry._payloads`~~ | ~~`WorldChunkPayloadRegistry`~~ | ~~派生数据~~ **实为独立权威数据** — 保存 runtimeSeedData/staticEntities,authority 命中路径从此恢复,不会回读 IndexedDB | `removeChunkPayload` 已定义但从未调用(**不应 unload 时删除**) | + +### 1.2 CPU 问题 + +| 场景 | 位置 | 问题 | +|------|------|------| +| `appendScatteredBlocks` | Chunk.js:3968 | 追加 5-20 个方块后全量重建 `blockDataArray`(遍历 500-2000 条目) | +| `_applyConsolidateResult` | ChunkConsolidation.js:461 | consolidation 不改 authority,但回包后仍全量重建 `blockDataArray` | + +## 2. 不做的事 + +- 不清理废弃 flush 代码(`flushChunk` / `flushBeforeUnload` / `flushAllDirty` 等) +- 不优化 Consolidation 的 postMessage 双重序列化 +- 不清理 `SpecialEntitiesShadowStore`(特殊实体数量极少,永驻合理) +- 不清理 `WorldChunkRegistry._entries`(条目极小,且是权威元数据) +- 不清理 `WorldChunkPayloadRegistry._payloads`(独立权威数据,authority 命中路径依赖此恢复 staticEntities / structureCenters,unload 时删除会丢实体)。**已知限制**:`_payloads` 会随 `WorldGenerationService` 为每个生成的 chunk 写入 runtimeSeedData/staticEntities 而全量增长。后续可拆分 payload 权威性——最小不可推导的 seed/结构中心长期保存,staticEntities 若能从 blockData/seed 确定性重建则按距离释放——但需先实现重建逻辑和测试,本轮不做 +- 不改 `WorldBlockDataStore._slices` 的生命周期(全量永驻是设计原则) +- 不清理 `PersistenceWorker` 端的 `regionCache`(max 6 region,独立 Worker 线程,与主线程 RegionCache 无关,本轮只聚焦主线程内存) +- 不优化 `ensureRegion()` 从 Worker 传入完整 region 后再剥离 blockData 造成的**峰值内存拷贝**(region 在 postMessage 反序列化时带完整 blockData 进入主线程,strip 后才释放)。后续可在 Worker 侧预剥离或改用 `getRegionMetadata` 只传元数据 +- 不重命名性能计量字段(如 `initArrayStorageMs`),保持与已有日志/监控一致 + +## 3. 改动清单 + +### M1: RegionCache 去除 blockData 冗余副本 + +**目标**:消除 RegionCache 中与 `WorldBlockDataStore` 重复的 blockData plain object 副本。 + +**背景**:cold import 路径从 IndexedDB 读出 `chunkRecord`(含 plain object 格式 blockData),存入 RegionCache,再 `deserializeBlockData` 写入 `WorldBlockDataStore`。之后 RegionCache 里的 plain object 副本再无人读,但始终占据内存。最多 32 region × 64 chunk = 2048 chunk 的冗余 blockData。 + +**改动**: + +采用统一 helper 而非逐点修改,避免遗漏: + +1. 在 `WorldRuntime` 中新增 `_stripBlockDataFromRegionRecord(region)` 方法,遍历 `region.chunks` 删除每个 chunk 的 `blockData` 字段,返回浅克隆(不修改原始对象) +2. 所有 `this._regionCache.set(...)` 调用处,对 region 调用此 helper。涉及 **6 个 `_regionCache.set` 调用**: + - `_upsertRegionCacheChunkRecord`(L109)— 构建新 region 对象(不修改旧缓存引用)后使用 helper 写入 cache + - `ensureRegion`(L761)— 在 `_regionCache.set` 处使用 helper + - `flushAllDirty`(L346、L353、L366)— 在 3 个 `_regionCache.set` 处使用 helper + - `flushPendingUnloadQueueWithinBudget`(L683)— 在 `_regionCache.set` 处使用 helper +3. `_updateRegionCacheChunkRecord`(L535)— 无 `_regionCache.set`(原地修改缓存引用),对单条 chunkRecord per-record 剥离 blockData +4. `World.js` cold import 路径(约 L392-401):成功写入 authority 后,将 `result.chunkRecord.blockData` 置为 `null` + +**关键约束 — full-save 路径不能用 stripped cache 做基底**:M1 剥离 RegionCache blockData 后,所有从 `_regionCache.get()` 取 region 再做 `saveRegionRecord` 的路径都不安全 — stripped region 中未被 flush 的其他 chunk 的 blockData 会随整包写盘丢失。涉及 `flushAllDirty` 的 `else if (region)` 分支和 `flushPendingUnloadQueueWithinBudget` 的 `else` fallback。**缓解措施**:(1) 这些分支优先使用 `applyRegionPatch`(patch-only,不影响其他 chunk);(2) 无 patch API 时从 `worldStore.getRegionRecord()` 重读完整 region 再属性 merge(`{ ...existing, ...chunkRecord }`),不用 stripped cache 做整包 save 基底;(3) 写盘成功后将 `group.chunks` 新数据 merge 回 region cache 再 strip,确保 cache 元数据(staticEntities 等)不陈旧;(4) `preserveStoredBlockData` 路径改为 `delete chunkRecord.blockData`,让 patch 路径自然保留 IndexedDB 中已有数据,full-save 路径通过属性 merge 保留重读 region 的 blockData,不用 `continue` 跳过以避免 entry 永久残留或静默丢数据。 + +**影响**:`_resolveSerializedBlockData` 中从 RegionCache 读 blockData 的 fallback 路径本轮不改代码,但实际会读到 undefined,等价于走其他 fallback。这些 fallback 路径属于已废弃的 flush 链路,本轮不影响 runtime 正确性。 + +**测试**:需同步更新 `src/tests/test-world-runtime.js` 中断言 RegionCache 保留 blockData 的用例(L46 等处)。 + +### M2: chunk unload 时清理 runtime 残留(`_dirtyChunks` + `pendingUnloadFlushQueue` + `_flushTimers`) + +**目标**:chunk 卸载时清除 `WorldRuntime` 中与该 chunk 关联的所有 deprecated 运行时残留。 + +**背景**:`recordBlockMutation` 每次编辑都追加条目到 `_dirtyChunks`。flush 已旁路不再消费这些条目,chunk unload 也不调用 `clearChunkDirty`(该方法已存在于 WorldRuntime.js:203)。此外 `pendingUnloadFlushQueue` 和 `_flushTimers` 也属于 deprecated flush 链路残留,在热路径中可能缓慢增长。 + +**改动**: +1. 在 `WorldRuntime` 中新增 `clearChunkRuntimeResidue(cx, cz)` 方法,统一清理 `_dirtyChunks`、`pendingUnloadFlushQueue`、`_flushTimers` 中的对应条目 +2. `World.js` chunk unload 循环中,`chunk.dispose()` 前调用 `this.worldRuntime?.clearChunkRuntimeResidue(chunk.cx, chunk.cz)` + +**语义决策**:`_dirtyChunks` 的注释称"仅用于观测和导出标记"(WorldRuntime.js:165),chunk unload 时清理会丢失这一标记。本轮认为这是可接受的:authority unification 后,`WorldBlockDataStore` 是写入权威,持久化/导出脏判断应由 store 层面承担(如 `_versions` + baseline version 或独立 export-dirty 集合),不应依赖 runtime dirty set。本轮不实现 store 层面的 dirty 追踪,但明确 `_dirtyChunks` 仅保留当前 loaded chunk 的派生状态。 + +### M3: chunk topology 变化后重建 `_staticTreeTerrainBoostChunkKeys` + +**目标**:chunk 卸载后清理过期的 boost key,避免 Set 无限增长。 + +**背景**:该 Set 记录"包含静态树的 chunk 周围需要地形增强的**目标** chunk key"。同一个目标 key 可能由**多个不同源 chunk** 的 structureCenters 标记。逐 chunk 直接 delete 语义不安全 — 可能删除仍被其他已加载源 chunk 标记的 key,导致返回该 chunk 时失去有效的 boost 优先级。 + +**改动**:提取 `_rebuildStaticTreeTerrainBoostChunkKeys()` helper 方法封装重建逻辑(清空 Set + 遍历已加载 chunk 的 structureCenters 重新 add),在 `chunkTopologyChanged` 分支中调用。提取 helper 使测试可以直接调用生产代码路径验证,避免测试重复实现而失去回归防护能力。 + +```javascript +_rebuildStaticTreeTerrainBoostChunkKeys() { + this._staticTreeTerrainBoostChunkKeys.clear(); + for (const [, ch] of this.chunks) { + this._markStaticTreeTerrainBoostFromChunk(ch); + } +} + +// 在 chunkTopologyChanged 分支中(已有 clearBlockLookupCaches 调用之后) +this._rebuildStaticTreeTerrainBoostChunkKeys(); +``` + +**代价**:每次 topology 变化时遍历已加载 chunk 的 structureCenters(通常 50-80 个 chunk,每个 0-5 个 center),开销极小。 + +### ~~M4: chunk unload 时释放 `WorldChunkPayloadRegistry` payload~~ [已撤回] + +**撤回原因**:`WorldChunkPayloadRegistry` 实为独立权威数据,不是"可从 blockData 反推"的派生数据。authority 命中路径(World.js:323-355)直接从 payload registry 恢复 `staticEntities` 和 `structureCenters`,命中后不再回读 IndexedDB。如果 unload 时删除 payload,返回该 chunk 时只能恢复 blockData,modGunMan、rovers、structureCenters 会丢失。 + +除非先实现"从 blockData/seed 确定性重建 payload"的代码和测试,否则 `WorldChunkPayloadRegistry` 仍应被视为独立权威数据,不应在 unload 时清理。 + +### M5: `appendScatteredBlocks` 增量更新 `blockDataArray` + +**目标**:将追加跨 chunk 方块后的 `blockDataArray` 重建从 O(n) 降为 O(k)(k = 追加方块数)。 + +**背景**:`appendScatteredBlocks`(Chunk.js:3886)已在 L3936-3946 对 `solidBlocks` 和 `lightSourceCoords` 做了增量更新,但 L3968 调用 `_initArrayStorageFromBlockData()` 全量重建 `blockDataArray`/`blockPalette`/`solidBlockIds`。追加通常只有 5-20 个方块,全量遍历 500-2000 条目不合理。 + +**改动**:将 L3968 的 `this._initArrayStorageFromBlockData()` 替换为增量更新逻辑: + +```javascript +for (const [code] of patches) { + const entry = this.blockData.get(code); + if (!entry) continue; + const parsed = parseBlockEntry(entry); + const type = parsed.type; + if (!type || type === 'air') continue; + const { x, y, z } = Chunk.decodeCoord(code); + const lx = x - this.cx * CHUNK_SIZE; + const ly = y - this.worldY; + const lz = z - this.cz * CHUNK_SIZE; + if (lx < 0 || lx >= CHUNK_SIZE || ly < 0 || ly >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) continue; + const blockIndex = (ly << 8) | (lz << 4) | lx; + const blockId = this._getOrCreateBlockId(entry); + this.blockDataArray[blockIndex] = blockId; + const props = getBlockProps(type); + if (props.isSolid) this.solidBlockIds.add(blockId); +} +``` + +**兼容性**:`_getOrCreateBlockId` 在增量场景下仍正确 — 新方块类型会自动追加到 `blockPalette`/`blockPaletteReverse` 中。 + +### M6: `_applyConsolidateResult` 去除多余 `_initArrayStorageFromBlockData` + +**目标**:consolidation 回包后不再全量重建 `blockDataArray`。 + +**背景**:consolidation 的设计契约是不改 authority — 它只更新派生层(mesh、visibleKeys、solidBlocks)。`_syncVisibilityAndCollision`(L430)已用 Worker 回包更新了可见性和碰撞索引。既然 blockData 没变,`blockDataArray` 不需要重建。 + +**改动**:删除 ChunkConsolidation.js:461 的 `this._initArrayStorageFromBlockData()` 调用。 + +## 4. 统一 chunk unload 清理顺序 + +M2 改动在 `World.js` chunk unload 循环内执行。M3 改为 topology 变化后批量重建,在循环外执行。M4 已撤回。 + +``` +for (const [key, chunk] of this.chunks) { + if (超出渲染距离) { + // 1. 通知 MinecartManager(已有) + // 2. 清理方块分发 buffer(已有) + // 3. 从场景移除(已有) + // 4. 清理 Face Culling 更新队列(已有) + // 5. [新增] 清理 runtime 残留(dirty + pendingUnload + flushTimer) + // 6. 释放 GPU 资源、detach authority slice(已有 chunk.dispose()) + // 7. 从 chunks Map 移除(已有) + } +} + +if (chunkTopologyChanged) { + // ... 已有:clearBlockLookupCaches, requestShadowMapUpdate + // [新增] 从已加载 chunks 重建 _staticTreeTerrainBoostChunkKeys +} +``` + +## 5. 验收标准 + +1. 玩家持续奔跑 5 分钟后,RegionCache 中不存在任何 chunk 的 blockData plain object 副本(通过统一 helper `_stripBlockDataFromRegionRecord` 覆盖全部 7 个 RegionCache 写入路径:6 个 `_regionCache.set` 调用 — `_upsertRegionCacheChunkRecord`(L109)、`ensureRegion`(L761)、`flushAllDirty`(L346/L353/L366)、`flushPendingUnloadQueueWithinBudget`(L683);以及 `_updateRegionCacheChunkRecord`(L535) 原地更新路径采用 per-record 剥离) +2. chunk unload 后,`worldRuntime._dirtyChunks`、`pendingUnloadFlushQueue`、`_flushTimers` 中不残留对应条目 +3. chunk topology 变化后,`_staticTreeTerrainBoostChunkKeys` 仅包含当前已加载 chunk 产生的 boost key +4. ~~chunk unload 后,`worldChunkPayloadRegistry` 中不残留对应 payload~~ [已撤回 — payload 是独立权威数据] +5. `appendScatteredBlocks` 追加少量方块时,不触发全量 `_initArrayStorageFromBlockData`;追加非实心方块、带 orientation 方块后 blockDataArray 正确 +6. consolidation 完成后,不触发 `_initArrayStorageFromBlockData`;consolidation 后 blockDataArray、solidBlockIds 与 blockData 一致 +7. 所有现有测试通过(`node command/run-tests.js`),`test-world-runtime.js` 中断言 RegionCache 保留 blockData 的用例已同步更新 +8. 手动验证:放置方块 → 离开 chunk → 返回,方块仍在 +9. 手动验证:跨 chunk 结构生成正确,AO 和碰撞无回退 +10. 控制台验证(跑图 5 分钟后在 DevTools 中检查): + - `game.world.worldRuntime._regionCache` 所有 chunk record 无 `blockData` 字段 + - `game.world.worldRuntime._dirtyChunks.size` / `pendingUnloadFlushQueue.size` / `_flushTimers.size` 不随已卸载 chunk 增长 + - `game.world._staticTreeTerrainBoostChunkKeys` 中所有 key 均可由当前已加载 chunk 的 structureCenters 重新推导得到(boost key 是目标 chunk key,不一定已加载) + +## 6. 风险 + +1. **M1 风险 — 导出路径**:未来恢复持久化时需要导出 blockData,不能再从 RegionCache 取。需改为从 `WorldBlockDataStore` 序列化导出。这与 authority unification 设计文档中 `authority-based rewrite` 的方向一致。 +2. **M1 风险 — full-save 路径丢 blockData**:M1 剥离 RegionCache blockData 后,所有从 `_regionCache.get()` 取 region 再做 `saveRegionRecord` 的路径都不安全——stripped region 中未被 flush 的其他 chunk 的 blockData 会随整包写盘丢失。涉及 `flushAllDirty` 的 `else if (region)` 分支(WorldRuntime.js:347)和 `flushPendingUnloadQueueWithinBudget` 的 `else` fallback(WorldRuntime.js:652)。**缓解措施**:(1) 这些分支优先使用 `applyRegionPatch`(patch-only,不影响其他 chunk);(2) 无 patch API 时从 `worldStore.getRegionRecord()` 重读完整 region 再属性 merge(`{ ...existing, ...chunkRecord }`),不用 stripped cache 做整包 save 基底;(3) 写盘成功后将 `group.chunks` 新数据 merge 回 region cache 再 strip,确保 cache 元数据(staticEntities/runtimeSeedData 等)不陈旧;(4) `preserveStoredBlockData` 路径改为 `delete chunkRecord.blockData`,让 patch 路径自然保留已有数据,full-save 路径通过属性 merge 保留重读 region 的 blockData,不用 `continue` 跳过以避免 entry 永久残留或静默丢数据。 +3. ~~**M4 风险**~~:[已撤回] `WorldChunkPayloadRegistry` 实为独立权威数据,不能 unload 时清理。 +4. **M5 风险**:增量更新 `blockDataArray` 时 `_getOrCreateBlockId` 可能为相同类型创建不同 blockId(如果 palette 在上次全量重建后被清空过)。但在增量场景下 palette 不会被清空,只会追加,所以没有问题。 +5. **M6 风险 — solidBlockIds 共享 id 误删**:`_updateBlockState`(Chunk.js:1554)和 `_registerSpecialEntityCollision`(Chunk.js:2629)在删除/替换/覆盖方块时调用 `solidBlockIds.delete(oldId)`,但同一 blockId 可能被多个同类型方块共享。存在三个子问题:(a) **删除场景**(stone → air):删除一块 stone 会将 stone 的 blockId 从 solidBlockIds 移除,即使其他 stone 方块仍存在;(b) **替换场景**(stone → glass_block):替换分支中 `blockDataArray.includes(oldId)` 在写入新 id 之前执行,当前位置仍是 oldId,导致 includes 永远返回 true,旧 id 永不被移除;(c) **实体覆盖场景**:`_registerSpecialEntityCollision`(Chunk.js:2629)用 0 覆盖地形方块时直接 delete,没有 includes 检查。当前 consolidation 的全量重建(`_initArrayStorageFromBlockData`)掩盖了这些 bug。M6 移除重建后 bug 暴露。**缓解措施**:M6 实现前,先为 `solidBlockIds` 补充删除、替换、实体覆盖三个场景的测试;修复方案为在 `_updateBlockState` 和 `_registerSpecialEntityCollision` 中先写入新值再扫描 `blockDataArray` 确认旧 id 不再被引用后才 delete。 +6. **M6 风险 — 旧 bug 持续不一致**:如果存在某个旧 bug 导致 `blockDataArray` 在 consolidation 前已经和 blockData 不一致,去掉这次重建会让不一致持续。但按当前设计所有写入口都同步更新 `blockDataArray`,除 solidBlockIds 共享 id 问题外不一致不应发生。 From 47666a9927daf7f6d2972ee5ddf47badbe317c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=94=E8=B5=A4?= Date: Wed, 13 May 2026 18:50:03 +0800 Subject: [PATCH 17/17] =?UTF-8?q?refactor(world):=20=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=20=E2=80=94=20RegionCache=E5=8E=BB=E5=86=97=E4=BD=99?= =?UTF-8?q?=E3=80=81unload=E6=B8=85=E7=90=86=E3=80=81=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1: RegionCache 通过 _stripBlockDataFromRegionRecord 统一剥离 blockData 冗余副本, 覆盖全部 _regionCache.set 调用点,修复 full-save 路径丢数据风险。 M2: chunk unload 时清理 _dirtyChunks / pendingUnloadFlushQueue / _flushTimers 残留。 M3: chunk topology 变化后从已加载 chunks 重建 _staticTreeTerrainBoostChunkKeys, 避免 Set 无限增长。 M5: appendScatteredBlocks 增量更新 blockDataArray,O(n)→O(k)。 M6: 修复 solidBlockIds 共享 id 误删 bug(_updateBlockState / _registerSpecialEntityCollision); _applyConsolidateResult 去除多余的 _initArrayStorageFromBlockData 全量重建。 361/361 测试通过,零新增 lint 警告。 Co-Authored-By: Claude Sonnet 4.6 --- src/tests/test-chunk.js | 307 ++++++++++++++++++++++++++++++++ src/tests/test-world-runtime.js | 56 +++++- src/tests/test-world.js | 165 +++++++++++++++++ src/world/Chunk.js | 37 +++- src/world/ChunkConsolidation.js | 2 - src/world/World.js | 16 +- src/world/WorldRuntime.js | 145 ++++++++++----- 7 files changed, 671 insertions(+), 57 deletions(-) diff --git a/src/tests/test-chunk.js b/src/tests/test-chunk.js index aa030ca..ed70721 100644 --- a/src/tests/test-chunk.js +++ b/src/tests/test-chunk.js @@ -17,6 +17,7 @@ import * as THREE from 'three'; import { Chunk } from '../world/Chunk.js'; import { worldWorker, workerCallbacks } from '../world/ChunkConsolidation.js'; import { mockFaceCullingSystem, mockMaterials, mockBlockData } from './test-mocks.js'; +import { WorldBlockDataStore } from '../world/WorldBlockDataStore.js'; // 模拟 WorldWorker class MockWorldWorker { @@ -1767,4 +1768,310 @@ describe('Chunk 真实类测试', (test) => { } }); + test('appendScatteredBlocks - 追加方块后应增量更新 blockDataArray 而非全量重建', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 预填充一些方块 + const baseCode = Chunk.encodeCoord(1, 1, 1); + chunk.blockData.set(baseCode, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const baseIndex = (1 << 8) | (1 << 4) | 1; + const baseBlockId = chunk.blockDataArray[baseIndex]; + assertTrue(baseBlockId > 0, '预填充方块应有 blockId'); + + // spy 全量重建 + let fullRebuildCalled = false; + const original = chunk._initArrayStorageFromBlockData.bind(chunk); + chunk._initArrayStorageFromBlockData = () => { + fullRebuildCalled = true; + original(); + }; + + // 追加实心方块 + const appended = chunk.appendScatteredBlocks( + [{ x: 5, y: 2, z: 5, type: 'dirt', orientation: 0 }], + new Set([Chunk.encodeCoord(5, 2, 5)]), + [], + { deferConsolidation: true } + ); + + assertEqual(appended, 1, '应追加 1 个方块'); + assertFalse(fullRebuildCalled, '不应调用全量 _initArrayStorageFromBlockData'); + + // 行为断言:新方块已写入 blockDataArray + const newIndex = (2 << 8) | (5 << 4) | 5; + assertTrue(chunk.blockDataArray[newIndex] > 0, '新方块应有 blockId'); + + // 行为断言:旧方块未被破坏 + assertEqual(chunk.blockDataArray[baseIndex], baseBlockId, '预填充方块 blockId 不应被清除'); + + // 行为断言:solidBlockIds 包含实心方块 + const newBlockId = chunk.blockDataArray[newIndex]; + assertTrue(chunk.solidBlockIds.has(newBlockId), '实心方块应加入 solidBlockIds'); + + teardownEnvironment(); + }); + + test('appendScatteredBlocks - 追加非实心方块不应加入 solidBlockIds', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + chunk._initArrayStorageFromBlockData(); + + // 追加非实心方块(glass_block 在 test-mocks 中定义为非 solid) + chunk.appendScatteredBlocks( + [{ x: 3, y: 1, z: 3, type: 'glass_block', orientation: 0 }], + new Set([Chunk.encodeCoord(3, 1, 3)]), + [], + { deferConsolidation: true } + ); + + const blockIndex = (1 << 8) | (3 << 4) | 3; + const blockId = chunk.blockDataArray[blockIndex]; + assertTrue(blockId > 0, '非实心方块也应有 blockId'); + assertFalse(chunk.solidBlockIds.has(blockId), '非实心方块不应加入 solidBlockIds'); + + teardownEnvironment(); + }); + + test('appendScatteredBlocks - 带 orientation 方块应正确写入 blockDataArray', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + chunk._initArrayStorageFromBlockData(); + + chunk.appendScatteredBlocks( + [{ x: 7, y: 3, z: 7, type: 'wood', orientation: 2 }], + new Set([Chunk.encodeCoord(7, 3, 7)]), + [], + { deferConsolidation: true } + ); + + const blockIndex = (3 << 8) | (7 << 4) | 7; + const blockId = chunk.blockDataArray[blockIndex]; + assertTrue(blockId > 0, '带 orientation 方块应有 blockId'); + + // 验证 palette 中存储了完整 entry(含 orientation) + const entry = chunk.blockPalette.get(blockId); + assertTrue(typeof entry === 'object' && entry.orientation === 2, + 'palette 应保留 orientation 信息'); + + teardownEnvironment(); + }); + + test('appendScatteredBlocks - 通过 authority store 路径追加方块后 blockDataArray 与 store slice 一致', () => { + setupEnvironment(); + + const store = new WorldBlockDataStore(); + + const world = createMockWorld(); + world.worldBlockDataStore = store; + const chunk = new Chunk(0, 0, world); + + // attach authority slice — 使用 ensureChunkSlice 获取或创建 slice + store.replaceChunkSlice(0, 0, new Map(), 'test-setup'); + chunk.blockData = store.ensureChunkSlice(0, 0); + chunk._initArrayStorageFromBlockData(); + + // spy 全量重建 + let fullRebuildCalled = false; + chunk._initArrayStorageFromBlockData = () => { fullRebuildCalled = true; }; + + chunk.appendScatteredBlocks( + [{ x: 4, y: 2, z: 4, type: 'stone', orientation: 0 }], + new Set([Chunk.encodeCoord(4, 2, 4)]), + [], + { deferConsolidation: true } + ); + + assertFalse(fullRebuildCalled, 'authority store 路径也不应触发全量重建'); + + // store slice 应已包含新方块 + const code = Chunk.encodeCoord(4, 2, 4); + assertTrue(chunk.blockData.has(code), 'authority store slice 应包含追加的方块'); + + // blockDataArray 应与 slice 一致 + const blockIndex = (2 << 8) | (4 << 4) | 4; + assertTrue(chunk.blockDataArray[blockIndex] > 0, 'blockDataArray 应已更新'); + + teardownEnvironment(); + }); + + test('_updateBlockState - 两块同类型方块共享 blockId,删除其中一块后另一块仍应在 solidBlockIds 中', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置两块 stone(共享同一 blockId) + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 1, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const idx1 = (1 << 8) | (5 << 4) | 5; + const idx2 = (1 << 8) | (6 << 4) | 6; + const stoneBlockId = chunk.blockDataArray[idx1]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertEqual(chunk.blockDataArray[idx2], stoneBlockId, '两块 stone 应共享同一 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 删除第一块 stone(设为 air) + chunk.blockData.set(code1, 'air'); + chunk._updateBlockState(5, 1, 5, 'air', 'air'); + + // 另一块 stone 仍在,其 blockId 应仍在 solidBlockIds 中 + assertTrue(chunk.solidBlockIds.has(stoneBlockId), + '删除共享 blockId 的一块后,另一块仍存在,solidBlockIds 不应移除该 id'); + + teardownEnvironment(); + }); + + test('_updateBlockState - 两块同类型方块共享 blockId,替换其中一块为其他类型,另一块仍 solid', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置两块 stone(共享同一 blockId) + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 1, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const idx1 = (1 << 8) | (5 << 4) | 5; + const stoneBlockId = chunk.blockDataArray[idx1]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 替换第一块 stone 为 glass_block(非实心) + chunk.blockData.set(code1, 'glass_block'); + chunk._updateBlockState(5, 1, 5, 'glass_block', 'glass_block'); + + // 另一块 stone 仍在,其 blockId 应仍在 solidBlockIds 中 + assertTrue(chunk.solidBlockIds.has(stoneBlockId), + '替换共享 blockId 的一块后,另一块仍存在,solidBlockIds 不应移除该 id'); + + teardownEnvironment(); + }); + + test('_updateBlockState - 替换唯一一块方块后旧 blockId 应从 solidBlockIds 中移除', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置一块 stone + const code1 = Chunk.encodeCoord(5, 1, 5); + chunk.blockData.set(code1, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const stoneBlockId = chunk.blockDataArray[(1 << 8) | (5 << 4) | 5]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 替换成 glass_block(非实心) + chunk.blockData.set(code1, 'glass_block'); + chunk._updateBlockState(5, 1, 5, 'glass_block', 'glass_block'); + + // stone blockId 不再被任何位置引用,应从 solidBlockIds 移除 + assertTrue(!chunk.solidBlockIds.has(stoneBlockId), + '替换后旧 stone blockId 不应继续留在 solidBlockIds 中'); + + teardownEnvironment(); + }); + + test('_registerSpecialEntityCollision - 实体占位覆盖方块后,其他同类型方块的 blockId 仍应在 solidBlockIds 中', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 放置两块 stone + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 1, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'stone'); + chunk._initArrayStorageFromBlockData(); + + const stoneBlockId = chunk.blockDataArray[(1 << 8) | (5 << 4) | 5]; + assertTrue(stoneBlockId > 0, 'stone 应有 blockId'); + assertTrue(chunk.solidBlockIds.has(stoneBlockId), 'stone blockId 应在 solidBlockIds 中'); + + // 通过生产代码路径注册实体碰撞,覆盖 (5,1,5) 位置的 stone + chunk._registerSpecialEntityCollision('modGunMan', { id: 'test-entity', x: 5, y: 1, z: 5 }); + + // 另一块 stone 仍在,其 blockId 应仍在 solidBlockIds 中 + assertTrue(chunk.solidBlockIds.has(stoneBlockId), + '_registerSpecialEntityCollision 覆盖一块后,另一块同类型的 blockId 不应被误删'); + + teardownEnvironment(); + }); + + test('_applyConsolidateResult - 不应调用 _initArrayStorageFromBlockData 且数据保持一致', () => { + setupEnvironment(); + + const world = createMockWorld(); + const chunk = new Chunk(0, 0, world); + + // 预填充方块并初始化 blockDataArray + const code1 = Chunk.encodeCoord(5, 1, 5); + const code2 = Chunk.encodeCoord(6, 2, 6); + chunk.blockData.set(code1, 'stone'); + chunk.blockData.set(code2, 'dirt'); + chunk._initArrayStorageFromBlockData(); + chunk.solidBlocks.add(code1); + chunk.solidBlocks.add(code2); + chunk.visibleKeys.add(code1); + chunk.visibleKeys.add(code2); + + // 记录 consolidation 前的 blockDataArray 值 + const idx1 = (1 << 8) | (5 << 4) | 5; + const idx2 = (2 << 8) | (6 << 4) | 6; + const preBlockId1 = chunk.blockDataArray[idx1]; + const preBlockId2 = chunk.blockDataArray[idx2]; + assertTrue(preBlockId1 > 0, 'stone 应有 blockId'); + assertTrue(preBlockId2 > 0, 'dirt 应有 blockId'); + + // spy 全量重建 + let fullRebuildCalled = false; + chunk._initArrayStorageFromBlockData = () => { + fullRebuildCalled = true; + }; + + // 模拟 consolidation 回包 + chunk.isConsolidating = true; + chunk.dirtyBlocks = 1; + chunk._applyConsolidateResult( + { + scatteredBlocks: [ + { x: 5, y: 1, z: 5, type: 'stone', orientation: 0 }, + { x: 6, y: 2, z: 6, type: 'dirt', orientation: 0 } + ], + meshData: [], + visibleKeys: ['5,1,5', '6,2,6'], + solidBlocks: ['5,1,5', '6,2,6'], + structureCenters: [] + }, + 1, + new Set() + ); + + assertFalse(fullRebuildCalled, 'consolidation 不改 authority,不应触发全量 _initArrayStorageFromBlockData'); + + // 行为断言:blockDataArray 未被清零(因为没有调用全量重建,值应保持) + assertEqual(chunk.blockDataArray[idx1], preBlockId1, 'consolidation 后 stone 的 blockId 应保持'); + assertEqual(chunk.blockDataArray[idx2], preBlockId2, 'consolidation 后 dirt 的 blockId 应保持'); + + teardownEnvironment(); + }); + }); diff --git a/src/tests/test-world-runtime.js b/src/tests/test-world-runtime.js index 7cc0a87..b7a5dc9 100644 --- a/src/tests/test-world-runtime.js +++ b/src/tests/test-world-runtime.js @@ -43,7 +43,7 @@ describe('WorldRuntime 运行时工作集测试', (test) => { const cachedRegion = runtime._regionCache.get('0,0'); assertTrue(!!cachedRegion, '读取 chunk 后应向运行时 region cache 注入最小基线'); - assertDeepEqual(cachedRegion.chunks['0,0'].blockData, chunkRecord.blockData, '缓存中的 chunkRecord 应与读取结果一致'); + assertTrue(cachedRegion.chunks['0,0'].blockData === undefined, 'RegionCache 不应保留 blockData 冗余副本'); globalThis._worldStore = originalWorldStore; }); @@ -870,7 +870,7 @@ describe('WorldRuntime 运行时工作集测试', (test) => { globalThis._worldStore = originalWorldStore; }); - test('flushBeforeUnload - region-cache 来源时应复用已缓存 blockData,不在队列中复制整块数据', async () => { + test('flushBeforeUnload - region-cache 来源时应复用已存 blockData,不在队列中复制整块数据', async () => { const originalWorldStore = globalThis._worldStore; const cachedBlockCode = encodeCoord(1, 2, 3); const cachedBlockData = { @@ -882,6 +882,7 @@ describe('WorldRuntime 运行时工作集测试', (test) => { }; const runtime = new WorldRuntime(); + // 手动设置 cache(含 blockData):此测试验证 deprecated flushBeforeUnload 路径 runtime._regionCache.set('0,0', { regionKey: '0,0', rx: 0, @@ -907,7 +908,7 @@ describe('WorldRuntime 运行时工作集测试', (test) => { assertTrue(!!queuedRecord, '应生成 unload queue 记录'); assertEqual(queuedRecord.preserveStoredBlockData, true, 'region-cache 来源时应标记复用已存 blockData'); assertEqual(queuedRecord.chunkRecord.blockData, null, '队列里不应再复制整块 blockData'); - assertEqual(runtime._regionCache.get('0,0').chunks['0,0'].blockData, cachedBlockData, 'region cache 仍应继续复用原 blockData 引用'); + assertTrue(runtime._regionCache.get('0,0').chunks['0,0'].blockData === undefined, 'M1 后 RegionCache 不应保留 blockData'); globalThis._worldStore = originalWorldStore; }); @@ -972,4 +973,53 @@ describe('WorldRuntime 运行时工作集测试', (test) => { globalThis._worldStore = originalWorldStore; }); + + test('clearChunkRuntimeResidue - 应清理 dirtyChunks / pendingUnloadFlushQueue / _flushTimers', () => { + const originalWorldStore = globalThis._worldStore; + globalThis._worldStore = { getChunkRecord: async () => null }; + + const runtime = new WorldRuntime(); + runtime._dirtyChunks.set('0,0', { cx: 0, cz: 0, dirty: true }); + runtime.pendingUnloadFlushQueue.set('0,0', { chunkKey: '0,0' }); + const stubTimerId = setTimeout(() => {}, 0); + clearTimeout(stubTimerId); + runtime._flushTimers.set('0,0', stubTimerId); + + runtime.clearChunkRuntimeResidue(0, 0); + + assertFalse(runtime._dirtyChunks.has('0,0'), '_dirtyChunks 应已清理'); + assertFalse(runtime.pendingUnloadFlushQueue.has('0,0'), 'pendingUnloadFlushQueue 应已清理'); + assertFalse(runtime._flushTimers.has('0,0'), '_flushTimers 应已清理'); + + // 不存在的 chunk 调用不应报错 + runtime.clearChunkRuntimeResidue(99, 99); + + globalThis._worldStore = originalWorldStore; + }); + + test('_upsertRegionCacheChunkRecord - 存入 RegionCache 的 chunkRecord 不应包含 blockData', async () => { + const originalWorldStore = globalThis._worldStore; + globalThis._worldStore = { getChunkRecord: async () => null }; + + const runtime = new WorldRuntime(); + const chunkRecord = { + cx: 0, cz: 0, + blockData: { 123: 'stone', 456: 'dirt' }, + staticEntities: [{ type: 'tree' }], + runtimeSeedData: { structureCenters: [] } + }; + + runtime._upsertRegionCacheChunkRecord(0, 0, chunkRecord); + + const cachedRegion = runtime._regionCache.get('0,0'); + assertTrue(!!cachedRegion, '应已注入 region cache'); + const stored = cachedRegion.chunks['0,0']; + assertTrue( + stored.blockData === undefined, + '存入 RegionCache 的 chunkRecord 不应包含 blockData' + ); + assertDeepEqual(stored.staticEntities, [{ type: 'tree' }], 'staticEntities 应保留'); + + globalThis._worldStore = originalWorldStore; + }); }); diff --git a/src/tests/test-world.js b/src/tests/test-world.js index 9be5606..fb844d8 100644 --- a/src/tests/test-world.js +++ b/src/tests/test-world.js @@ -1578,6 +1578,171 @@ describe('World 真实类测试', (test) => { teardownEnvironment(); }); + test('chunk unload 后 runtime 残留(dirty/pendingUnload/flushTimer)不应残留', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + // 模拟 worldRuntime,注入 dirty / pendingUnload / flushTimer 三种残留 + const dirtyChunks = new Map(); + dirtyChunks.set('0,0', { cx: 0, cz: 0, dirty: true }); + const pendingUnloadFlushQueue = new Map(); + pendingUnloadFlushQueue.set('0,0', { chunkKey: '0,0' }); + const flushTimers = new Map(); + const stubTimerId = setTimeout(() => {}, 0); + clearTimeout(stubTimerId); + flushTimers.set('0,0', stubTimerId); + + let clearTimeoutCalledWith = null; + world.worldRuntime = { + _dirtyChunks: dirtyChunks, + pendingUnloadFlushQueue, + _flushTimers: flushTimers, + _chunkKey(cx, cz) { return `${cx},${cz}`; }, + clearChunkRuntimeResidue(cx, cz) { + const key = this._chunkKey(cx, cz); + this._dirtyChunks.delete(key); + this.pendingUnloadFlushQueue.delete(key); + const timer = this._flushTimers.get(key); + if (timer !== undefined) { + clearTimeoutCalledWith = timer; + clearTimeout(timer); + this._flushTimers.delete(key); + } + }, + ensureChunkData() { + return Promise.resolve({ status: 'missing-region' }); + }, + prefetchRegions() {} + }; + + // 移动到远处触发 unload + world.update(new THREE.Vector3(200, 10, 200), 0.016); + + assertFalse(world.chunks.has('0,0'), 'chunk 应已卸载'); + assertFalse(dirtyChunks.has('0,0'), 'unload 后 _dirtyChunks 不应残留'); + assertFalse(pendingUnloadFlushQueue.has('0,0'), 'unload 后 pendingUnloadFlushQueue 不应残留'); + assertFalse(flushTimers.has('0,0'), 'unload 后 _flushTimers 不应残留'); + + teardownEnvironment(); + }); + + test('chunk topology 变化后 _staticTreeTerrainBoostChunkKeys 应从已加载 chunks 重建', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + // 手动注入一个过期 boost key(模拟已卸载 chunk 遗留) + world._staticTreeTerrainBoostChunkKeys.add('99,99'); + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('99,99'), '应已注入过期 key'); + + world.worldRuntime = { + ensureChunkData() { + return Promise.resolve({ status: 'missing-region' }); + }, + prefetchRegions() {}, + clearChunkRuntimeResidue() {} + }; + + // 移动到远处触发 unload(会触发 chunkTopologyChanged) + world.update(new THREE.Vector3(200, 10, 200), 0.016); + + assertFalse(world._staticTreeTerrainBoostChunkKeys.has('99,99'), + 'topology 变化后过期 boost key 不应残留'); + + teardownEnvironment(); + }); + + test('chunk topology 重建时多源 chunk 产生的重叠 boost key 应保留', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + // 确保两个源 chunk 都已加载(防止断言被静默跳过) + const chunk00 = world.chunks.get('0,0'); + const chunk10 = world.chunks.get('1,0'); + assertTrue(!!chunk00, '测试前提:chunk 0,0 应已加载'); + assertTrue(!!chunk10, '测试前提:chunk 1,0 应已加载'); + + // 模拟两个源 chunk 都有 static_tree,且 boost 覆盖范围重叠于 '1,0' + chunk00.structureCenters = [ + { type: 'static_tree', x: 14, y: 10, z: 8 } + ]; + chunk10.structureCenters = [ + { type: 'static_tree', x: 18, y: 10, z: 8 } + ]; + + // 注入过期 key,然后通过生产代码 helper 重建 + world._staticTreeTerrainBoostChunkKeys.add('99,99'); + world._rebuildStaticTreeTerrainBoostChunkKeys(); + + // '1,0' 应存在(被两个源 chunk 共同标记),过期 key 应清除 + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('1,0'), + '多源 overlap 的 boost key 应在重建后保留'); + assertFalse(world._staticTreeTerrainBoostChunkKeys.has('99,99'), + '过期 boost key 应被清除'); + + teardownEnvironment(); + }); + + test('卸载一个源 chunk 后,另一个源产生的 boost key 仍应存在', async () => { + setupEnvironment(); + + scene = new THREE.Scene(); + world = new World(scene); + + world.update(new THREE.Vector3(0, 10, 0), 0.016); + await waitForChunkReady(world, '0,0'); + + world.bootstrapState.phase = 'runtime-streaming'; + + const chunk00 = world.chunks.get('0,0'); + const chunk10 = world.chunks.get('1,0'); + assertTrue(!!chunk00, '测试前提:chunk 0,0 应已加载'); + assertTrue(!!chunk10, '测试前提:chunk 1,0 应已加载'); + + // 两个源 chunk 都标记 '1,0' 为 boost target + chunk00.structureCenters = [ + { type: 'static_tree', x: 14, y: 10, z: 8 } + ]; + chunk10.structureCenters = [ + { type: 'static_tree', x: 18, y: 10, z: 8 } + ]; + + // 通过生产代码 helper 初始重建,确认 '1,0' 存在 + world._rebuildStaticTreeTerrainBoostChunkKeys(); + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('1,0'), + '初始重建后 1,0 应存在'); + + // 模拟卸载 chunk 0,0(移除后通过生产代码 helper 重建) + world.chunks.delete('0,0'); + world._rebuildStaticTreeTerrainBoostChunkKeys(); + + // chunk 1,0 仍在,它自身的 structureCenters 仍应标记 '1,0' + assertTrue(world._staticTreeTerrainBoostChunkKeys.has('1,0'), + '卸载一个源后,另一个源产生的 boost key 仍应存在'); + + teardownEnvironment(); + }); + test('dispose - 应尽力 flush WorldRuntime 的待处理写回工作', async () => { setupEnvironment(); diff --git a/src/world/Chunk.js b/src/world/Chunk.js index 30ce70b..98bc939 100644 --- a/src/world/Chunk.js +++ b/src/world/Chunk.js @@ -1551,19 +1551,24 @@ export class Chunk { // 清空数组位置 const oldId = this.blockDataArray[blockIndex]; if (oldId !== 0) { - this.solidBlockIds.delete(oldId); this.blockDataArray[blockIndex] = 0; + // 仅当该 blockId 不再被任何其他位置引用时才从 solidBlockIds 移除 + if (!this.blockDataArray.includes(oldId)) { + this.solidBlockIds.delete(oldId); + } } } else { // 获取或创建 blockId const blockId = this._getOrCreateBlockId(entry); - // 清空旧 id const oldId = this.blockDataArray[blockIndex]; + // 先写入新 id,再检查旧 id 是否还被引用 + // (必须先写,否则 includes(oldId) 会命中当前位置,永远返回 true) + this.blockDataArray[blockIndex] = blockId; if (oldId !== 0 && oldId !== blockId) { - this.solidBlockIds.delete(oldId); + if (!this.blockDataArray.includes(oldId)) { + this.solidBlockIds.delete(oldId); + } } - // 设置新 id - this.blockDataArray[blockIndex] = blockId; // 如果是实心方块,加入 solid set if (props.isSolid) { this.solidBlockIds.add(blockId); @@ -2626,7 +2631,9 @@ export class Chunk { const oldBlockId = this.blockDataArray[blockIndex]; if (oldBlockId !== 0) { this.blockDataArray[blockIndex] = 0; - this.solidBlockIds.delete(oldBlockId); + if (!this.blockDataArray.includes(oldBlockId)) { + this.solidBlockIds.delete(oldBlockId); + } } } }); @@ -3965,7 +3972,23 @@ export class Chunk { // 同步数组存储;跨 chunk 流式补片不立即抢占 WorldWorker 合并队列 this.dirtyBlocks += appendedCount; - this._initArrayStorageFromBlockData(); + for (const [code] of patches) { + const actualEntry = this.blockData.get(code); + if (!actualEntry) continue; + const parsed = parseBlockEntry(actualEntry); + const type = parsed.type; + if (!type || type === 'air') continue; + const { x, y, z } = Chunk.decodeCoord(code); + const lx = x - this.cx * CHUNK_SIZE; + const ly = y - this.worldY; + const lz = z - this.cz * CHUNK_SIZE; + if (lx < 0 || lx >= CHUNK_SIZE || ly < 0 || ly >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) continue; + const blockIndex = (ly << 8) | (lz << 4) | lx; + const blockId = this._getOrCreateBlockId(actualEntry); + this.blockDataArray[blockIndex] = blockId; + const props = getBlockProps(type); + if (props.isSolid) this.solidBlockIds.add(blockId); + } const t2 = globalThis.performance?.now?.() ?? Date.now(); recordChunkPerf('chunk.append-scattered-blocks', t2 - t0, { chunkKey: `${this.cx},${this.cz}`, diff --git a/src/world/ChunkConsolidation.js b/src/world/ChunkConsolidation.js index 156622a..d772ceb 100644 --- a/src/world/ChunkConsolidation.js +++ b/src/world/ChunkConsolidation.js @@ -457,8 +457,6 @@ export function extendChunk(Chunk) { this._unregisterLightSources(); this._registerLightSources(); - // 重建数组存储,确保 blockDataArray 与 blockData 权威源同步 - this._initArrayStorageFromBlockData(); const t7 = performance.now(); // 重置状态 diff --git a/src/world/World.js b/src/world/World.js index 25d0d92..391bfdb 100644 --- a/src/world/World.js +++ b/src/world/World.js @@ -400,6 +400,9 @@ export class World { this.worldBlockDataStore?.ensureChunkSlice(chunk.cx, chunk.cz); } + // 释放 chunkRecord 对 rawBlockData 的引用,无论是否为空 + result.chunkRecord.blockData = null; + // 2. 写入 payload registry if (staticEntities?.length > 0 || runtimeSeedData) { this.worldChunkPayloadRegistry?.mergeChunkPayload(chunk.cx, chunk.cz, { @@ -704,6 +707,13 @@ export class World { } } + _rebuildStaticTreeTerrainBoostChunkKeys() { + this._staticTreeTerrainBoostChunkKeys.clear(); + for (const [, ch] of this.chunks) { + this._markStaticTreeTerrainBoostFromChunk(ch); + } + } + /** * 处理 Chunk 生成完成后的结果 * 替代原有的 chunk.acceptWorkerResult 直接调用 @@ -1108,7 +1118,10 @@ export class World { chunk.batchFaceCullingTimer = null; } - // 5. 释放显存并从活动 chunk 集合移除 + // 5. 清理 runtime 残留(dirty + pendingUnload + flushTimer) + this.worldRuntime?.clearChunkRuntimeResidue?.(chunk.cx, chunk.cz); + + // 6. 释放显存并从活动 chunk 集合移除 chunk.dispose(); this.chunks.delete(key); chunkTopologyChanged = true; @@ -1119,6 +1132,7 @@ export class World { this.runtimeIdleScheduler?.markBusy('chunk-topology-changed'); this.clearBlockLookupCaches(); this.requestShadowMapUpdate('chunk-topology-changed'); + this._rebuildStaticTreeTerrainBoostChunkKeys(); } // Chunk 就绪数量变化时执行一次去重,避免历史重复 owner 长期存在 diff --git a/src/world/WorldRuntime.js b/src/world/WorldRuntime.js index 854477f..464a847 100644 --- a/src/world/WorldRuntime.js +++ b/src/world/WorldRuntime.js @@ -101,12 +101,14 @@ export class WorldRuntime { if (!Array.isArray(existingRegion.chunkKeys)) existingRegion.chunkKeys = []; const chunkKey = this._chunkKey(cx, cz); - existingRegion.chunks[chunkKey] = chunkRecord; - if (!existingRegion.chunkKeys.includes(chunkKey)) { - existingRegion.chunkKeys.push(chunkKey); - } - - this._regionCache.set(regionKey, existingRegion); + const newRegion = this._stripBlockDataFromRegionRecord({ + ...existingRegion, + chunks: { ...existingRegion.chunks, [chunkKey]: chunkRecord }, + chunkKeys: existingRegion.chunkKeys.includes(chunkKey) + ? existingRegion.chunkKeys + : [...existingRegion.chunkKeys, chunkKey] + }); + this._regionCache.set(regionKey, newRegion); } // ============================================================ @@ -204,6 +206,38 @@ export class WorldRuntime { this._dirtyChunks.delete(this._chunkKey(cx, cz)); } + /** + * 统一清理 chunk 卸载后的 runtime 残留 + */ + clearChunkRuntimeResidue(cx, cz) { + const key = this._chunkKey(cx, cz); + this._dirtyChunks.delete(key); + this.pendingUnloadFlushQueue.delete(key); + const timer = this._flushTimers.get(key); + if (timer !== undefined) { + clearTimeout(timer); + this._flushTimers.delete(key); + } + } + + /** + * 从 region record 的各 chunk 中剥离 blockData 冗余副本。 + * 返回可安全放入 _regionCache 的浅克隆,不修改原始对象。 + */ + _stripBlockDataFromRegionRecord(region) { + if (!region?.chunks) return region; + const cleaned = { ...region, chunks: {} }; + for (const [ck, rec] of Object.entries(region.chunks)) { + if (rec && rec.blockData !== undefined) { + const { blockData: _dropped, ...rest } = rec; + cleaned.chunks[ck] = rest; + } else { + cleaned.chunks[ck] = rec; + } + } + return cleaned; + } + /** * 获取所有脏 chunk keys */ @@ -319,10 +353,13 @@ export class WorldRuntime { regionGroups.set(rKey, { rx, rz, chunks: new Map() }); } const group = regionGroups.get(rKey); - const cachedChunkRecord = this._getCachedChunkRecord(queueRecord.cx, queueRecord.cz); const chunkRecord = this._cloneSerializable(queueRecord.chunkRecord, null); if (queueRecord.preserveStoredBlockData === true) { - chunkRecord.blockData = cachedChunkRecord?.blockData || {}; + // RegionCache 已剥离 blockData,不再从 cache 读取。 + // 删除 chunkRecord 的 blockData,让写盘路径自然保留 IndexedDB 中已有数据: + // - patch 路径:chunkRecord 不含 blockData,已有数据不受影响 + // - full-save 路径:从 getRegionRecord() 重读完整 region,merge 时无 blockData 属性不会覆盖 + delete chunkRecord.blockData; } group.chunks.set(key, chunkRecord); const metrics = this._getSerializedBlockMetrics(chunkRecord.blockData); @@ -343,14 +380,29 @@ export class WorldRuntime { const [cx, cz] = chunkKey.split(',').map(Number); this._updateRegionCacheChunkRecord(cx, cz, chunkRecord); } - this._regionCache.set(rKey, region); + this._regionCache.set(rKey, this._stripBlockDataFromRegionRecord(region)); } else if (region) { - // 更新已有 region + const chunkPatches = Array.from(group.chunks.entries()).map(([chunkKey, chunkRecord]) => ({ + chunkKey, + chunkRecord: this._cloneSerializable(chunkRecord, null) + })); + if (typeof this._worldStore.applyRegionPatch === 'function') { + await this._worldStore.applyRegionPatch(group.rx, group.rz, { chunkPatches }); + } else { + // 无 patch API — 从 worldStore 重读完整 region 再 merge,不用 stripped cache 做基底 + const fullRegion = (typeof this._worldStore.getRegionRecord === 'function' + ? await this._worldStore.getRegionRecord(group.rx, group.rz) + : null) || region; + for (const [chunkKey, chunkRecord] of group.chunks) { + fullRegion.chunks[chunkKey] = { ...fullRegion.chunks[chunkKey], ...chunkRecord }; + } + await this._worldStore.saveRegionRecord(group.rx, group.rz, fullRegion); + } + // merge 新 chunkRecord 到 region cache(更新 staticEntities/runtimeSeedData 等元数据) for (const [chunkKey, chunkRecord] of group.chunks) { - region.chunks[chunkKey] = chunkRecord; + region.chunks[chunkKey] = { ...region.chunks[chunkKey], ...chunkRecord }; } - await this._worldStore.saveRegionRecord(group.rx, group.rz, region); - this._regionCache.set(rKey, region); + this._regionCache.set(rKey, this._stripBlockDataFromRegionRecord(region)); } else { // 创建新 region(不应该发生,因为脏 chunk 必然有 region) const newRegion = { @@ -363,7 +415,7 @@ export class WorldRuntime { generatorVersion: '1.0' }; await this._worldStore.saveRegionRecord(group.rx, group.rz, newRegion); - this._regionCache.set(rKey, newRegion); + this._regionCache.set(rKey, this._stripBlockDataFromRegionRecord(newRegion)); } // 清除已写回的脏标记 @@ -532,7 +584,8 @@ export class WorldRuntime { } const key = this._chunkKey(cx, cz); - cachedRegion.chunks[key] = chunkRecord; + const { blockData: _dropped, ...cleanRecord } = chunkRecord; + cachedRegion.chunks[key] = cleanRecord; if (!Array.isArray(cachedRegion.chunkKeys)) { cachedRegion.chunkKeys = []; @@ -641,6 +694,13 @@ export class WorldRuntime { } } + // Step 14.5: preserveStoredBlockData — 删除 blockData,让写盘路径自然保留已有数据 + for (const entry of regionEntries) { + if (entry.preserveStoredBlockData) { + delete entry.chunkRecord.blockData; + } + } + const chunkPatches = regionEntries.map((entry) => ({ chunkKey: entry.chunkKey, preserveStoredBlockData: entry.preserveStoredBlockData === true, @@ -650,37 +710,33 @@ export class WorldRuntime { if (typeof this._worldStore.applyRegionPatch === 'function') { await this._worldStore.applyRegionPatch(rx, rz, { chunkPatches }); } else { - const region = this._cloneSerializable( - this._regionCache.get(regionKey), - { - regionKey, - rx, - rz, - chunkKeys: [], - chunks: {}, - generatedAt: Date.now(), - generatorVersion: '1.0' - } - ); - if (!region.chunks) region.chunks = {}; - if (!Array.isArray(region.chunkKeys)) region.chunkKeys = []; + // 无 patch API — 从 worldStore 重读完整 region,不用 stripped cache 做基底 + const fullRegion = (typeof this._worldStore.getRegionRecord === 'function' + ? await this._worldStore.getRegionRecord(rx, rz) + : null) || { + regionKey, + rx, + rz, + chunkKeys: [], + chunks: {}, + generatedAt: Date.now(), + generatorVersion: '1.0' + }; + if (!fullRegion.chunks) fullRegion.chunks = {}; + if (!Array.isArray(fullRegion.chunkKeys)) fullRegion.chunkKeys = []; for (const entry of regionEntries) { - const currentChunk = region.chunks[entry.chunkKey] || {}; - region.chunks[entry.chunkKey] = { - ...currentChunk, - ...entry.chunkRecord, - blockData: entry.preserveStoredBlockData - ? (currentChunk.blockData || {}) - : entry.chunkRecord.blockData - }; - if (!region.chunkKeys.includes(entry.chunkKey)) { - region.chunkKeys.push(entry.chunkKey); + const currentChunk = fullRegion.chunks[entry.chunkKey] || {}; + // 纯属性 merge:preserveStoredBlockData 下 chunkRecord 已无 blockData, + // spread 自然保留 currentChunk 的 blockData + fullRegion.chunks[entry.chunkKey] = { ...currentChunk, ...entry.chunkRecord }; + if (!fullRegion.chunkKeys.includes(entry.chunkKey)) { + fullRegion.chunkKeys.push(entry.chunkKey); } } - await this._worldStore.saveRegionRecord(rx, rz, region); - this._regionCache.set(regionKey, region); + await this._worldStore.saveRegionRecord(rx, rz, fullRegion); + this._regionCache.set(regionKey, this._stripBlockDataFromRegionRecord(fullRegion)); } for (const entry of regionEntries) { @@ -757,11 +813,12 @@ export class WorldRuntime { // 3. 发起新请求 const loadPromise = this._worldStore.getRegionRecord(rx, rz) .then((record) => { - if (record) { - this._regionCache.set(regionKey, record); + const cleaned = record ? this._stripBlockDataFromRegionRecord(record) : record; + if (cleaned) { + this._regionCache.set(regionKey, cleaned); } this._regionLoadPromises.delete(regionKey); - return record; + return cleaned; }) .catch((err) => { this._regionLoadPromises.delete(regionKey);