ugly-game 0.5.6 → 0.5.8

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.
@@ -1,6 +1,3 @@
1
- /** One cube instance: world position, per-axis scale, a hue (< 0 ⇒ neutral ground), and an optional
2
- * emissive flag (> 0 ⇒ the cube self-glows at full brightness, bypassing sun/shadow/AO — used for the
3
- * place ghost, destroy marker, projectiles, and sparkles so effect cubes read as distinct from terrain). */
4
1
  export interface CubeInstance {
5
2
  x: number;
6
3
  y: number;
@@ -37,7 +34,27 @@ export declare class GpuShadowRenderer {
37
34
  private overlayBG;
38
35
  private overlayScratch;
39
36
  private overlayCount;
37
+ private cullPipe;
38
+ private cullBGL;
39
+ private cap;
40
+ private camArgs;
41
+ private camVis;
42
+ private camCull;
43
+ private camCullBG;
44
+ private lightArgs;
45
+ private lightVis;
46
+ private lightCull;
47
+ private lightCullBG;
48
+ private statsBuf;
49
+ /** Last frame's visible counts (main-pass camera cull, shadow-pass light cull) + total — for IR/telemetry. */
50
+ lastCull: {
51
+ total: number;
52
+ camVisible: number;
53
+ lightVisible: number;
54
+ };
40
55
  constructor(device: GPUDevice, format: GPUTextureFormat);
56
+ private makeCullBuf;
57
+ private makeCullResources;
41
58
  private scratch;
42
59
  private fill;
43
60
  private makeBindGroups;
@@ -48,6 +65,15 @@ export declare class GpuShadowRenderer {
48
65
  updateInstances(instances: CubeInstance[]): void;
49
66
  /** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
50
67
  updateOverlay(instances: CubeInstance[]): void;
68
+ /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
69
+ private writeCull;
51
70
  /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
52
71
  frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean): void;
72
+ /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
73
+ * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
74
+ readCullStats(): Promise<{
75
+ total: number;
76
+ camVisible: number;
77
+ lightVisible: number;
78
+ }>;
53
79
  }
package/dist/gpuShadow.js CHANGED
@@ -8,7 +8,12 @@
8
8
  // Everything is a cube instance — including the ground (a big flat cube) — so one geometry
9
9
  // and one vertex path serve both passes. Per-instance non-uniform scale (vec3) lets the
10
10
  // ground be flat while props are boxy.
11
+ /** One cube instance: world position, per-axis scale, a hue (< 0 ⇒ neutral ground), and an optional
12
+ * emissive flag (> 0 ⇒ the cube self-glows at full brightness, bypassing sun/shadow/AO — used for the
13
+ * place ghost, destroy marker, projectiles, and sparkles so effect cubes read as distinct from terrain). */
14
+ import { frustumPlanes } from './mat4';
11
15
  const SHADOW_SIZE = 2048;
16
+ const CULL_WG = 64; // compute-cull workgroup size
12
17
  const OVERLAY_CAP = 512; // max translucent overlay cubes per frame (the place ghost + headroom)
13
18
  // Build the 36-vertex cube (6 faces × 2 tris) in JS and inject as WGSL const arrays —
14
19
  // bulletproof + readable vs. bit-twiddling from vertex_index. cullMode is 'none', so winding
@@ -61,19 +66,60 @@ fn hsv(hh: f32) -> vec3<f32> {
61
66
  if (h < 5.0) { return vec3(x, 0.0, 1.0); } return vec3(1.0, 0.0, x);
62
67
  }
63
68
  `;
69
+ // GPU-DRIVEN CULLING. The whole instance buffer stays resident; a compute pass frustum-culls it and compacts the
70
+ // survivors' indices into a `visible` list + an indirect draw's instanceCount. The draw passes then index
71
+ // insts[visible[ii]] and drawIndirect the compacted count — so the vertex shader + rasterizer only ever touch
72
+ // in-frustum cubes. Two culls per frame: the MAIN pass against the CAMERA frustum, the SHADOW pass against the
73
+ // LIGHT (ortho) frustum, so shadows from off-screen occluders stay correct while both passes shrink.
74
+ //
75
+ // The test is an ORIENTED-BOX vs plane separating test (NOT a bounding sphere). Each cube's world extent along a
76
+ // plane normal n is |n·bx|+|n·by|+|n·bz|, where bx/by/bz are the box's rotated semi-axes (0.5·scl rotated by the
77
+ // instance quaternion). This is exact for any size/aspect/rotation — critical because a bounding SPHERE from the
78
+ // centre wrongly culls a big flat cube (a ground plane whose centre sits far behind the camera while the ground
79
+ // underfoot is plainly in view). Mirrors gpuDriven.ts; validated against a CPU box-vs-plane oracle.
80
+ const CULL_WGSL = /* wgsl */ `
81
+ struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32> };
82
+ struct Args { vc: u32, ic: atomic<u32>, fv: u32, fi: u32 };
83
+ struct CullCam { planes: array<vec4<f32>, 6>, cnt: vec4<f32> }; // cnt.x = instance count
84
+ @group(0) @binding(0) var<storage, read> insts: array<Inst>;
85
+ @group(0) @binding(1) var<storage, read_write> args: Args;
86
+ @group(0) @binding(2) var<storage, read_write> visible: array<u32>;
87
+ @group(0) @binding(3) var<uniform> cc: CullCam;
88
+ fn qr(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> { let t = 2.0 * cross(q.xyz, v); return v + q.w * t + cross(q.xyz, t); }
89
+ @compute @workgroup_size(${CULL_WG})
90
+ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
91
+ let i = gid.x;
92
+ if (i >= u32(cc.cnt.x)) { return; }
93
+ let inst = insts[i];
94
+ let c = inst.pos.xyz;
95
+ let h = 0.5 * inst.scl.xyz;
96
+ let bx = qr(inst.rot, vec3<f32>(h.x, 0.0, 0.0)); // rotated box semi-axes
97
+ let by = qr(inst.rot, vec3<f32>(0.0, h.y, 0.0));
98
+ let bz = qr(inst.rot, vec3<f32>(0.0, 0.0, h.z));
99
+ for (var p = 0u; p < 6u; p++) {
100
+ let pl = cc.planes[p];
101
+ let e = abs(dot(pl.xyz, bx)) + abs(dot(pl.xyz, by)) + abs(dot(pl.xyz, bz)); // box half-width along the normal
102
+ if (dot(pl.xyz, c) + pl.w < -e) { return; }
103
+ }
104
+ let slot = atomicAdd(&args.ic, 1u);
105
+ visible[slot] = i;
106
+ }
107
+ `;
64
108
  const SHADOW_WGSL = COMMON_WGSL + /* wgsl */ `
109
+ @group(0) @binding(2) var<storage, read> vis: array<u32>;
65
110
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
66
- return cam.lightVP * vec4(worldOf(insts[ii], vi), 1.0);
111
+ return cam.lightVP * vec4(worldOf(insts[vis[ii]], vi), 1.0);
67
112
  }
68
113
  `;
69
114
  const MAIN_WGSL = COMMON_WGSL + /* wgsl */ `
70
115
  @group(0) @binding(2) var shadowMap: texture_depth_2d;
71
116
  @group(0) @binding(3) var shadowSamp: sampler_comparison;
117
+ @group(0) @binding(4) var<storage, read> vis: array<u32>;
72
118
 
73
119
  struct VOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) hue: f32, @location(3) emis: f32, @location(4) blk: vec3<f32>, @location(5) objN: vec3<f32> };
74
120
 
75
121
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VOut {
76
- let inst = insts[ii];
122
+ let inst = insts[vis[ii]];
77
123
  let world = worldOf(inst, vi);
78
124
  var o: VOut;
79
125
  o.clip = cam.camVP * vec4(world, 1.0);
@@ -171,6 +217,21 @@ export class GpuShadowRenderer {
171
217
  overlayBG;
172
218
  overlayScratch = new Float32Array(OVERLAY_CAP * 12);
173
219
  overlayCount = 0;
220
+ // GPU-driven culling: one compute pipeline; per-frustum (camera + light) indirect args + compacted visible lists.
221
+ cullPipe;
222
+ cullBGL;
223
+ cap = 0;
224
+ camArgs;
225
+ camVis;
226
+ camCull;
227
+ camCullBG;
228
+ lightArgs;
229
+ lightVis;
230
+ lightCull;
231
+ lightCullBG;
232
+ statsBuf; // readback: [camVisible, lightVisible] for the IR/tests
233
+ /** Last frame's visible counts (main-pass camera cull, shadow-pass light cull) + total — for IR/telemetry. */
234
+ lastCull = { total: 0, camVisible: 0, lightVisible: 0 };
174
235
  constructor(device, format) {
175
236
  this.device = device;
176
237
  this.camBuf = device.createBuffer({ size: this.cam.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
@@ -181,6 +242,7 @@ export class GpuShadowRenderer {
181
242
  const shadowBGL = device.createBindGroupLayout({ entries: [
182
243
  { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
183
244
  { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
245
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, // light-visible list
184
246
  ] });
185
247
  this.shadowPipe = device.createRenderPipeline({
186
248
  layout: device.createPipelineLayout({ bindGroupLayouts: [shadowBGL] }),
@@ -193,6 +255,7 @@ export class GpuShadowRenderer {
193
255
  { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
194
256
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'depth' } },
195
257
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'comparison' } },
258
+ { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, // camera-visible list
196
259
  ] });
197
260
  this.mainPipe = device.createRenderPipeline({
198
261
  layout: device.createPipelineLayout({ bindGroupLayouts: [mainBGL] }),
@@ -224,6 +287,44 @@ export class GpuShadowRenderer {
224
287
  this.overlayBG = device.createBindGroup({ layout: alphaBGL, entries: [
225
288
  { binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.overlayBuf } }
226
289
  ] });
290
+ // GPU-driven cull: compute pipeline shared by the camera + light dispatches (each with its own args/vis/uniform).
291
+ const cullMod = device.createShaderModule({ code: CULL_WGSL });
292
+ this.cullBGL = device.createBindGroupLayout({ entries: [
293
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // insts
294
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // args (atomic ic)
295
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // visible list
296
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count
297
+ ] });
298
+ this.cullPipe = device.createComputePipeline({
299
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.cullBGL] }),
300
+ compute: { module: cullMod, entryPoint: 'cull' },
301
+ });
302
+ }
303
+ // One frustum's cull resources: indirect args (drawIndirect), a compacted visible-index list sized to the
304
+ // instance capacity, and a small uniform holding the 6 planes + the live instance count.
305
+ makeCullBuf() {
306
+ const d = this.device;
307
+ const args = d.createBuffer({ size: 4 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
308
+ const vis = d.createBuffer({ size: Math.max(1, this.cap) * 4, usage: GPUBufferUsage.STORAGE });
309
+ const cull = d.createBuffer({ size: 6 * 16 + 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
310
+ const bg = d.createBindGroup({ layout: this.cullBGL, entries: [
311
+ { binding: 0, resource: { buffer: this.instBuf } }, { binding: 1, resource: { buffer: args } },
312
+ { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } }
313
+ ] });
314
+ return { args, vis, cull, bg };
315
+ }
316
+ makeCullResources() {
317
+ const cam = this.makeCullBuf();
318
+ this.camArgs = cam.args;
319
+ this.camVis = cam.vis;
320
+ this.camCull = cam.cull;
321
+ this.camCullBG = cam.bg;
322
+ const lit = this.makeCullBuf();
323
+ this.lightArgs = lit.args;
324
+ this.lightVis = lit.vis;
325
+ this.lightCull = lit.cull;
326
+ this.lightCullBG = lit.bg;
327
+ this.statsBuf = this.device.createBuffer({ size: 4 * 4 * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
227
328
  }
228
329
  scratch = new Float32Array(0);
229
330
  fill(scratch, instances, n) {
@@ -244,17 +345,21 @@ export class GpuShadowRenderer {
244
345
  }
245
346
  }
246
347
  makeBindGroups() {
348
+ this.makeCullResources(); // (re)create the cull buffers against the fresh instBuf/cap first — the draw passes bind their visible lists
247
349
  this.shadowBG = this.device.createBindGroup({ layout: this.shadowBGL, entries: [
248
350
  { binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
351
+ { binding: 2, resource: { buffer: this.lightVis } },
249
352
  ] });
250
353
  this.mainBG = this.device.createBindGroup({ layout: this.mainBGL, entries: [
251
354
  { binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
252
355
  { binding: 2, resource: this.shadowView }, { binding: 3, resource: this.shadowSampler },
356
+ { binding: 4, resource: { buffer: this.camVis } },
253
357
  ] });
254
358
  }
255
359
  /** Static scene: sizes the buffer to exactly these instances. */
256
360
  setScene(instances) {
257
361
  this.count = instances.length;
362
+ this.cap = this.count;
258
363
  this.scratch = new Float32Array(this.count * 12);
259
364
  this.fill(this.scratch, instances, this.count);
260
365
  this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
@@ -263,6 +368,7 @@ export class GpuShadowRenderer {
263
368
  }
264
369
  /** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
265
370
  setCapacity(cap) {
371
+ this.cap = cap;
266
372
  this.scratch = new Float32Array(cap * 12);
267
373
  this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
268
374
  this.count = 0;
@@ -282,6 +388,19 @@ export class GpuShadowRenderer {
282
388
  this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * 12);
283
389
  this.overlayCount = n;
284
390
  }
391
+ /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
392
+ writeCull(buf, vp) {
393
+ const planes = frustumPlanes(vp);
394
+ const u = new Float32Array(6 * 4 + 4);
395
+ for (let p = 0; p < 6; p++) {
396
+ u[p * 4] = planes[p][0];
397
+ u[p * 4 + 1] = planes[p][1];
398
+ u[p * 4 + 2] = planes[p][2];
399
+ u[p * 4 + 3] = planes[p][3];
400
+ }
401
+ u[24] = this.count;
402
+ this.device.queue.writeBuffer(buf, 0, u);
403
+ }
285
404
  /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
286
405
  frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false) {
287
406
  this.cam.set(camVP, 0);
@@ -295,27 +414,58 @@ export class GpuShadowRenderer {
295
414
  this.cam[38] = sunCol[2];
296
415
  this.cam[39] = SHADOW_SIZE;
297
416
  this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
298
- // Pass 1: occluder depth from the sun.
417
+ // GPU-DRIVEN CULL: reset each frustum's indirect args ([vertexCount=36, instanceCount=0, 0, 0]), then compute-cull
418
+ // — main pass vs the camera frustum, shadow pass vs the light frustum. Each dispatch atomically compacts its
419
+ // survivors into a visible list + counts them in instanceCount, which the matching drawIndirect then consumes.
420
+ const resetArgs = new Uint32Array([36, 0, 0, 0]);
421
+ this.device.queue.writeBuffer(this.camArgs, 0, resetArgs);
422
+ this.device.queue.writeBuffer(this.lightArgs, 0, resetArgs);
423
+ this.writeCull(this.camCull, camVP);
424
+ this.writeCull(this.lightCull, lightVP);
425
+ if (this.count > 0) {
426
+ const cp = enc.beginComputePass();
427
+ cp.setPipeline(this.cullPipe);
428
+ cp.setBindGroup(0, this.camCullBG);
429
+ cp.dispatchWorkgroups(Math.ceil(this.count / CULL_WG));
430
+ cp.setBindGroup(0, this.lightCullBG);
431
+ cp.dispatchWorkgroups(Math.ceil(this.count / CULL_WG));
432
+ cp.end();
433
+ }
434
+ // Pass 1: occluder depth from the sun (light-frustum-culled).
299
435
  const sp = enc.beginRenderPass({ colorAttachments: [], depthStencilAttachment: {
300
436
  view: this.shadowView, depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store'
301
437
  } });
302
438
  sp.setPipeline(this.shadowPipe);
303
439
  sp.setBindGroup(0, this.shadowBG);
304
- sp.draw(36, this.count);
440
+ sp.drawIndirect(this.lightArgs, 0);
305
441
  sp.end();
306
- // Pass 2: camera, shadow-sampled.
442
+ // Pass 2: camera, shadow-sampled (camera-frustum-culled).
307
443
  const mp = enc.beginRenderPass({
308
444
  colorAttachments: [{ view: colorView, ...(load ? { loadOp: 'load' } : { clearValue: { r: 0.04, g: 0.05, b: 0.07, a: 1 }, loadOp: 'clear' }), storeOp: 'store' }],
309
445
  depthStencilAttachment: { view: sceneDepth, ...(load ? { depthLoadOp: 'load' } : { depthClearValue: 1.0, depthLoadOp: 'clear' }), depthStoreOp: 'store' }
310
446
  });
311
447
  mp.setPipeline(this.mainPipe);
312
448
  mp.setBindGroup(0, this.mainBG);
313
- mp.draw(36, this.count);
449
+ mp.drawIndirect(this.camArgs, 0);
314
450
  if (this.overlayCount > 0) {
315
451
  mp.setPipeline(this.alphaPipe);
316
452
  mp.setBindGroup(0, this.overlayBG);
317
453
  mp.draw(36, this.overlayCount);
318
- } // translucent overlay last
454
+ } // translucent overlay last (uncapped, no cull)
319
455
  mp.end();
456
+ this.lastCull.total = this.count;
457
+ }
458
+ /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
459
+ * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
460
+ async readCullStats() {
461
+ const enc = this.device.createCommandEncoder();
462
+ enc.copyBufferToBuffer(this.camArgs, 0, this.statsBuf, 0, 16); // args[1] = instanceCount (the visible count)
463
+ enc.copyBufferToBuffer(this.lightArgs, 0, this.statsBuf, 16, 16);
464
+ this.device.queue.submit([enc.finish()]);
465
+ await this.statsBuf.mapAsync(GPUMapMode.READ);
466
+ const a = new Uint32Array(this.statsBuf.getMappedRange().slice(0));
467
+ this.statsBuf.unmap();
468
+ this.lastCull = { total: this.count, camVisible: a[1], lightVisible: a[5] };
469
+ return this.lastCull;
320
470
  }
321
471
  }
package/dist/mat4.d.ts CHANGED
@@ -12,6 +12,6 @@ export declare function ortho(l: number, r: number, b: number, t: number, n: num
12
12
  export declare function lightViewProjection(dir: Vec3, center: Vec3, extent: number, depth: number): Mat4;
13
13
  export type Plane = readonly [number, number, number, number];
14
14
  /** Extract + normalize the 6 frustum planes from a column-major view-proj (Gribb-Hartmann). */
15
- export declare function frustumPlanes(m: Mat4): Plane[];
15
+ export declare function frustumPlanes(m: ArrayLike<number>): Plane[];
16
16
  /** Sphere vs frustum: false only if the sphere is fully outside any plane. */
17
17
  export declare function sphereVisible(planes: Plane[], x: number, y: number, z: number, radius: number): boolean;
@@ -6,6 +6,9 @@ interface DOStorage {
6
6
  get<T>(key: string): Promise<T | undefined>;
7
7
  put<T>(key: string, value: T): Promise<void>;
8
8
  delete(key: string): Promise<void>;
9
+ list<T>(options?: {
10
+ prefix?: string;
11
+ }): Promise<Map<string, T>>;
9
12
  }
10
13
  interface DOState {
11
14
  storage: DOStorage;
@@ -22,8 +25,11 @@ export declare class RoomCore {
22
25
  private seq;
23
26
  constructor(state: DOState, opts?: RoomOptions);
24
27
  fetch(request: Request): Promise<unknown>;
25
- /** The app DO forwards its webSocketMessage here. */
26
- webSocketMessage(ws: WS, message: string | ArrayBuffer): void;
28
+ /** The app DO forwards its webSocketMessage here. Async so relayed edits can be durably accumulated. */
29
+ webSocketMessage(ws: WS, message: string | ArrayBuffer): Promise<void>;
30
+ /** LWW-accumulate a relayed keyed edit into DO storage, so a later joiner reconstructs the world. Keeps the
31
+ * higher (stamp, by) — the exact rule the free-roam client's LwwStore uses, so server + clients converge. */
32
+ private accumulateEdit;
27
33
  webSocketClose(ws: WS): void;
28
34
  webSocketError(ws: WS): void;
29
35
  private json;
@@ -17,6 +17,13 @@
17
17
  // · `repin` → lone client re-pins the shared-clock epoch
18
18
  // Snapshot bytes are stored chunked (PUT/GET) for join/resync + save. WebSocket
19
19
  // HIBERNATION (state.acceptWebSocket) means no duration billed while a room idles.
20
+ //
21
+ // PERSISTENT KEYED WORLD (free-roam / all-mine-sky): a `relay` whose payload carries an `e = {key, val, stamp,
22
+ // by}` is ALSO accumulated server-side, last-write-wins by (stamp, by), under `e:<key>`. GET `edits` returns the
23
+ // accumulated set so a joiner reconstructs the world — even into an empty room. Persisting from the WS handler
24
+ // (not a concurrent HTTP PUT) is REQUIRED: an HTTP storage write to a DO that holds a live hibernatable socket
25
+ // silently no-ops, whereas a write inside webSocketMessage commits. Relays without an `e.key` (e.g. cogsworth's
26
+ // lockstep commands) are untouched — accumulation is opt-in by payload shape, so RoomCore stays game-agnostic.
20
27
  // ─────────────────────────────────────────────────────────────────────────────
21
28
  const CHUNK = 96 * 1024; // DO value cap is ~128KB; chunk snapshots under it
22
29
  const KEYS = { epoch: 'epoch', chunks: 'chunks', bytes: 'bytes', tick: 'tick' };
@@ -45,6 +52,18 @@ export class RoomCore {
45
52
  pair[1].send(JSON.stringify({ t: 'hello', peers: this.state.getWebSockets().length, epoch, now: Date.now() }));
46
53
  return new Response(null, { status: 101, webSocket: pair[0] });
47
54
  }
55
+ // ── accumulated keyed edits (free-roam join sync): the LWW deltas persisted from the relay stream ──
56
+ if (path === 'edits') {
57
+ const m = await this.state.storage.list({ prefix: 'e:' });
58
+ return new Response(JSON.stringify([...m.values()]), {
59
+ status: 200,
60
+ headers: {
61
+ 'content-type': 'application/json',
62
+ 'cache-control': 'no-store',
63
+ 'x-ugly-no-spa-fallback': '1',
64
+ },
65
+ });
66
+ }
48
67
  // ── snapshot SAVE (join/resync + persistence): body = opaque bytes, stored chunked ──
49
68
  if (request.method === 'PUT' || request.method === 'POST') {
50
69
  const body = new Uint8Array(await request.arrayBuffer());
@@ -87,8 +106,8 @@ export class RoomCore {
87
106
  },
88
107
  });
89
108
  }
90
- /** The app DO forwards its webSocketMessage here. */
91
- webSocketMessage(ws, message) {
109
+ /** The app DO forwards its webSocketMessage here. Async so relayed edits can be durably accumulated. */
110
+ async webSocketMessage(ws, message) {
92
111
  let m = null;
93
112
  try {
94
113
  m = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message));
@@ -104,7 +123,7 @@ export class RoomCore {
104
123
  }
105
124
  if (m.t === 'repin' && typeof m.tick === 'number' && this.state.getWebSockets().length === 1) {
106
125
  const epoch = Date.now() - m.tick * (this.opts.tickMs ?? 0);
107
- void this.state.storage.put(KEYS.epoch, epoch);
126
+ await this.state.storage.put(KEYS.epoch, epoch);
108
127
  ws.send(JSON.stringify({ t: 'epoch', epoch, now: Date.now() }));
109
128
  return;
110
129
  }
@@ -120,6 +139,28 @@ export class RoomCore {
120
139
  const stamped = JSON.stringify({ ...m, seq: ++this.seq });
121
140
  for (const peer of this.state.getWebSockets())
122
141
  peer.send(stamped);
142
+ // free-roam persistence: a relay carrying a keyed edit is accumulated LWW so it outlives the session (fanout
143
+ // first — this must never delay the relay). Opt-in by payload shape → lockstep relays (no e.key) are untouched.
144
+ if (m.t === 'relay')
145
+ await this.accumulateEdit(m.e);
146
+ }
147
+ /** LWW-accumulate a relayed keyed edit into DO storage, so a later joiner reconstructs the world. Keeps the
148
+ * higher (stamp, by) — the exact rule the free-roam client's LwwStore uses, so server + clients converge. */
149
+ async accumulateEdit(e) {
150
+ if (!e || typeof e !== 'object')
151
+ return;
152
+ const ed = e;
153
+ if (typeof ed.key !== 'number' && typeof ed.key !== 'string')
154
+ return;
155
+ const k = `e:${ed.key}`;
156
+ const es = typeof ed.stamp === 'number' ? ed.stamp : 0;
157
+ const cur = await this.state.storage.get(k);
158
+ if (cur) {
159
+ const cs = typeof cur.stamp === 'number' ? cur.stamp : 0;
160
+ if (cs > es || (cs === es && String(cur.by) >= String(ed.by)))
161
+ return; // an existing write outranks it
162
+ }
163
+ await this.state.storage.put(k, e);
123
164
  }
124
165
  webSocketClose(ws) {
125
166
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {