@homie-rocks/studio 0.46.0 → 0.46.3
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.
- package/CHANGELOG.md +105 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +115 -5
- package/guides/game/SKILL.md +5 -0
- package/lib/build-identity.mjs +43 -0
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/rules-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +5 -5
- package/lib/template-history.json +84 -0
- package/netplay/netplay.ts +19 -6
- package/package.json +4 -2
- package/rules/core.ts +29 -4
- package/rules/host.ts +119 -21
- package/rules/interest.mjs +320 -51
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +29 -8
- package/rules/timing.mjs +20 -0
- package/rules/types.ts +5 -0
- package/rules/view.ts +229 -25
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +2 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +92 -10
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +113 -19
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
- package/worker/world-wire.mjs +110 -0
package/rules/interest.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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],
|
|
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
|
|
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]
|
|
65
|
-
if (
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
+
updates.push(entity);
|
|
123
210
|
}
|
|
124
|
-
|
|
125
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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/pack.ts
CHANGED
|
@@ -140,6 +140,7 @@ const made = <T extends object>(fd: Field, o: T): T => { Object.freeze(o); MADE.
|
|
|
140
140
|
export function coerce(fd: Field, v: unknown, dims: number): unknown {
|
|
141
141
|
switch (fd.t) {
|
|
142
142
|
case 'bit': case 'press': return v === true || v === 1;
|
|
143
|
+
case 'held':
|
|
143
144
|
case 'fix': { const x = num(v); const n = Math.round(x * FIX_STEP) / FIX_STEP; return Number.isFinite(n) ? Math.max(-FIX_MAX, Math.min(FIX_MAX, n)) + 0 : zeroed(v, x); }
|
|
144
145
|
case 'vec3': return vec3(v, dims);
|
|
145
146
|
case 'dir': return dir(v, dims);
|
package/rules/rules.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const QUEUE_MAX = 16_384;
|
|
|
54
54
|
|
|
55
55
|
/* ------------------------------------------------------------------ field types */
|
|
56
56
|
|
|
57
|
-
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse';
|
|
57
|
+
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse' | 'held';
|
|
58
58
|
export interface Field {
|
|
59
59
|
readonly t: FieldKind;
|
|
60
60
|
/** The value a new entity starts with. */
|
|
@@ -82,7 +82,8 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
|
|
|
82
82
|
* false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
|
|
83
83
|
* unit vector in the declared dimensions; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
|
|
84
84
|
* entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
|
|
85
|
-
* fires once
|
|
85
|
+
* fires once; `held` (in `input` only) is a number the browser averages over each tick, so a tap shorter than a tick
|
|
86
|
+
* moves for the part of the tick it was down.
|
|
86
87
|
*/
|
|
87
88
|
export const f = brand(Object.freeze({
|
|
88
89
|
u8: plain('u8'), u16: plain('u16'), u32: plain('u32'), i8: plain('i8'), i16: plain('i16'), i32: plain('i32'),
|
|
@@ -93,6 +94,8 @@ export const f = brand(Object.freeze({
|
|
|
93
94
|
struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
|
|
94
95
|
press: (): Field => made('press', undefined),
|
|
95
96
|
pulse: (): Field => made('pulse', undefined),
|
|
97
|
+
/** Input only (0.46.3): a number averaged over the tick. A key down for a third of a tick reads a third; a stick reads its mean. */
|
|
98
|
+
held: (): Field => made('held', undefined),
|
|
96
99
|
}));
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -167,6 +170,8 @@ export interface RulesDef {
|
|
|
167
170
|
bots?: { keep: number };
|
|
168
171
|
records?: RoundRecords;
|
|
169
172
|
historySeconds?: number;
|
|
173
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
174
|
+
rewindSeconds?: number;
|
|
170
175
|
start?: RoomHandler;
|
|
171
176
|
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> };
|
|
172
177
|
on?: Record<string, RoomHandler>;
|
|
@@ -205,6 +210,8 @@ export interface RoomSettings {
|
|
|
205
210
|
durability: { movementSeconds: number };
|
|
206
211
|
budget: { tick: number };
|
|
207
212
|
predict: { idleHold?: boolean; catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
|
|
213
|
+
/** The studio's own pace on what one player sends, messages a second by frame kind (`in`, `ev`, ...). Absent: none. */
|
|
214
|
+
rates?: Record<string, number>;
|
|
208
215
|
}
|
|
209
216
|
/**
|
|
210
217
|
* The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
|
|
@@ -238,8 +245,16 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
238
245
|
if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
|
|
239
246
|
return n;
|
|
240
247
|
};
|
|
241
|
-
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters']);
|
|
242
|
-
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)`);
|
|
248
|
+
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters', 'rates']);
|
|
249
|
+
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)`);
|
|
250
|
+
let rates: Record<string, number> | undefined;
|
|
251
|
+
if (r.rates !== undefined) {
|
|
252
|
+
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');
|
|
253
|
+
else for (const [kind, v] of Object.entries(r.rates)) {
|
|
254
|
+
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`);
|
|
255
|
+
else (rates ??= {})[kind] = v;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
243
258
|
let host: 'server' | 'browser' = 'server';
|
|
244
259
|
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"'); }
|
|
245
260
|
if (r.lifetime !== undefined && !['match', 'persistent'].includes(r.lifetime)) problems.push('"room.lifetime" is "match" or "persistent"');
|
|
@@ -258,7 +273,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
258
273
|
const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
|
|
259
274
|
return {
|
|
260
275
|
settings: {
|
|
261
|
-
host, offline, tickHz, inputHz, ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
|
|
276
|
+
host, offline, tickHz, inputHz, ...(rates ? { rates } : {}), ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
|
|
262
277
|
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) } : {}) },
|
|
263
278
|
durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
|
|
264
279
|
budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
|
|
@@ -377,6 +392,8 @@ export interface Compiled {
|
|
|
377
392
|
view: FieldList;
|
|
378
393
|
records?: RoundRecords;
|
|
379
394
|
historySeconds?: number;
|
|
395
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
396
|
+
rewindSeconds?: number;
|
|
380
397
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
381
398
|
bots: number;
|
|
382
399
|
start: RoomHandler | null;
|
|
@@ -399,7 +416,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
|
|
|
399
416
|
|
|
400
417
|
const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
|
|
401
418
|
const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
|
|
402
|
-
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
|
|
419
|
+
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse', 'held']);
|
|
403
420
|
|
|
404
421
|
/**
|
|
405
422
|
* One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
|
|
@@ -426,7 +443,8 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
|
|
|
426
443
|
const x = fd as Field;
|
|
427
444
|
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)…)`);
|
|
428
445
|
if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
|
|
429
|
-
if (
|
|
446
|
+
if (x.t === 'held' && !inInput) throw new Error(`${where}: f.held() is for input only (a control averaged over the tick it was held in)`);
|
|
447
|
+
if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'held' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.held(), f.bit() or f.press()`);
|
|
430
448
|
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`);
|
|
431
449
|
if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
|
|
432
450
|
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`);
|
|
@@ -636,13 +654,14 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
636
654
|
}
|
|
637
655
|
}
|
|
638
656
|
if (room.historySeconds !== undefined && (typeof room.historySeconds !== 'number' || !Number.isFinite(room.historySeconds) || room.historySeconds < 0 || room.historySeconds > 2)) throw new Error('room.historySeconds is from 0 to 2');
|
|
657
|
+
if (room.rewindSeconds !== undefined && (typeof room.rewindSeconds !== 'number' || !Number.isFinite(room.rewindSeconds) || room.rewindSeconds < 0 || room.rewindSeconds > (room.historySeconds ?? 0))) throw new Error('room.rewindSeconds is the most a shot is rewound for a slow connection: a number from 0 to room.historySeconds');
|
|
639
658
|
const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
|
|
640
659
|
const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
|
|
641
660
|
if(d.navigation){const entries=Object.entries(d.navigation);if(entries.length>8)throw new Error('navigation declares at most 8 graphs');for(const [name,graph] of entries){if(!/^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(name))throw new Error('navigation graph names are identifiers up to 48 characters');navigation[name]=prepareGraph(graph);graphNodes+=navigation[name].length;if(graphNodes>8192)throw new Error('navigation declares at most 8192 nodes in total');}}
|
|
642
661
|
Object.freeze(navigation);
|
|
643
662
|
return {
|
|
644
663
|
navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
645
|
-
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
664
|
+
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
646
665
|
tune, publicTune, map, settings, seats,
|
|
647
666
|
};
|
|
648
667
|
}
|
|
@@ -664,6 +683,8 @@ export interface Schema {
|
|
|
664
683
|
commands: Record<string, FieldList>;
|
|
665
684
|
shared: FieldList;
|
|
666
685
|
historySeconds?: number;
|
|
686
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
687
|
+
rewindSeconds?: number;
|
|
667
688
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
668
689
|
settings: RoomSettings;
|
|
669
690
|
}
|
package/rules/timing.mjs
ADDED
|
@@ -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
|
+
}
|