minecraft-renderer 0.1.106 → 0.1.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/lightOwnerWorker.js +9 -0
  2. package/dist/lightOwnerWorker.js.map +7 -0
  3. package/dist/mesher.js +1 -1
  4. package/dist/mesher.js.map +2 -2
  5. package/dist/mesherWasm.js +222 -222
  6. package/dist/minecraft-renderer.js +105 -76
  7. package/dist/minecraft-renderer.js.meta.json +1 -1
  8. package/dist/threeWorker.js +537 -508
  9. package/package.json +1 -1
  10. package/src/bundler/bundlePrepare.test.ts +25 -0
  11. package/src/bundler/bundlePrepare.ts +1 -1
  12. package/src/graphicsBackend/appViewer.ts +15 -0
  13. package/src/graphicsBackend/config.ts +8 -0
  14. package/src/graphicsBackend/rendererDefaultOptions.test.ts +51 -0
  15. package/src/graphicsBackend/rendererDefaultOptions.ts +18 -1
  16. package/src/graphicsBackend/rendererOptionsSync.ts +4 -3
  17. package/src/lib/blockEntityLightRegistry.ts +4 -0
  18. package/src/lib/skyLight.ts +17 -0
  19. package/src/lib/worldrendererCommon.lightOwner.test.ts +916 -0
  20. package/src/lib/worldrendererCommon.removeColumn.test.ts +16 -0
  21. package/src/lib/worldrendererCommon.ts +340 -67
  22. package/src/lighting/cache/rendererLightCache.test.ts +158 -0
  23. package/src/lighting/cache/rendererLightCache.ts +248 -0
  24. package/src/lighting/coordinator/clientLightCoordinator.test.ts +102 -0
  25. package/src/lighting/coordinator/clientLightCoordinator.ts +574 -0
  26. package/src/lighting/debug/clientLightProbe.test.ts +24 -0
  27. package/src/lighting/debug/clientLightProbe.ts +20 -0
  28. package/src/lighting/debug/clientLightTrace.test.ts +271 -0
  29. package/src/lighting/debug/clientLightTrace.ts +335 -0
  30. package/src/lighting/entity/entityLightController.test.ts +216 -0
  31. package/src/lighting/entity/entityLightController.ts +322 -0
  32. package/src/lighting/owner/clientLightOwner.test.ts +467 -0
  33. package/src/lighting/owner/clientLightOwner.ts +523 -0
  34. package/src/lighting/owner/lightOwnerHost.test.ts +78 -0
  35. package/src/lighting/owner/lightOwnerHost.ts +202 -0
  36. package/src/lighting/owner/lightOwnerWorker.ts +154 -0
  37. package/src/lighting/owner/updateLight1171OwnerPath.test.ts +68 -0
  38. package/src/lighting/owner/updateLightToOwnerEvents.ts +49 -0
  39. package/src/lighting/tables/lightTables1171.generated.json +10 -0
  40. package/src/lighting/tables/lightTables1171.test.ts +168 -0
  41. package/src/lighting/tables/lightTables1171.ts +59 -0
  42. package/src/lighting/versioning/clientLightVersions.test.ts +241 -0
  43. package/src/lighting/versioning/clientLightVersions.ts +203 -0
  44. package/src/lighting/versioning/pendingSectionFlush.test.ts +214 -0
  45. package/src/lighting/versioning/pendingSectionFlush.ts +123 -0
  46. package/src/mesher-shared/lightNibblePack.ts +26 -0
  47. package/src/mesher-shared/mcDataWorkerShim.cjs +9 -7
  48. package/src/mesher-shared/modelsGeometryCommon.test.ts +6 -1
  49. package/src/mesher-shared/shared.ts +48 -1
  50. package/src/three/chunkMeshManager.ts +26 -0
  51. package/src/three/entities.ts +97 -8
  52. package/src/three/entity/EntityMesh.ts +1 -0
  53. package/src/three/entity/legWalkAnimation.test.ts +154 -0
  54. package/src/three/entity/legWalkAnimation.ts +193 -0
  55. package/src/three/globalBlockBuffer.ts +94 -2
  56. package/src/three/globalLegacyBuffer.ts +94 -2
  57. package/src/three/graphicsBackendBase.ts +2 -9
  58. package/src/three/modules/clouds.ts +191 -0
  59. package/src/three/modules/index.ts +4 -0
  60. package/src/three/modules/sunMoon.ts +151 -0
  61. package/src/three/skyboxRenderer.ts +15 -8
  62. package/src/three/tests/clouds.test.ts +59 -0
  63. package/src/three/tests/globalBlockBuffer.test.ts +98 -0
  64. package/src/three/tests/globalLegacyBuffer.test.ts +108 -0
  65. package/src/three/tests/waterFog.test.ts +25 -0
  66. package/src/three/waterFog.ts +28 -0
  67. package/src/three/worldRendererThree.ts +84 -39
  68. package/src/wasm-mesher/bridge/render-from-wasm.ts +46 -1
  69. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +31 -5
  70. package/src/wasm-mesher/runtime-build/wasm_mesher.js +180 -5
  71. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  72. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +11 -0
  73. package/src/wasm-mesher/tests/mesherLightDependencies.test.ts +83 -0
  74. package/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts +39 -0
  75. package/src/wasm-mesher/tests/mesherWasmConversionCache.test.ts +30 -1
  76. package/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts +73 -0
  77. package/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts +51 -0
  78. package/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts +78 -0
  79. package/src/wasm-mesher/worker/clientLightDarkPublication.probe.test.ts +32 -0
  80. package/src/wasm-mesher/worker/mesherLightDependencies.ts +43 -0
  81. package/src/wasm-mesher/worker/mesherWasm.ownerLightSync.test.ts +198 -0
  82. package/src/wasm-mesher/worker/mesherWasm.ts +506 -96
  83. package/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +1 -0
  84. package/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts +47 -0
  85. package/src/wasm-mesher/worker/mesherWasmConversionCache.ts +16 -5
  86. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +17 -1
  87. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +10 -0
  88. package/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts +208 -0
  89. package/src/wasm-mesher/worker/mesherWasmLightMerge.ts +228 -0
  90. package/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts +161 -0
  91. package/src/wasm-mesher/worker/mesherWasmOwnerLight.ts +112 -0
  92. package/src/wasm-mesher/worker/mesherWasmTickParseCache.ts +45 -0
  93. package/src/wasm-mesher/worker/mesherWasmTickSchedule.ts +44 -0
  94. package/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts +63 -0
  95. package/src/worldView/types.ts +3 -0
  96. package/src/worldView/worldView.geometryReload.test.ts +68 -0
  97. package/src/worldView/worldView.spiral.test.ts +1 -1
  98. package/src/worldView/worldView.ts +49 -1
@@ -0,0 +1,916 @@
1
+ //@ts-nocheck
2
+ import { EventEmitter } from 'events'
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
4
+ import { Vec3 } from 'vec3'
5
+ import { proxy } from 'valtio'
6
+ import * as worldRendererModule from './worldrendererCommon'
7
+ import { WorldRendererCommon } from './worldrendererCommon'
8
+ import { defaultWorldRendererConfig } from '../graphicsBackend/config'
9
+ import { defaultPerformanceInstabilityFactors } from '../performanceMonitor'
10
+ import { getInitialPlayerState } from '../playerState/playerState'
11
+ import type { DisplayWorldOptions, GraphicsInitOptions } from '../graphicsBackend/types'
12
+ import { LIGHT_OWNER_WORKER_SCRIPT } from '../lighting/owner/clientLightOwner'
13
+ import { INITIAL_TOPOLOGY_REVISION } from '../lighting/versioning/clientLightVersions'
14
+ import { clearTopologyPostCache, getOrComputeTopologyPost } from '../wasm-mesher/worker/mesherWasmTopologyPostCache'
15
+ import Chunks from 'prismarine-chunk'
16
+
17
+ vi.mock('./ui/newStats', () => ({
18
+ addNewStat: vi.fn(() => ({ updateText: vi.fn(), setVisibility: vi.fn() })),
19
+ updateStatText: vi.fn(),
20
+ removeAllStats: vi.fn(),
21
+ updatePanesVisibility: vi.fn(),
22
+ MC_RENDERER_DEBUG_OVERLAY_CLASS: 'mc-renderer-debug-overlay'
23
+ }))
24
+
25
+ vi.mock('./utils/skins', () => ({
26
+ setSkinsConfig: vi.fn(),
27
+ steveTexture: {},
28
+ stevePngUrl: ''
29
+ }))
30
+
31
+ function ensurePromiseWithResolvers() {
32
+ if (!Promise.withResolvers) {
33
+ Promise.withResolvers = function <T>() {
34
+ let resolve!: (value: T | PromiseLike<T>) => void
35
+ let reject!: (reason?: unknown) => void
36
+ const promise = new Promise<T>((res, rej) => {
37
+ resolve = res
38
+ reject = rej
39
+ })
40
+ return { promise, resolve, reject }
41
+ }
42
+ }
43
+ }
44
+
45
+ class TestWorldRenderer extends WorldRendererCommon {
46
+ outputFormat = 'threeJs' as const
47
+
48
+ changeBackgroundColor() {}
49
+ changeCardinalLight() {}
50
+ handleWorkerMessage() {}
51
+ updateCamera() {}
52
+ render() {}
53
+ updateShowChunksBorder() {}
54
+ updatePlayerEntity() {}
55
+ worldStop() {}
56
+ }
57
+
58
+ const constructedScripts: string[] = []
59
+
60
+ function createRenderer(enableOwner = false, workerCount = 2, version = '1.17.1') {
61
+ const rendererState = proxy({
62
+ world: {
63
+ chunksLoaded: {} as Record<string, true>,
64
+ heightmaps: {} as Record<string, Int16Array>,
65
+ allChunksLoaded: false,
66
+ mesherWork: false,
67
+ instabilityFactors: defaultPerformanceInstabilityFactors(),
68
+ intersectMedia: null
69
+ },
70
+ renderer: '',
71
+ preventEscapeMenu: false
72
+ })
73
+
74
+ const displayOptions: DisplayWorldOptions = {
75
+ version,
76
+ worldView: Object.assign(new EventEmitter(), { reloadLoadedChunks: vi.fn(async () => {}) }) as unknown as DisplayWorldOptions['worldView'],
77
+ inWorldRenderingConfig: proxy({ ...defaultWorldRendererConfig, mesherWorkers: workerCount, enableClientLightOwner: enableOwner }),
78
+ playerStateReactive: getInitialPlayerState(),
79
+ rendererState,
80
+ nonReactiveState: {
81
+ fps: 0,
82
+ worstRenderTime: 0,
83
+ avgRenderTime: 0,
84
+ world: {
85
+ chunksLoadedCount: 0,
86
+ chunksTotalNumber: 0,
87
+ chunksFullInfo: ''
88
+ },
89
+ renderer: {
90
+ timeline: { live: [], frozen: [], lastSecond: [] }
91
+ }
92
+ },
93
+ resourcesManager: {
94
+ currentResources: {
95
+ mcData: { version: {} },
96
+ blocksAtlasJson: {},
97
+ blockstatesModels: {}
98
+ }
99
+ } as DisplayWorldOptions['resourcesManager']
100
+ }
101
+
102
+ const initOptions: GraphicsInitOptions = {
103
+ config: { sceneBackground: '#000' },
104
+ rendererSpecificSettings: {},
105
+ callbacks: {
106
+ displayCriticalError: vi.fn(),
107
+ setRendererSpecificSettings: vi.fn(),
108
+ fireCustomEvent: vi.fn()
109
+ }
110
+ }
111
+
112
+ const renderer = new TestWorldRenderer(displayOptions.resourcesManager, displayOptions, initOptions)
113
+ renderer.active = true
114
+ renderer.viewDistance = 8
115
+ renderer.viewerChunkPosition = new Vec3(0, 64, 0)
116
+ renderer.worldSizeParams = { minY: 0, worldHeight: 256 }
117
+ return renderer
118
+ }
119
+
120
+ describe('WorldRendererCommon client light owner spawn', () => {
121
+ test('owner idle releases only settled edits without a covering light publication', () => {
122
+ const renderer = createRenderer(true)
123
+ const internal = renderer.lightCoordinator as any
124
+ internal.interactiveSections.set('0,64,0', { lightVersionAtEdit: 1, editGeneration: 2 })
125
+ internal.interactiveSections.set('16,64,0', { lightVersionAtEdit: 1, editGeneration: 3 })
126
+ internal.interactiveSections.set('32,64,0', { lightVersionAtEdit: 1, editGeneration: 2, coveringVersion: 2 })
127
+ internal.releaseInteractiveEditsWithoutCoveringPublication(2)
128
+ expect(internal.interactiveSections.has('0,64,0')).toBe(false)
129
+ expect(internal.interactiveSections.has('16,64,0')).toBe(true)
130
+ expect(internal.interactiveSections.has('32,64,0')).toBe(true)
131
+ })
132
+
133
+ beforeEach(() => {
134
+ ensurePromiseWithResolvers()
135
+ constructedScripts.length = 0
136
+ vi.stubGlobal('location', { href: 'http://localhost/' })
137
+ vi.stubGlobal(
138
+ 'Worker',
139
+ class MockWorker {
140
+ script: string
141
+ postMessage = vi.fn()
142
+ terminate = vi.fn()
143
+ addEventListener = vi.fn()
144
+ onmessage: ((event: MessageEvent) => void) | null = null
145
+ constructor(script: string) {
146
+ this.script = script
147
+ constructedScripts.push(script)
148
+ }
149
+ }
150
+ )
151
+ vi.spyOn(worldRendererModule, 'meshersSendMcData').mockImplementation(() => {})
152
+ })
153
+
154
+ afterEach(() => {
155
+ vi.restoreAllMocks()
156
+ vi.unstubAllGlobals()
157
+ })
158
+
159
+ test('flag-off initWorkers does not spawn the light-owner worker', () => {
160
+ const renderer = createRenderer(false, 2)
161
+ renderer.initWorkers(2)
162
+ expect(renderer.workers).toHaveLength(2)
163
+ expect(constructedScripts.every(script => script !== LIGHT_OWNER_WORKER_SCRIPT)).toBe(true)
164
+ expect(renderer.hasClientLightOwner()).toBe(false)
165
+ expect(defaultWorldRendererConfig.enableClientLightOwner).toBe(false)
166
+ })
167
+
168
+ test('flag-on on a non-1.17.1 session does not load the 1.17.1 tables', () => {
169
+ const renderer = createRenderer(true, 1, '1.16.5')
170
+ renderer.initWorkers(1)
171
+ expect(renderer.workers).toHaveLength(1)
172
+ expect(constructedScripts.every(script => script !== LIGHT_OWNER_WORKER_SCRIPT)).toBe(true)
173
+ expect(renderer.getClientLightOwnerWorker()).toBeNull()
174
+ expect(renderer.hasClientLightOwner()).toBe(false)
175
+ expect(renderer.getClientLightOwnerFailureReason()).toMatch(/1\.17\.1/)
176
+ })
177
+
178
+ test('flag-on initWorkers spawns a dedicated owner, not a mesh worker', () => {
179
+ const renderer = createRenderer(true, 2)
180
+ renderer.initWorkers(2)
181
+ expect(renderer.workers).toHaveLength(2)
182
+ expect(constructedScripts.filter(script => script === LIGHT_OWNER_WORKER_SCRIPT)).toHaveLength(1)
183
+ expect(constructedScripts.filter(script => script !== LIGHT_OWNER_WORKER_SCRIPT)).toHaveLength(2)
184
+ expect(renderer.getClientLightOwnerWorker()).toBeTruthy()
185
+ expect(renderer.hasClientLightOwner()).toBe(false)
186
+ })
187
+
188
+ test('flag-on owner is not working until the worker reports ready', () => {
189
+ const renderer = createRenderer(true, 1)
190
+ renderer.initWorkers(1)
191
+ expect(renderer.hasClientLightOwner()).toBe(false)
192
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
193
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
194
+ expect(renderer.hasClientLightOwner()).toBe(true)
195
+ })
196
+
197
+ test('flag-on owner goes failed on worker error and is not working', () => {
198
+ const renderer = createRenderer(true, 1)
199
+ renderer.initWorkers(1)
200
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
201
+ owner.onmessage?.({ data: { type: 'error', error: 'NetworkError loading lightOwnerWorker.js' } } as MessageEvent)
202
+ expect(renderer.hasClientLightOwner()).toBe(false)
203
+ })
204
+
205
+ test('flag-off setBlock / addColumn / removeColumn do not post to an owner worker', () => {
206
+ const renderer = createRenderer(false, 2)
207
+ renderer.initWorkers(2)
208
+ const ownerPosts = constructedScripts.map((_, i) => i).filter(i => constructedScripts[i] === LIGHT_OWNER_WORKER_SCRIPT)
209
+ expect(ownerPosts).toHaveLength(0)
210
+
211
+ const meshPosts = renderer.workers.map((w: { postMessage: ReturnType<typeof vi.fn> }) => w.postMessage)
212
+ renderer.loadedChunks['0,0'] = true
213
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
214
+ renderer.removeColumn(0, 0)
215
+
216
+ expect(meshPosts.some(fn => fn.mock.calls.length > 0)).toBe(true)
217
+ expect(renderer.hasClientLightOwner()).toBe(false)
218
+ })
219
+
220
+ test('startup light stays bulk; a later publication of an edited section stays urgent', () => {
221
+ const renderer = createRenderer(true, 1)
222
+ renderer.initWorkers(1)
223
+ renderer.forceCallFromMesherReplayer = true
224
+ renderer.neighborChunkUpdates = false
225
+ renderer.loadedChunks['0,0'] = true
226
+ renderer.loadedChunks['64,64'] = true
227
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
228
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
229
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
230
+ const publish = (version: number, sx: number, sy: number, sz: number) => {
231
+ owner.onmessage?.({
232
+ data: {
233
+ type: 'publication',
234
+ publication: {
235
+ worldGeneration: 1,
236
+ publicationVersion: version,
237
+ sections: [{ sx, sy, sz, blockLight: new Uint8Array(2048) }]
238
+ }
239
+ }
240
+ } as MessageEvent)
241
+ }
242
+ const dirties = () => mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
243
+
244
+ mesh.postMessage.mockClear()
245
+ publish(1, 0, 4, 0)
246
+ const startup = dirties()
247
+ expect(startup.length).toBeGreaterThan(0)
248
+ expect(startup.every((message: { urgent?: boolean }) => message.urgent !== true)).toBe(true)
249
+
250
+ mesh.postMessage.mockClear()
251
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
252
+ const editDirties = dirties()
253
+ expect(editDirties.length).toBeGreaterThan(0)
254
+ expect(editDirties.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true)
255
+
256
+ mesh.postMessage.mockClear()
257
+ publish(2, 0, 4, 0)
258
+ const covering = dirties()
259
+ expect(covering.length).toBeGreaterThan(0)
260
+ expect(covering.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true)
261
+
262
+ mesh.postMessage.mockClear()
263
+ publish(3, 4, 4, 4)
264
+ const unrelated = dirties()
265
+ expect(unrelated.length).toBeGreaterThan(0)
266
+ expect(unrelated.every((message: { urgent?: boolean }) => message.urgent !== true)).toBe(true)
267
+ })
268
+
269
+ test('geometry at the pre-edit light version keeps the edit urgent until its own publication is committed', () => {
270
+ const renderer = createRenderer(true, 1)
271
+ renderer.initWorkers(1)
272
+ renderer.forceCallFromMesherReplayer = true
273
+ renderer.neighborChunkUpdates = false
274
+ renderer.loadedChunks['0,0'] = true
275
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
276
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
277
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
278
+ const publish = (version: number) => {
279
+ owner.onmessage?.({
280
+ data: {
281
+ type: 'publication',
282
+ publication: {
283
+ worldGeneration: 1,
284
+ publicationVersion: version,
285
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
286
+ }
287
+ }
288
+ } as MessageEvent)
289
+ }
290
+ const dirties = () => mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
291
+ publish(5)
292
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
293
+ const topology = (renderer as any).lightCoordinator.topologyRevisionBySection.get('0,64,0') as number
294
+ const geometry = {
295
+ type: 'geometry',
296
+ key: '0,64,0',
297
+ worldGeneration: 1,
298
+ lightPublicationVersion: 5,
299
+ sessionEpoch: 1,
300
+ columnIncarnation: 1,
301
+ requestId: 1,
302
+ topologyRevision: topology,
303
+ meshMode: 'owner' as const,
304
+ workerIndex: 0,
305
+ geometry: {}
306
+ }
307
+ const early = (renderer as any).lightCoordinator.evaluateOwnerGeometry(geometry)
308
+ expect(early.accepted).toBe(true)
309
+ expect(early.keepCovering).toBe(false)
310
+ renderer.handleMessage(geometry)
311
+ owner.onmessage?.({ data: { type: 'stepped', remaining: false, stepGeneration: 0 } } as MessageEvent)
312
+ mesh.postMessage.mockClear()
313
+ publish(6)
314
+ const covering = dirties()
315
+ expect(covering.length).toBeGreaterThan(0)
316
+ expect(covering.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true)
317
+
318
+ renderer.handleMessage({ ...geometry, lightPublicationVersion: 6 })
319
+ mesh.postMessage.mockClear()
320
+ publish(7)
321
+ const afterCommit = dirties()
322
+ expect(afterCommit.length).toBeGreaterThan(0)
323
+ expect(afterCommit.every((message: { urgent?: boolean }) => message.urgent !== true)).toBe(true)
324
+ })
325
+
326
+ test('a second urgent edit in the throttle window is dispatched immediately', () => {
327
+ vi.useFakeTimers()
328
+ try {
329
+ const renderer = createRenderer(true, 1)
330
+ renderer.initWorkers(1)
331
+ renderer.forceCallFromMesherReplayer = true
332
+ renderer.neighborChunkUpdates = false
333
+ renderer.loadedChunks['0,0'] = true
334
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
335
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
336
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
337
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
338
+ mesh.postMessage.mockClear()
339
+ renderer.setBlockStateIdInner(new Vec3(2, 64, 1), 2)
340
+ const second = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
341
+ expect(second.length).toBeGreaterThan(0)
342
+ expect(second.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true)
343
+ } finally {
344
+ vi.clearAllTimers()
345
+ vi.useRealTimers()
346
+ }
347
+ })
348
+
349
+ test('flag-on setBlock / addColumn / removeColumn / update_light forward to the owner', () => {
350
+ const renderer = createRenderer(true, 2)
351
+ renderer.initWorkers(2)
352
+ const owner = renderer.getClientLightOwnerWorker() as unknown as { postMessage: ReturnType<typeof vi.fn> }
353
+ expect(owner).toBeTruthy()
354
+ owner.postMessage.mockClear()
355
+
356
+ renderer.loadedChunks['0,0'] = true
357
+ renderer.setBlockStateIdInner(new Vec3(8, 64, 8), 2)
358
+ const Chunk = Chunks('1.17.1') as any
359
+ renderer.addColumn(0, 0, new Chunk().toJson(), false)
360
+ renderer.removeColumn(0, 0)
361
+ renderer.feedChunkPacket({
362
+ kind: 'setUpdateLightV17',
363
+ protocol: 756,
364
+ numSections: 16,
365
+ rawPacket: new Uint8Array([1, 2, 3])
366
+ })
367
+
368
+ const payloads = owner.postMessage.mock.calls.map(call => call[0])
369
+ expect(payloads.some(p => p?.type === 'pushEvent' && p.event?.type === 'blockChange')).toBe(true)
370
+ expect(payloads.some(p => p?.type === 'pushEvent' && p.event?.type === 'ingestBlockSection')).toBe(true)
371
+ expect(payloads.some(p => p?.type === 'pushEvent' && p.event?.type === 'unloadColumn')).toBe(true)
372
+ expect(payloads.some(p => p?.type === 'setUpdateLightV17')).toBe(true)
373
+ })
374
+
375
+ test('resetWorld terminates the owner worker when it was spawned', () => {
376
+ const renderer = createRenderer(true, 1)
377
+ renderer.initWorkers(1)
378
+ const owner = renderer.getClientLightOwnerWorker() as unknown as { terminate: ReturnType<typeof vi.fn> }
379
+ expect(owner).toBeTruthy()
380
+ renderer.resetWorld()
381
+ expect(owner.terminate).toHaveBeenCalled()
382
+ expect(renderer.hasClientLightOwner()).toBe(false)
383
+ expect(renderer.workers).toHaveLength(0)
384
+ })
385
+
386
+ test('owner publication remesh does not wait the 100ms trailing window', () => {
387
+ vi.useFakeTimers()
388
+ try {
389
+ const renderer = createRenderer(true, 1)
390
+ renderer.initWorkers(1)
391
+ renderer.forceCallFromMesherReplayer = true
392
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
393
+ renderer.setSectionDirty(new Vec3(0, 64, 0), true, true)
394
+ mesh.postMessage.mockClear()
395
+ ;(renderer as any).lightCoordinator.onPublication({
396
+ applied: true,
397
+ lastVersion: 7,
398
+ acceptedGeneration: 1,
399
+ dirtyMeshSections: [{ sx: 0, sy: 64, sz: 0 }],
400
+ workerMessage: null
401
+ })
402
+ const immediate = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
403
+ expect(immediate.some((message: { lightPublicationVersion?: number }) => message.lightPublicationVersion === 7)).toBe(true)
404
+ mesh.postMessage.mockClear()
405
+ vi.advanceTimersByTime(WorldRendererCommon['GEOMETRY_THROTTLE_DELAY'])
406
+ const trailing = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
407
+ expect(trailing).toHaveLength(0)
408
+ } finally {
409
+ vi.useRealTimers()
410
+ }
411
+ })
412
+
413
+ test('owner publication is posted only to mesh workers that own dirty sections', () => {
414
+ const renderer = createRenderer(true, 8)
415
+ renderer.initWorkers(8)
416
+ for (const worker of renderer.workers) {
417
+ ;(worker as { postMessage: ReturnType<typeof vi.fn> }).postMessage.mockClear()
418
+ }
419
+ const workerMessage = {
420
+ type: 'applyOwnerLightPublication',
421
+ worldGeneration: 1,
422
+ publicationVersion: 4,
423
+ sections: [{ sx: 0, sy: 64, sz: 0, blockLight: new Uint8Array(2048) }]
424
+ }
425
+ ;(renderer as any).lightCoordinator.onPublication({
426
+ applied: true,
427
+ lastVersion: 4,
428
+ acceptedGeneration: 1,
429
+ dirtyMeshSections: [{ sx: 0, sy: 64, sz: 0 }],
430
+ workerMessage
431
+ })
432
+ const posted = renderer.workers.map(worker =>
433
+ (worker as { postMessage: ReturnType<typeof vi.fn> }).postMessage.mock.calls.some(call => call[0]?.type === 'applyOwnerLightPublication')
434
+ )
435
+ expect(posted.filter(Boolean).length).toBeGreaterThan(0)
436
+ expect(posted.filter(Boolean).length).toBeLessThan(renderer.workers.length)
437
+ })
438
+
439
+ test('trailing dirty after an owner publication keeps the target revision', () => {
440
+ vi.useFakeTimers()
441
+ try {
442
+ const renderer = createRenderer(true, 1)
443
+ renderer.initWorkers(1)
444
+ renderer.forceCallFromMesherReplayer = true
445
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
446
+ mesh.postMessage.mockClear()
447
+ renderer.setSectionDirty(new Vec3(0, 64, 0), true, true)
448
+ ;(renderer as any).lightCoordinator.onPublication({
449
+ applied: true,
450
+ lastVersion: 7,
451
+ acceptedGeneration: 1,
452
+ dirtyMeshSections: [{ sx: 0, sy: 64, sz: 0 }],
453
+ workerMessage: null
454
+ })
455
+ vi.advanceTimersByTime(WorldRendererCommon['GEOMETRY_THROTTLE_DELAY'])
456
+ const dirties = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
457
+ const trailing = dirties.at(-1)
458
+ expect(trailing?.lightPublicationVersion).toBe(7)
459
+ expect(trailing?.worldGeneration).toBe(1)
460
+ } finally {
461
+ vi.useRealTimers()
462
+ }
463
+ })
464
+
465
+ test('rejected stale owner geometry does not add a remesh when covering is already outstanding', () => {
466
+ const renderer = createRenderer(true, 1)
467
+ renderer.initWorkers(1)
468
+ renderer.forceCallFromMesherReplayer = true
469
+ renderer.loadedChunks['0,0'] = true
470
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
471
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
472
+ owner.onmessage?.({
473
+ data: {
474
+ type: 'publication',
475
+ publication: {
476
+ worldGeneration: 1,
477
+ publicationVersion: 2,
478
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
479
+ }
480
+ }
481
+ } as MessageEvent)
482
+
483
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
484
+ mesh.postMessage.mockClear()
485
+ renderer.sectionsWaiting.set('0,64,0', 1)
486
+ renderer.handleMessage({
487
+ type: 'geometry',
488
+ key: '0,64,0',
489
+ worldGeneration: 1,
490
+ lightPublicationVersion: 1,
491
+ sessionEpoch: 1,
492
+ columnIncarnation: 1,
493
+ requestId: 1,
494
+ topologyRevision: 1,
495
+ workerIndex: 0,
496
+ geometry: {}
497
+ })
498
+ const dirties = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
499
+ expect(dirties).toHaveLength(0)
500
+
501
+ renderer.handleMessage({ type: 'sectionFinished', key: '0,64,0', workerIndex: 0, processTime: 0 })
502
+ const replacement = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
503
+ expect(replacement).toHaveLength(1)
504
+ expect(replacement[0]?.lightPublicationVersion).toBe(2)
505
+ expect(renderer.sectionsWaiting.get('0,64,0')).toBe(1)
506
+ expect((renderer as any).lightCoordinator.rejectedFinishedBySection.get('0,64,0') ?? 0).toBe(0)
507
+ })
508
+
509
+ test('fast A→B→C drops B and keeps a single covering remesh for C', () => {
510
+ const renderer = createRenderer(true, 1)
511
+ renderer.initWorkers(1)
512
+ renderer.forceCallFromMesherReplayer = true
513
+ renderer.loadedChunks['0,0'] = true
514
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
515
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
516
+ const publish = (version: number) => {
517
+ owner.onmessage?.({
518
+ data: {
519
+ type: 'publication',
520
+ publication: {
521
+ worldGeneration: 1,
522
+ publicationVersion: version,
523
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
524
+ }
525
+ }
526
+ } as MessageEvent)
527
+ }
528
+ publish(1)
529
+ publish(2)
530
+ publish(3)
531
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
532
+ mesh.postMessage.mockClear()
533
+ renderer.sectionsWaiting.set('0,64,0', 1)
534
+ renderer.handleMessage({
535
+ type: 'geometry',
536
+ key: '0,64,0',
537
+ worldGeneration: 1,
538
+ lightPublicationVersion: 2,
539
+ sessionEpoch: 1,
540
+ columnIncarnation: 1,
541
+ requestId: 2,
542
+ topologyRevision: 1,
543
+ workerIndex: 0,
544
+ geometry: {}
545
+ })
546
+ expect(mesh.postMessage.mock.calls.filter(call => call[0]?.type === 'dirty')).toHaveLength(0)
547
+ const accepted = (renderer as any).lightCoordinator.evaluateOwnerGeometry({
548
+ type: 'geometry',
549
+ key: '0,64,0',
550
+ worldGeneration: 1,
551
+ lightPublicationVersion: 3,
552
+ sessionEpoch: 1,
553
+ columnIncarnation: 1,
554
+ requestId: 3,
555
+ topologyRevision: 1,
556
+ geometry: {}
557
+ })
558
+ expect(accepted.accepted).toBe(true)
559
+ })
560
+
561
+ test('owner failure posts a revert to mesh workers and rejects later owner publications', () => {
562
+ const renderer = createRenderer(true, 1)
563
+ renderer.initWorkers(1)
564
+ renderer.forceCallFromMesherReplayer = true
565
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
566
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
567
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
568
+ mesh.postMessage.mockClear()
569
+ owner.onmessage?.({ data: { type: 'error', error: 'owner wasm crashed' } } as MessageEvent)
570
+ expect(renderer.hasClientLightOwner()).toBe(false)
571
+ expect((renderer as any).getClientLightOwnerFailureReason()).toBe('owner wasm crashed')
572
+ expect(mesh.postMessage.mock.calls.some(call => call[0]?.type === 'revertOwnerLightToIncoming')).toBe(true)
573
+ const late = (renderer as any).lightCoordinator.evaluateOwnerGeometry({
574
+ type: 'geometry',
575
+ key: '0,64,0',
576
+ worldGeneration: 1,
577
+ lightPublicationVersion: 9,
578
+ sessionEpoch: 1,
579
+ meshMode: 'owner',
580
+ geometry: {}
581
+ })
582
+ expect(late.accepted).toBe(false)
583
+ })
584
+
585
+ test('reload of the same column increments incarnation so a late reply is dropped', () => {
586
+ const renderer = createRenderer(true, 1)
587
+ renderer.initWorkers(1)
588
+ renderer.forceCallFromMesherReplayer = true
589
+ renderer.loadedChunks['0,0'] = true
590
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
591
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
592
+ const first = (renderer as any).columnIncarnationFor('0,0')
593
+ renderer.removeColumn(0, 0)
594
+ renderer.loadedChunks['0,0'] = true
595
+ const second = (renderer as any).columnIncarnationFor('0,0')
596
+ expect(second).toBeGreaterThan(first ?? 0)
597
+ const late = (renderer as any).lightCoordinator.evaluateOwnerGeometry({
598
+ type: 'geometry',
599
+ key: '0,64,0',
600
+ worldGeneration: 1,
601
+ lightPublicationVersion: 4,
602
+ sessionEpoch: 1,
603
+ columnIncarnation: first ?? 1,
604
+ requestId: 4,
605
+ topologyRevision: 1,
606
+ meshMode: 'owner',
607
+ geometry: {}
608
+ })
609
+ expect(late.accepted).toBe(false)
610
+ })
611
+
612
+ test('flag-on ready owner does not fan-out raw update_light to mesh workers', () => {
613
+ const renderer = createRenderer(true, 2)
614
+ renderer.initWorkers(2)
615
+ const owner = renderer.getClientLightOwnerWorker() as unknown as {
616
+ onmessage: ((event: MessageEvent) => void) | null
617
+ postMessage: ReturnType<typeof vi.fn>
618
+ }
619
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
620
+ owner.postMessage.mockClear()
621
+ for (const worker of renderer.workers) {
622
+ ;(worker as { postMessage: ReturnType<typeof vi.fn> }).postMessage.mockClear()
623
+ }
624
+ renderer.feedChunkPacket({
625
+ kind: 'setUpdateLightV17',
626
+ protocol: 756,
627
+ numSections: 16,
628
+ rawPacket: new Uint8Array([1, 2, 3])
629
+ })
630
+ expect(owner.postMessage.mock.calls.some(call => call[0]?.type === 'setUpdateLightV17')).toBe(true)
631
+ expect(
632
+ renderer.workers.every(worker =>
633
+ (worker as { postMessage: ReturnType<typeof vi.fn> }).postMessage.mock.calls.every(call => call[0]?.type !== 'setUpdateLightV17')
634
+ )
635
+ ).toBe(true)
636
+ })
637
+
638
+ test('stale topology with fresh light is rejected and covering stays outstanding', () => {
639
+ const renderer = createRenderer(true, 1)
640
+ renderer.initWorkers(1)
641
+ renderer.forceCallFromMesherReplayer = true
642
+ renderer.neighborChunkUpdates = false
643
+ renderer.loadedChunks['0,0'] = true
644
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
645
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
646
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
647
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 2)
648
+ const topology = (renderer as any).lightCoordinator.topologyRevisionBySection.get('0,64,0') as number
649
+ expect(topology).toBeGreaterThan(1)
650
+ owner.onmessage?.({
651
+ data: {
652
+ type: 'publication',
653
+ publication: {
654
+ worldGeneration: 1,
655
+ publicationVersion: 4,
656
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
657
+ }
658
+ }
659
+ } as MessageEvent)
660
+ expect((renderer as any).lightCoordinator.session.requiredLightForSection('0,64,0')?.topologyRevision).toBe(topology)
661
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
662
+ mesh.postMessage.mockClear()
663
+ renderer.sectionsWaiting.set('0,64,0', 1)
664
+ renderer.handleMessage({
665
+ type: 'geometry',
666
+ key: '0,64,0',
667
+ worldGeneration: 1,
668
+ lightPublicationVersion: 4,
669
+ sessionEpoch: 1,
670
+ columnIncarnation: 1,
671
+ requestId: 1,
672
+ topologyRevision: topology - 1,
673
+ workerIndex: 0,
674
+ geometry: {}
675
+ })
676
+ expect(
677
+ (renderer as any).lightCoordinator.evaluateOwnerGeometry({
678
+ type: 'geometry',
679
+ key: '0,64,0',
680
+ worldGeneration: 1,
681
+ lightPublicationVersion: 4,
682
+ sessionEpoch: 1,
683
+ columnIncarnation: 1,
684
+ topologyRevision: topology - 1,
685
+ geometry: {}
686
+ }).accepted
687
+ ).toBe(false)
688
+ expect(mesh.postMessage.mock.calls.filter(call => call[0]?.type === 'dirty')).toHaveLength(0)
689
+ expect((renderer as any).lightCoordinator.pendingTopologyBySection.get('0,64,0')).toBe(topology)
690
+ renderer.handleMessage({ type: 'sectionFinished', key: '0,64,0', workerIndex: 0, processTime: 0 })
691
+ const replacement = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty')
692
+ expect(replacement).toHaveLength(1)
693
+ expect(replacement[0]?.topologyRevision).toBe(topology)
694
+ expect(replacement[0]?.lightPublicationVersion).toBe(4)
695
+ expect(renderer.sectionsWaiting.get('0,64,0')).toBe(1)
696
+ expect((renderer as any).lightCoordinator.pendingTopologyBySection.get('0,64,0')).toBe(topology)
697
+ })
698
+
699
+ test('fresh topology with stale light installs and keeps covering without a second dispatch', () => {
700
+ const renderer = createRenderer(true, 1)
701
+ renderer.initWorkers(1)
702
+ renderer.forceCallFromMesherReplayer = true
703
+ renderer.neighborChunkUpdates = false
704
+ renderer.loadedChunks['0,0'] = true
705
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
706
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
707
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
708
+ const topology = (renderer as any).lightCoordinator.topologyRevisionBySection.get('0,64,0') as number
709
+ owner.onmessage?.({
710
+ data: {
711
+ type: 'publication',
712
+ publication: {
713
+ worldGeneration: 1,
714
+ publicationVersion: 3,
715
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
716
+ }
717
+ }
718
+ } as MessageEvent)
719
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
720
+ mesh.postMessage.mockClear()
721
+ renderer.sectionsWaiting.set('0,64,0', 2)
722
+ renderer.handleMessage({
723
+ type: 'geometry',
724
+ key: '0,64,0',
725
+ worldGeneration: 1,
726
+ lightPublicationVersion: 1,
727
+ sessionEpoch: 1,
728
+ columnIncarnation: 1,
729
+ requestId: 1,
730
+ topologyRevision: topology,
731
+ workerIndex: 0,
732
+ geometry: {}
733
+ })
734
+ const decision = (renderer as any).lightCoordinator.evaluateOwnerGeometry({
735
+ type: 'geometry',
736
+ key: '0,64,0',
737
+ worldGeneration: 1,
738
+ lightPublicationVersion: 1,
739
+ sessionEpoch: 1,
740
+ columnIncarnation: 1,
741
+ topologyRevision: topology,
742
+ geometry: {}
743
+ })
744
+ expect(decision.accepted).toBe(true)
745
+ expect(decision.keepCovering).toBe(true)
746
+ expect(mesh.postMessage.mock.calls.filter(call => call[0]?.type === 'dirty')).toHaveLength(0)
747
+ expect((renderer as any).lightCoordinator.pendingCoveringBySection.has('0,64,0')).toBe(true)
748
+ expect((renderer as any).lightCoordinator.pendingTopologyBySection.has('0,64,0')).toBe(false)
749
+ renderer.handleMessage({ type: 'sectionFinished', key: '0,64,0', workerIndex: 0, processTime: 0 })
750
+ expect(renderer.sectionsWaiting.get('0,64,0')).toBe(1)
751
+ expect((renderer as any).lightCoordinator.pendingCoveringBySection.has('0,64,0')).toBe(true)
752
+ })
753
+
754
+ test('fast A→B→C publications keep the max topology revision', () => {
755
+ const renderer = createRenderer(true, 1)
756
+ renderer.initWorkers(1)
757
+ renderer.forceCallFromMesherReplayer = true
758
+ renderer.neighborChunkUpdates = false
759
+ renderer.loadedChunks['0,0'] = true
760
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
761
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
762
+ const publish = (version: number) => {
763
+ owner.onmessage?.({
764
+ data: {
765
+ type: 'publication',
766
+ publication: {
767
+ worldGeneration: 1,
768
+ publicationVersion: version,
769
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
770
+ }
771
+ }
772
+ } as MessageEvent)
773
+ }
774
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
775
+ publish(1)
776
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 2)
777
+ publish(2)
778
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 3)
779
+ publish(3)
780
+ const topology = (renderer as any).lightCoordinator.topologyRevisionBySection.get('0,64,0')
781
+ expect((renderer as any).lightCoordinator.session.requiredLightForSection('0,64,0')).toMatchObject({
782
+ requiredVersion: 3,
783
+ worldGeneration: 1,
784
+ topologyRevision: topology
785
+ })
786
+ })
787
+
788
+ test('publishing light only for column A accepts the neighbor wall without a publication of B', () => {
789
+ const renderer = createRenderer(true, 2)
790
+ renderer.initWorkers(2)
791
+ renderer.forceCallFromMesherReplayer = true
792
+ renderer.loadedChunks['0,0'] = true
793
+ renderer.loadedChunks['16,0'] = true
794
+ renderer.loadedChunks['-16,0'] = true
795
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
796
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
797
+ owner.onmessage?.({
798
+ data: {
799
+ type: 'publication',
800
+ publication: {
801
+ worldGeneration: 1,
802
+ publicationVersion: 8,
803
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
804
+ }
805
+ }
806
+ } as MessageEvent)
807
+
808
+ const posted = renderer.workers.map(worker =>
809
+ (worker as { postMessage: ReturnType<typeof vi.fn> }).postMessage.mock.calls.some(call => call[0]?.type === 'applyOwnerLightPublication')
810
+ )
811
+ expect(posted).toEqual([true, true])
812
+ expect((renderer as any).lightCoordinator.session.requiredLightForSection('16,64,0')?.requiredVersion).toBe(8)
813
+ expect((renderer as any).lightCoordinator.session.requiredLightForSection('-16,64,0')?.requiredVersion).toBe(8)
814
+
815
+ const evaluate = (key: string, lightPublicationVersion: number, meshMode: 'owner' | 'legacyBootstrap') => {
816
+ const [x, , z] = key.split(',').map(Number)
817
+ return (renderer as any).lightCoordinator.evaluateOwnerGeometry({
818
+ type: 'geometry',
819
+ key,
820
+ worldGeneration: 1,
821
+ lightPublicationVersion,
822
+ sessionEpoch: 1,
823
+ columnIncarnation: renderer.columnIncarnationFor(`${x},${z}`),
824
+ requestId: 1,
825
+ topologyRevision: INITIAL_TOPOLOGY_REVISION,
826
+ meshMode,
827
+ geometry: {}
828
+ })
829
+ }
830
+
831
+ const bootstrapWall = evaluate('-16,64,0', 8, 'legacyBootstrap')
832
+ expect(bootstrapWall.accepted).toBe(true)
833
+ expect(bootstrapWall.keepCovering).toBe(false)
834
+ expect(evaluate('16,64,0', 3, 'owner').accepted).toBe(false)
835
+ expect(evaluate('16,64,0', 8, 'owner').accepted).toBe(true)
836
+ })
837
+
838
+ test('bootstrap geometry from the previous column life is rejected', () => {
839
+ const renderer = createRenderer(true, 1)
840
+ renderer.initWorkers(1)
841
+ renderer.forceCallFromMesherReplayer = true
842
+ renderer.loadedChunks['0,0'] = true
843
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
844
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
845
+ const first = renderer.columnIncarnationFor('0,0')
846
+ renderer.removeColumn(0, 0)
847
+ renderer.loadedChunks['0,0'] = true
848
+ const late = (renderer as any).lightCoordinator.evaluateOwnerGeometry({
849
+ type: 'geometry',
850
+ key: '0,64,0',
851
+ meshMode: 'legacyBootstrap',
852
+ sessionEpoch: 1,
853
+ columnIncarnation: first,
854
+ geometry: {}
855
+ })
856
+ expect(late.accepted).toBe(false)
857
+ })
858
+
859
+ test('the first edit misses the initial topology cache and covering remesh carries the new revision', () => {
860
+ vi.useFakeTimers()
861
+ try {
862
+ const renderer = createRenderer(true, 1)
863
+ renderer.initWorkers(1)
864
+ renderer.forceCallFromMesherReplayer = true
865
+ renderer.neighborChunkUpdates = false
866
+ renderer.loadedChunks['0,0'] = true
867
+ const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null }
868
+ owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent)
869
+ const mesh = renderer.workers[0] as { postMessage: ReturnType<typeof vi.fn> }
870
+ mesh.postMessage.mockClear()
871
+ renderer.setSectionDirty(new Vec3(1, 64, 1), true, true)
872
+ const initial = mesh.postMessage.mock.calls
873
+ .map(call => call[0])
874
+ .filter((message: { type?: string }) => message?.type === 'dirty')
875
+ .at(-1)
876
+ expect(initial?.topologyRevision).toBe(INITIAL_TOPOLOGY_REVISION)
877
+
878
+ let walks = 0
879
+ const compute = () => {
880
+ walks++
881
+ return { visibilitySet: walks, signs: {}, heads: {}, banners: {} }
882
+ }
883
+ getOrComputeTopologyPost('0,64,0', initial?.topologyRevision, compute)
884
+ renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1)
885
+ owner.onmessage?.({
886
+ data: {
887
+ type: 'publication',
888
+ publication: {
889
+ worldGeneration: 1,
890
+ publicationVersion: 2,
891
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }]
892
+ }
893
+ }
894
+ } as MessageEvent)
895
+ const covering = mesh.postMessage.mock.calls
896
+ .map(call => call[0])
897
+ .filter((message: { type?: string; lightPublicationVersion?: number; x?: number; y?: number; z?: number }) => {
898
+ if (message?.type !== 'dirty' || message.lightPublicationVersion !== 2) return false
899
+ const sx = Math.floor((message.x ?? 0) / 16) * 16
900
+ const sy = Math.floor((message.y ?? 0) / 16) * 16
901
+ const sz = Math.floor((message.z ?? 0) / 16) * 16
902
+ return `${sx},${sy},${sz}` === '0,64,0'
903
+ })
904
+ expect(covering).toHaveLength(1)
905
+ expect(covering[0]?.topologyRevision).not.toBe(initial?.topologyRevision)
906
+ const editedRevision = covering[0]?.topologyRevision
907
+ const recomputed = getOrComputeTopologyPost('0,64,0', editedRevision, compute)
908
+ expect(recomputed.hit).toBe(false)
909
+ expect(walks).toBe(2)
910
+ } finally {
911
+ clearTopologyPostCache()
912
+ vi.clearAllTimers()
913
+ vi.useRealTimers()
914
+ }
915
+ })
916
+ })