@homie-rocks/studio 0.45.3 → 0.46.2

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,7 @@
1
1
  /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
2
  * Deltas may name a periodic keyframe or the previous ordered frame. A missing
3
- * chained predecessor is ignored until the next keyframe (at most one second).
3
+ * chained predecessor is ignored until the next keyframe. Periodic encoders
4
+ * repair the chain; ordered transports reset it on connection or epoch change.
4
5
  * No history grows with time: one keyframe per connection, no per-frame queue.
5
6
  */
6
7
  const spatial = new WeakMap(), controls = new WeakMap();
@@ -16,7 +17,7 @@ function indexFor(snap, radius) {
16
17
  const p = e[3], key = `${Math.floor(p[0]/size)},${Math.floor(p[1]/size)},${Math.floor((p[2]??0)/size)}`;
17
18
  const list = cells.get(key) ?? []; list.push(e); cells.set(key, list);
18
19
  }
19
- const index = { own, cells, size, order }; byRadius.set(radius,index); return index;
20
+ const index = { own, cells, size, order, candidates: new Map() }; byRadius.set(radius,index); return index;
20
21
  }
21
22
  export function interestSnapshot(snap, seat, radiusM) {
22
23
  const [round, entities] = snap.d;
@@ -27,50 +28,121 @@ export function interestSnapshot(snap, seat, radiusM) {
27
28
  if (own && radiusM === null) visible = entities;
28
29
  else if (own) {
29
30
  const q=own[3], x=Math.floor(q[0]/index.size), y=Math.floor(q[1]/index.size), z=Math.floor((q[2]??0)/index.size);
30
- for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
31
- for(const e of index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]) {
32
- const p=e[3];
33
- if(e===own||(p[0]-q[0])**2+(p[1]-q[1])**2+((p[2]??0)-(q[2]??0))**2<=radiusM**2)visible.push(e);
34
- }
35
- // Snapshot order is part of the view contract, even though cells are not.
36
- visible.sort((a,b)=>index.order.get(a)-index.order.get(b));
31
+ const cell = `${x},${y},${z}`;
32
+ let candidates = index.candidates.get(cell);
33
+ if (!candidates) {
34
+ candidates = [];
35
+ for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
36
+ candidates.push(...(index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]));
37
+ // Every player in this cell shares the ordered candidates. The final
38
+ // radius check remains exact for that player's position.
39
+ candidates.sort((a,b)=>index.order.get(a)-index.order.get(b));
40
+ index.candidates.set(cell, candidates);
41
+ }
42
+ for (const e of candidates) {
43
+ const p=e[3];
44
+ if(e===own||(p[0]-q[0])**2+(p[1]-q[1])**2+((p[2]??0)-(q[2]??0))**2<=radiusM**2)visible.push(e);
45
+ }
37
46
  }
38
47
  }
39
48
  let control = snap.c && controls.get(snap.c);
40
49
  if (!control) { control = new Map((snap.c ?? []).map(row => [row[0], row])); if (snap.c) controls.set(snap.c, control); }
41
50
  return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: control.has(seat) ? [control.get(seat)] : [] };
42
51
  }
43
- const encodedRows = new WeakMap();
44
- const rowText = row => {
45
- let text=encodedRows.get(row);
46
- if(!text){text=row.map(v=>JSON.stringify(v));encodedRows.set(row,text);}
52
+ // Snapshot tuples contain primitives and small numeric arrays. Compare these
53
+ // values without allocating a JSON string for every field on every tick.
54
+ // Keep JSON equality for uncommon object-valued fields, including key order.
55
+ function sameValue(a, b) {
56
+ if (a === b) return true;
57
+ if (Array.isArray(a) && Array.isArray(b)) {
58
+ if (a.length !== b.length) return false;
59
+ for (let i = 0; i < a.length; i++) if (!sameValue(a[i], b[i])) return false;
60
+ return true;
61
+ }
62
+ if (a === null || b === null || typeof a !== 'object' && typeof b !== 'object') return false;
63
+ return JSON.stringify(a) === JSON.stringify(b);
64
+ }
65
+
66
+ // Entity rows are immutable snapshot values. Gates share their comparisons and
67
+ // wire fragments across players whose visual baselines contain the same row.
68
+ const changesByBase = new WeakMap(), wireRows = new WeakMap();
69
+ function changedRow(entity, before) {
70
+ let cached = changesByBase.get(before);
71
+ if (cached?.entity === entity) return cached.row;
72
+ let mask = 0; const values = [];
73
+ for (let i = 1; i < entity.length; i++) if (!sameValue(entity[i], before[i])) { mask |= 1 << i; values.push(entity[i]); }
74
+ const row = mask ? [entity[0], mask, values] : null;
75
+ // A delivery frame visits all players before the next immutable frame.
76
+ // Keep the most recent pair without allocating a WeakMap for every row.
77
+ if (cached) { cached.entity = entity; cached.row = row; }
78
+ else changesByBase.set(before, { entity, row });
79
+ return row;
80
+ }
81
+ function wireRow(row) {
82
+ let text = wireRows.get(row);
83
+ if (text === undefined) { text = JSON.stringify(row); wireRows.set(row, text); }
47
84
  return text;
48
- };
85
+ }
86
+ /** JSON wire equivalent of a snapshot, sharing immutable entity fragments. */
87
+ export function snapshotText(snap) {
88
+ const { d, ...header } = snap;
89
+ let data;
90
+ if (Array.isArray(d)) data = '[' + JSON.stringify(d[0]) + ',[' + d[1].map(wireRow).join(',') + ']' + d.slice(2).map(v => ',' + JSON.stringify(v)).join('') + ']';
91
+ else {
92
+ const { changed, own, ...meta } = d;
93
+ data = JSON.stringify(meta).slice(0, -1) + ',"changed":[' + changed.map(wireRow).join(',') + ']' + (own ? ',"own":[' + own.map(wireRow).join(',') + ']' : '') + '}';
94
+ }
95
+ return JSON.stringify(header).slice(0, -1) + ',"d":' + data + '}';
96
+ }
97
+
98
+ /** Whether a body that left `sent` at tick `sentTick` and kept its velocity is within `tolerance` of `entity` now. */
99
+ const POSITION = 1 << 3, VELOCITY = 1 << 4, HEADING = 1 << 5;
100
+ function coasts(entity, sent, ticks, hz, tolerance) {
101
+ const p = entity[3], q = sent[3], v = sent[4], t = ticks / hz;
102
+ for (let i = 0; i < p.length; i++) if (Math.abs(p[i] - (q[i] + v[i] * t)) > tolerance) return false;
103
+ // A heading is a unit vector: the same tolerance, as a turn of that many metres at one metre's reach.
104
+ const h = entity[5], g = sent[5];
105
+ if (Array.isArray(h) && Array.isArray(g)) for (let i = 0; i < h.length; i++) if (Math.abs(h[i] - g[i]) > COAST_HEADING) return false;
106
+ return true;
107
+ }
108
+ /** A heading a coasting body may be off by before it is sent again: a twentieth of a radian, under three degrees. */
109
+ const COAST_HEADING = 0.05;
49
110
 
50
- export function snapshotEncoder(keyframeTicks, chained = false) {
111
+ /**
112
+ * `coast` ({ hz, toleranceM }, chained encoders only): a body that is where its last SENT position and velocity
113
+ * put it, within the tolerance, is not sent again; the decoder moves it there itself (`d.coast`). A body in a
114
+ * crowd changes its position every tick and its course rarely, so most of a frame goes unsent. The tolerance is the
115
+ * game's own `view.precisionM`; without one nothing coasts. A player's own body is always sent, exactly.
116
+ */
117
+ export function snapshotEncoder(keyframeTicks, chained = false, coast = null) {
51
118
  let base = null;
52
- let rows = new Map(), keyTick = -Infinity;
119
+ let rows = new Map(), keyTick = -Infinity, generation = 0;
120
+ const coasting = chained && coast && coast.toleranceM > 0 && coast.hz > 0 ? coast : null;
53
121
  return {
54
- reset() { base = null; rows.clear(); keyTick = -Infinity; },
122
+ reset() { base = null; rows.clear(); keyTick = -Infinity; generation = 0; },
55
123
  encode(snap, seat = null) {
56
124
  if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - keyTick >= keyframeTicks) {
57
125
  base = snap; keyTick = snap.k;
58
- rows = new Map(snap.d[1].map(e => [e[0], rowText(e)]));
59
- return snap;
126
+ rows = new Map(snap.d[1].map(e => [e[0], { entity: e, generation, k: snap.k }]));
127
+ return coasting ? { ...snap, coast: coasting.hz } : snap;
60
128
  }
61
- const present = new Set(), changed = [];
129
+ const changed = [], removed = []; generation++;
62
130
  const own = chained && seat !== null ? snap.d[1].filter(e => e[9] === seat && e[10] !== 1) : null;
63
131
  for (const entity of snap.d[1]) {
64
- const id = entity[0]; present.add(id);
65
- if (own?.includes(entity)) continue;
66
- const before = rows.get(id);
132
+ const id = entity[0], held = rows.get(id), before = held?.entity;
133
+ if (held) held.generation = generation;
134
+ else if (chained) rows.set(id, { entity, generation, k: snap.k });
135
+ if (own?.includes(entity)) { if (held && chained) { held.entity = entity; held.k = snap.k; } continue; }
67
136
  if (!before) { changed.push(entity); continue; }
68
- let mask = 0; const values = [];
69
- for (let i = 1; i < entity.length; i++) if (rowText(entity)[i] !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
70
- if (mask) changed.push([id, mask, values]);
137
+ const change = changedRow(entity, before);
138
+ // Only where it is, how fast and which way it faces: anything else a body changed is always sent.
139
+ if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM)) continue;
140
+ if (held && chained) { held.entity = entity; held.k = snap.k; }
141
+ if (change) changed.push(change);
71
142
  }
72
- const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 && JSON.stringify(snap.d[2]) !== JSON.stringify(base.d[2]) ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
73
- if (chained) { base = snap; rows = new Map(snap.d[1].map(e => [e[0], rowText(e)])); }
143
+ rows.forEach((held, id) => { if (held.generation !== generation) { removed.push(id); if (chained) rows.delete(id); } });
144
+ const out = { ...snap, ...(coasting ? { coast: coasting.hz } : {}), d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 && JSON.stringify(snap.d[2]) !== JSON.stringify(base.d[2]) ? { collision: snap.d[2] } : {}), changed, removed, ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
145
+ if (chained) base = snap;
74
146
  return out;
75
147
  },
76
148
  };
@@ -79,8 +151,21 @@ export function snapshotEncoder(keyframeTicks, chained = false) {
79
151
  export function snapshotDecoder() {
80
152
  let base = null;
81
153
  let rows = new Map();
154
+ // The tick each row was last SENT at: a frame that says `coast` leaves a body out while it keeps its course,
155
+ // and this end moves it along its last velocity. `rows` keeps what was sent; what is returned is where it is now.
156
+ let sentAt = new Map();
157
+ const coasted = (list, snap) => {
158
+ const hz = snap.coast;
159
+ if (!(hz > 0)) return list;
160
+ return list.map(row => {
161
+ const ticks = snap.k - (sentAt.get(row[0]) ?? snap.k), v = row[4];
162
+ if (ticks <= 0 || !Array.isArray(v) || !v.some(n => n !== 0)) return row;
163
+ const moved = row.slice(); moved[3] = row[3].map((n, i) => n + v[i] * ticks / hz);
164
+ return moved;
165
+ });
166
+ };
82
167
  return {
83
- reset() { base = null; rows.clear(); },
168
+ reset() { base = null; rows.clear(); sentAt.clear(); },
84
169
  decode(snap) {
85
170
  if (!snap || typeof snap !== 'object') return null;
86
171
  if (Array.isArray(snap.d)) {
@@ -89,6 +174,7 @@ export function snapshotDecoder() {
89
174
  // A late duplicate keyframe must not replace the newer baseline.
90
175
  if (!base || snap.e !== base.e || snap.k >= base.k) {
91
176
  base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
177
+ sentAt = new Map(snap.d[1].map(e => [e[0], snap.k]));
92
178
  }
93
179
  return snap;
94
180
  }
@@ -105,13 +191,14 @@ export function snapshotDecoder() {
105
191
  for (const e of d.own) visible.set(e[0], e);
106
192
  return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
107
193
  }
108
- const next = new Map(rows);
109
- for (const id of d.removed) next.delete(id);
194
+ // Validate first, then mutate only the private chained baseline. Returned
195
+ // snapshots keep their own entity array and immutable row values.
196
+ const updates = [];
110
197
  for (const change of d.changed) {
111
198
  if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
112
199
  if (change.length !== 3) {
113
200
  if (change.length < 9) return null;
114
- next.set(change[0], change); continue;
201
+ updates.push(change); continue;
115
202
  }
116
203
  if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
117
204
  const old = rows.get(change[0]);
@@ -119,10 +206,13 @@ export function snapshotDecoder() {
119
206
  const entity = old.slice(); let j = 0;
120
207
  for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
121
208
  if (j !== change[2].length) return null;
122
- next.set(entity[0], entity);
209
+ updates.push(entity);
123
210
  }
124
- for (const e of d.own ?? []) next.set(e[0], e);
125
- const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
211
+ const next = d.chain ? rows : new Map(rows);
212
+ for (const id of d.removed) { next.delete(id); if (d.chain) sentAt.delete(id); }
213
+ for (const entity of updates) { next.set(entity[0], entity); if (d.chain) sentAt.set(entity[0], snap.k); }
214
+ for (const e of d.own ?? []) { next.set(e[0], e); if (d.chain) sentAt.set(e[0], snap.k); }
215
+ const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
126
216
  if (d.chain) { base = decoded; rows = next; }
127
217
  return decoded;
128
218
  },
@@ -134,33 +224,212 @@ export function snapshotDecoder() {
134
224
  */
135
225
  const quantizedRows = new WeakMap();
136
226
  export function scheduledView({ radiusM = null, precisionM = 0, nearM = null, farHz = null } = {}, tickHz = 20) {
137
- let previous = new Map(), epoch = null;
227
+ const previous = new Map(); let epoch = null, generation = 0;
138
228
  return (snap, seat) => {
139
229
  if (snap.e !== epoch) { previous.clear(); epoch = snap.e; }
140
230
  const selected = interestSnapshot(snap, seat, radiusM);
141
231
  const own = selected.d[1].find(e => e[9] === seat && e[10] !== 1);
142
- const next = new Map();
143
- const entities = selected.d[1].map(entity => {
144
- let row = entity;
232
+ generation++;
233
+ // Radius selection already owns its array. Overview selection shares the
234
+ // source, so copy only that case before replacing rows with visual values.
235
+ const entities = selected.d[1] === snap.d[1] ? selected.d[1].slice() : selected.d[1];
236
+ for (let index = 0; index < entities.length; index++) {
237
+ const entity = entities[index], held = previous.get(entity[0]);
238
+ let row = entity, sampledTick = snap.k;
145
239
  if (entity !== own) {
146
- const distance = own ? Math.hypot(...entity[3].map((n, i) => n - (own[3][i] ?? 0))) : 0;
147
- const old = previous.get(entity[0]);
148
- const interval = farHz && nearM !== null && distance > nearM ? Math.max(1, Math.round(tickHz / farHz)) : 1;
149
- if (old && snap.k % interval !== 0) row = old;
150
- else if (precisionM > 0) {
240
+ const p = entity[3], q = own?.[3];
241
+ const distanceSquared = q ? (p[0]-q[0])**2 + (p[1]-q[1])**2 + ((p[2]??0)-(q[2]??0))**2 : 0;
242
+ const old = held?.row;
243
+ const interval = farHz && nearM !== null && distanceSquared > nearM**2 ? Math.max(1, Math.round(tickHz / farHz)) : 1;
244
+ // Spread far-view refreshes across players instead of sending the
245
+ // whole room's largest deltas on the same tick. Each player retains
246
+ // the declared rate; newly visible bodies still arrive immediately.
247
+ const phase = (seat ?? 0) % interval;
248
+ sampledTick = snap.k - ((snap.k - phase) % interval + interval) % interval;
249
+ // Coalescing may skip the exact phase. Refresh at the first delivered
250
+ // tick after it, instead of waiting indefinitely for that residue.
251
+ if (old && interval > 1 && snap.k - held.sampledTick < interval) {
252
+ row = old; sampledTick = held.sampledTick;
253
+ } else if (precisionM > 0) {
151
254
  let versions = quantizedRows.get(entity);
152
- if (!versions) { versions = new Map(); quantizedRows.set(entity, versions); }
153
- row = versions.get(precisionM);
255
+ row = versions?.precision === precisionM ? versions.row : versions?.others?.get(precisionM);
154
256
  if (!row) {
155
257
  row = entity.slice();
156
258
  for (const i of [3, 4]) row[i] = entity[i].map(n => Number((Math.round(n / precisionM) * precisionM).toPrecision(12)));
157
- versions.set(precisionM, row);
259
+ if (!versions) quantizedRows.set(entity, { precision: precisionM, row });
260
+ else { versions.others ??= new Map(); versions.others.set(precisionM, row); }
158
261
  }
159
262
  }
160
263
  }
161
- next.set(entity[0], row); return row;
162
- });
163
- previous = next;
164
- return { ...selected, d: [selected.d[0], entities, ...selected.d.slice(2)] };
264
+ if (held) { held.row = row; held.generation = generation; held.sampledTick = sampledTick; }
265
+ else previous.set(entity[0], { row, generation, sampledTick });
266
+ entities[index] = row;
267
+ }
268
+ previous.forEach((held, id) => { if (held.generation !== generation) previous.delete(id); });
269
+ selected.d[1] = entities;
270
+ return selected;
271
+ };
272
+ }
273
+
274
+ /* ------------------------------------------------------------------ a Gate's crowd path (0.46.2)
275
+ *
276
+ * One Gate holds a few dozen players of a room of hundreds, and each of them sees a few hundred bodies. Selecting,
277
+ * comparing and serialising every one of those for every player, twenty times a second, is what a Gate ran out of
278
+ * time doing on Cloudflare at a thousand players. This path does per WORLD what does not depend on the player (the
279
+ * visual value of each body, whether anything but its motion changed, the text of its row), and per player only
280
+ * what must be: is this body in range, and is it still where this player was last told it would be.
281
+ *
282
+ * It is the same wire as `scheduledView` + `snapshotEncoder(Infinity, true, coast)` and the same decoder reads it.
283
+ * It differs in one thing a player can see: a far body is not held still between its `farHz` refreshes and then
284
+ * moved; it coasts, and is sent again when it has strayed half the distance it covers between those refreshes.
285
+ */
286
+ const sameTail = (a, b) => {
287
+ if (a.length !== b.length || a[1] !== b[1] || a[2] !== b[2] || a[6] !== b[6]) return false;
288
+ for (let i = 7; i < a.length; i++) if (!sameValue(a[i], b[i])) return false;
289
+ return true;
290
+ };
291
+ /** What a Gate works out once for each world, whoever is looking. One per Gate. */
292
+ export function crowdWorlds({ radiusM = null, precisionM = 0 } = {}) {
293
+ const known = new Map(); let generation = 0, last = null, lastOut = null;
294
+ // A precision that is one over a whole number (0.01, 0.05, 0.25) rounds with one multiply and one divide, to the
295
+ // same number the longer way gives; any other takes the longer way.
296
+ const steps = precisionM > 0 ? 1 / precisionM : 0, whole = precisionM > 0 && Math.abs(steps - Math.round(steps)) < 1e-9 ? Math.round(steps) : 0;
297
+ const quantize = (v) => {
298
+ const out = new Array(v.length);
299
+ if (whole) for (let a = 0; a < v.length; a++) out[a] = Math.round(v[a] * whole) / whole;
300
+ else for (let a = 0; a < v.length; a++) out[a] = Number((Math.round(v[a] / precisionM) * precisionM).toPrecision(12));
301
+ return out;
302
+ };
303
+ return {
304
+ radiusM, precisionM,
305
+ of(snap) {
306
+ if (snap === last) return lastOut;
307
+ const entities = snap.d[1], n = entities.length; generation++;
308
+ const stamps = new Int32Array(n), visual = new Array(n), texts = new Array(n);
309
+ const own = new Map(), cells = new Map(), size = radiusM > 0 ? radiusM : 1;
310
+ for (let i = 0; i < n; i++) {
311
+ const e = entities[i]; let held = known.get(e[0]);
312
+ // The stamp moves when anything but position, velocity and heading changed: a field, a revision, a seat.
313
+ if (!held) { held = { row: e, stamp: 1, generation }; known.set(e[0], held); }
314
+ else { if (held.row !== e && !sameTail(held.row, e)) held.stamp++; held.row = e; held.generation = generation; }
315
+ stamps[i] = held.stamp;
316
+ if (e[9] !== undefined && e[10] !== 1) own.set(e[9], i);
317
+ if (radiusM !== null) {
318
+ const p = e[3], key = `${Math.floor(p[0] / size)},${Math.floor(p[1] / size)},${Math.floor((p[2] ?? 0) / size)}`;
319
+ const list = cells.get(key); if (list) list.push(i); else cells.set(key, [i]);
320
+ }
321
+ }
322
+ if (known.size > n) for (const [id, held] of known) if (held.generation !== generation) known.delete(id);
323
+ let all = radiusM === null ? Array.from({ length: n }, (_, i) => i) : null; const around = new Map();
324
+ const control = new Map((snap.c ?? []).map(row => [row[0], row]));
325
+ last = snap;
326
+ lastOut = {
327
+ entities, stamps, own, control, size,
328
+ rest: snap.d.slice(2), restText: snap.d.length > 2 ? JSON.stringify(snap.d[2]) : null,
329
+ /** The body as others see it: the game's precision on where and how fast, a thousandth on which way. */
330
+ visual(i) {
331
+ let row = visual[i];
332
+ if (row === undefined) {
333
+ const e = entities[i];
334
+ if (precisionM > 0) {
335
+ row = e.slice();
336
+ row[3] = quantize(e[3]); row[4] = quantize(e[4]);
337
+ if (Array.isArray(e[5])) { const h = e[5], out = new Array(h.length); for (let a = 0; a < h.length; a++) out[a] = Math.round(h[a] * 1000) / 1000; row[5] = out; }
338
+ } else row = e;
339
+ visual[i] = row;
340
+ }
341
+ return row;
342
+ },
343
+ /** `[id, mask, values]` as text, for a change of only where, how fast and which way (mask of bits 3, 4, 5). */
344
+ motion(i, mask) {
345
+ const slot = texts[i] ??= {}; let text = slot[mask];
346
+ if (text === undefined) {
347
+ const row = this.visual(i), values = [];
348
+ for (let f = 3; f <= 5; f++) if (mask & (1 << f)) values.push(row[f]);
349
+ text = slot[mask] = JSON.stringify([row[0], mask, values]);
350
+ }
351
+ return text;
352
+ },
353
+ everything() { return all ??= Array.from({ length: n }, (_, i) => i); },
354
+ /** Indices of every body in the nine (or 27) cells about a point, in the world's order. */
355
+ near(p) {
356
+ if (radiusM === null) return this.everything();
357
+ const x = Math.floor(p[0] / size), y = Math.floor(p[1] / size), z = Math.floor((p[2] ?? 0) / size), key = `${x},${y},${z}`;
358
+ let list = around.get(key);
359
+ if (!list) {
360
+ list = [];
361
+ for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) { const cell = cells.get(`${x + dx},${y + dy},${z + dz}`); if (cell) for (const i of cell) list.push(i); }
362
+ list.sort((a, b) => a - b); around.set(key, list);
363
+ }
364
+ return list;
365
+ },
366
+ };
367
+ return lastOut;
368
+ },
369
+ };
370
+ }
371
+ const sameVec = (a, b) => { if (a === b) return true; if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; };
372
+ /** One player's view of a Gate's worlds: `(snap, seat)` to the text of the frame to send them. */
373
+ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz = 20) {
374
+ const rows = new Map(); let epoch, tick = -Infinity, generation = 0, collision = null;
375
+ const { radiusM, precisionM } = worlds, reach = radiusM === null ? Infinity : radiusM * radiusM;
376
+ const nearSq = nearM !== null && farHz ? nearM * nearM : Infinity, farShare = farHz ? 0.5 / farHz : 0;
377
+ return (snap, seat) => {
378
+ const w = worlds.of(snap), { entities, stamps } = w;
379
+ const me = seat === null ? undefined : w.own.get(seat), mine = me === undefined ? null : entities[me], q = mine?.[3];
380
+ const key = tick === -Infinity || snap.e !== epoch || snap.k <= tick, baseTick = tick;
381
+ if (key) rows.clear();
382
+ epoch = snap.e; tick = snap.k; generation++;
383
+ const control = w.control.get(seat);
384
+ const head = JSON.stringify({ t: 'snap', from: null, k: snap.k, st: snap.st, ...(snap.e !== undefined ? { e: snap.e } : {}), c: control ? [control] : [], coast: tickHz }).slice(0, -1);
385
+ // A player with no body of their own (a screen) is shown the whole room; one whose body is gone, nothing.
386
+ const candidates = seat === null ? w.everything() : mine ? w.near(q) : [];
387
+ const parts = [];
388
+ for (let n = 0; n < candidates.length; n++) {
389
+ const i = candidates[n], e = entities[i];
390
+ let far = false;
391
+ if (e !== mine && q) {
392
+ const p = e[3]; let d = 0;
393
+ for (let a = 0; a < p.length; a++) d += (p[a] - q[a]) ** 2;
394
+ if (d > reach) continue;
395
+ far = d > nearSq;
396
+ }
397
+ const row = e === mine ? e : w.visual(i);
398
+ let held = rows.get(e[0]);
399
+ if (!held) { rows.set(e[0], { row, k: snap.k, stamp: stamps[i], generation }); if (e !== mine || key) parts.push(wireRow(row)); continue; }
400
+ held.generation = generation;
401
+ if (e === mine) { held.row = row; held.k = snap.k; held.stamp = stamps[i]; continue; }
402
+ const before = held.row;
403
+ if (held.stamp === stamps[i]) {
404
+ // Only its motion can differ. Where its last sent position and velocity put it now, within the game's
405
+ // precision (near) or half the way it goes between far refreshes: this player is told nothing.
406
+ const p = row[3], b = before[3], v = before[4], t = (snap.k - held.k) / tickHz;
407
+ let tolerance = precisionM;
408
+ if (far) { let speed = 0; for (let a = 0; a < v.length; a++) speed += v[a] * v[a]; tolerance = Math.max(precisionM, Math.sqrt(speed) * farShare); }
409
+ let there = true;
410
+ for (let a = 0; a < p.length; a++) if (Math.abs(p[a] - (b[a] + v[a] * t)) > tolerance) { there = false; break; }
411
+ if (there) {
412
+ const h = row[5], g = before[5];
413
+ if (Array.isArray(h) && Array.isArray(g)) for (let a = 0; a < h.length; a++) if (Math.abs(h[a] - g[a]) > COAST_HEADING) { there = false; break; }
414
+ }
415
+ if (there && tolerance > 0) continue;
416
+ const mask = (sameVec(p, b) ? 0 : POSITION) | (sameVec(row[4], v) ? 0 : VELOCITY) | (sameVec(row[5], before[5]) ? 0 : HEADING);
417
+ if (!mask) continue;
418
+ held.row = row; held.k = snap.k;
419
+ parts.push(w.motion(i, mask));
420
+ continue;
421
+ }
422
+ const change = changedRow(row, before);
423
+ held.row = row; held.k = snap.k; held.stamp = stamps[i];
424
+ if (change) parts.push(wireRow(change));
425
+ }
426
+ if (key) {
427
+ collision = w.restText;
428
+ return head + ',"d":[' + JSON.stringify(snap.d[0]) + ',[' + parts.join(',') + ']' + w.rest.map(v => ',' + JSON.stringify(v)).join('') + ']}';
429
+ }
430
+ let removed = '';
431
+ if (rows.size > 0) for (const [id, held] of rows) if (held.generation !== generation) { removed += (removed ? ',' : '') + JSON.stringify(id); rows.delete(id); }
432
+ const changedCollision = w.restText !== collision; collision = w.restText;
433
+ return head + ',"d":{"base":' + baseTick + ',"round":' + JSON.stringify(snap.d[0]) + (changedCollision && w.restText !== null ? ',"collision":' + w.restText : '') + ',"removed":[' + removed + '],"chain":true,"changed":[' + parts.join(',') + ']' + (mine ? ',"own":[' + wireRow(mine) + ']' : '') + '}}';
165
434
  };
166
435
  }
package/rules/rules.ts CHANGED
@@ -61,6 +61,7 @@ export interface Field {
61
61
  readonly init?: unknown;
62
62
  /** On one integer field of a player's body: this is the score a round's results rank by. */
63
63
  readonly score?: true;
64
+ readonly durable?: boolean;
64
65
  readonly visibility?: 'owner' | 'server' | 'results';
65
66
  /** `text`: the most characters. `list` and `map`: the most entries. */
66
67
  readonly max?: number;
@@ -69,11 +70,11 @@ export interface Field {
69
70
  /** `struct`: its fields, in order. */
70
71
  readonly fields?: Readonly<Record<string, Field>>;
71
72
  }
72
- export interface FieldOptions { init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
73
+ export interface FieldOptions { durable?: boolean; init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
73
74
  export type Fields = Readonly<Record<string, Field>>;
74
75
 
75
76
  const made = (t: FieldKind, o: FieldOptions | undefined, extra: Partial<Field> = {}): Field =>
76
- Object.freeze({ t, ...extra, ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
77
+ Object.freeze({ t, ...extra, ...(o?.durable !== undefined ? {durable:o.durable} : {}), ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
77
78
  const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
78
79
 
79
80
  /**
@@ -167,7 +168,7 @@ export interface RulesDef {
167
168
  records?: RoundRecords;
168
169
  historySeconds?: number;
169
170
  start?: RoomHandler;
170
- join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
171
+ join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
171
172
  on?: Record<string, RoomHandler>;
172
173
  };
173
174
  asks?: Record<string, AskDef>;
@@ -189,9 +190,14 @@ export function defineMove<M extends Record<string, MoveFn>>(def: M): M {
189
190
 
190
191
  /* ------------------------------------------------------------------ settings (game.json "room") */
191
192
 
193
+ export interface Character { kind: string; at: Vec3; heading: Vec3; fields: Record<string, unknown>; motion: Record<string, unknown> }
194
+
192
195
  export interface RoomSettings {
193
196
  host: 'server' | 'browser';
194
197
  offline: boolean;
198
+ /** Persistent rooms retain their committed world until explicitly replaced. */
199
+ lifetime?: 'match' | 'persistent';
200
+ characters?: boolean;
195
201
  tickHz: number;
196
202
  inputHz: number;
197
203
  /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
@@ -199,6 +205,8 @@ export interface RoomSettings {
199
205
  durability: { movementSeconds: number };
200
206
  budget: { tick: number };
201
207
  predict: { idleHold?: boolean; catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
208
+ /** The studio's own pace on what one player sends, messages a second by frame kind (`in`, `ev`, ...). Absent: none. */
209
+ rates?: Record<string, number>;
202
210
  }
203
211
  /**
204
212
  * The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
@@ -232,12 +240,24 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
232
240
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
233
241
  return n;
234
242
  };
235
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
236
- for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
243
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters', 'rates']);
244
+ for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view, rates)`);
245
+ let rates: Record<string, number> | undefined;
246
+ if (r.rates !== undefined) {
247
+ if (!r.rates || typeof r.rates !== 'object' || Array.isArray(r.rates)) problems.push('"room.rates" is an object of messages a second per player by kind ({ "in": 60 }); using none');
248
+ else for (const [kind, v] of Object.entries(r.rates)) {
249
+ if (!/^[a-z][a-z-]{0,15}$/.test(kind) || typeof v !== 'number' || !Number.isFinite(v) || v < 0) problems.push(`"room.rates.${kind}" is a number of messages a second, zero or more; leaving it out`);
250
+ else (rates ??= {})[kind] = v;
251
+ }
252
+ }
237
253
  let host: 'server' | 'browser' = 'server';
238
254
  if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
239
- let offline = true;
240
- if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
255
+ if (r.lifetime !== undefined && !['match', 'persistent'].includes(r.lifetime)) problems.push('"room.lifetime" is "match" or "persistent"');
256
+ if (r.lifetime === 'persistent' && host !== 'server') problems.push('"room.lifetime: persistent" needs room.host "server"');
257
+ if (r.characters !== undefined && typeof r.characters !== 'boolean') problems.push('"room.characters" is true or false');
258
+ if (r.characters === true && r.lifetime !== 'persistent') problems.push('"room.characters" needs room.lifetime "persistent"');
259
+ let offline = r.lifetime !== 'persistent';
260
+ if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push(`"room.offline" is true or false; using ${offline}`); }
241
261
  const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
242
262
  const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
243
263
  if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
@@ -248,9 +268,9 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
248
268
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
249
269
  return {
250
270
  settings: {
251
- host, offline, tickHz, inputHz,
271
+ host, offline, tickHz, inputHz, ...(rates ? { rates } : {}), ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
252
272
  view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
253
- durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
273
+ durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
254
274
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
255
275
  predict: { ...(p.idleHold === true ? {idleHold:true} : {}), catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
256
276
  },
@@ -403,6 +423,7 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
403
423
  const init = own(x, 'init');
404
424
  return Object.freeze({
405
425
  t: x.t,
426
+ ...(x.durable ? {durable:true} : {}),
406
427
  ...(x.visibility ? {visibility:x.visibility} : {}),
407
428
  ...(x.t === 'text' || x.t === 'list' || x.t === 'map' ? { max: x.max } : {}),
408
429
  ...(x.t === 'list' || x.t === 'map' ? { of: cleanField(x.of, `${where} (its entries)`, false) } : {}),
@@ -416,6 +437,7 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
416
437
  if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
417
438
  if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
418
439
  if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
440
+ if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
419
441
  if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
420
442
  if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
421
443
  if (x.t === 'text' && !(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 4096)) throw new Error(`${where}: f.text(max) needs its largest length, 1 to 4096 characters`);
@@ -489,6 +511,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
489
511
  const dims = d.space?.dims;
490
512
  if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
491
513
  const settings = env.settings ?? ROOM_DEFAULTS;
514
+ if (settings.characters && Object.values(d.entities ?? {}).some(e => typeof e.player === 'object' && e.player.leave === 'bot')) throw new Error('Account characters return on leaving; player.leave must be despawn');
492
515
  const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
493
516
  if (dims === 2 && (map.spheres.length || map.capsules.length || map.heightTiles.length)) throw new Error('spheres, capsules and height tiles need space.dims: 3');
494
517
  if (dims === 3 && map.bounds.max.z <= map.bounds.min.z) throw new Error('a 3D map needs bounds with positive height');
@@ -0,0 +1,20 @@
1
+ /** Bounded stage samples. Frozen runtime clocks are unavailable, never zero CPU. */
2
+ export function stageTimings(now = () => globalThis.performance?.now?.() ?? 0, limit = 256) {
3
+ const stages = new Map();
4
+ return {
5
+ now,
6
+ record(stage, start) {
7
+ const ms = Math.max(0, now() - start);
8
+ let sample = stages.get(stage);
9
+ if (!sample) { sample = { values: [], next: 0 }; stages.set(stage, sample); }
10
+ sample.values[sample.next] = ms; sample.next = (sample.next + 1) % limit;
11
+ },
12
+ facts() {
13
+ const advancing = [...stages.values()].some(({ values }) => values.some(ms => ms > 0));
14
+ return { clock: advancing ? 'performance' : 'unavailable-or-below-resolution', ms: Object.fromEntries([...stages].map(([name, { values }]) => {
15
+ const sorted = [...values].sort((a,b)=>a-b);
16
+ return [name, advancing ? { samples: sorted.length, p50: sorted[Math.floor(sorted.length*.5)], p95: sorted[Math.floor(sorted.length*.95)], max: sorted.at(-1) } : null];
17
+ })) };
18
+ }
19
+ };
20
+ }
@@ -0,0 +1,2 @@
1
+ // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.46.2","sha256":"335192af92b18b0857b7a32abc28017670a609737ef888a592f78e75561d16f0"});