ugly-game 0.5.7 → 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.
@@ -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.7",
3
+ "version": "0.5.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {