@homie-rocks/studio 0.46.5 → 0.47.0
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 +68 -0
- package/README.md +12 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +67 -0
- package/guides/game/SKILL.md +6 -0
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/rules-check-worker.mjs +2 -1
- package/lib/rules-guard.mjs +2 -2
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/rules/collision.ts +1 -0
- package/rules/core.ts +81 -33
- package/rules/host.ts +6 -2
- package/rules/interest.mjs +29 -14
- package/rules/live.ts +4 -3
- package/rules/map-index.ts +24 -0
- package/rules/map-state.ts +54 -0
- package/rules/math.ts +8 -5
- package/rules/nav/Flow.ts +53 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +22 -4
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +2 -2
- package/rules/view.ts +45 -19
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +26 -8
- package/worker/office.mjs +2 -2
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
package/worker/gate.mjs
CHANGED
|
@@ -36,9 +36,11 @@ function parseFrame(event) {
|
|
|
36
36
|
* as full as it can serve, and a room opens only the Gates its players fill.
|
|
37
37
|
* A studio chooses its room's size and nothing else. */
|
|
38
38
|
export const GATE_PLAYERS = 50;
|
|
39
|
-
|
|
39
|
+
/** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
|
|
40
|
+
export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
|
|
41
|
+
export function roomLayout(seats, per = GATE_PLAYERS) {
|
|
40
42
|
// A room of up to 64 is its Table alone, as it has been since 0.45.0.
|
|
41
|
-
const gates = seats <= 64 ? 1 : Math.ceil(seats /
|
|
43
|
+
const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
|
|
42
44
|
return { gates, concentrators: 0 };
|
|
43
45
|
}
|
|
44
46
|
/** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
|
|
@@ -52,17 +54,17 @@ function hashedGate(key, gates) {
|
|
|
52
54
|
* Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
|
|
53
55
|
* Gate's players now and `promised` those sent to it who have not arrived yet.
|
|
54
56
|
*/
|
|
55
|
-
export function nextGate(gates, held, promised = new Map()) {
|
|
57
|
+
export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
|
|
56
58
|
let least = 0, fewest = Infinity;
|
|
57
59
|
for (let i = 0; i < gates; i++) {
|
|
58
60
|
const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
|
|
59
|
-
if (n <
|
|
61
|
+
if (n < per) return i;
|
|
60
62
|
if (n < fewest) { fewest = n; least = i; }
|
|
61
63
|
}
|
|
62
64
|
return least;
|
|
63
65
|
}
|
|
64
66
|
export function gateFor(env, game, room, seats, key) {
|
|
65
|
-
const layout = roomLayout(seats);
|
|
67
|
+
const layout = roomLayout(seats, gatePlayers(env));
|
|
66
68
|
// One logical Gate is embedded in the Table for a small room. Same relay,
|
|
67
69
|
// admission and delivery protocol; no extra Durable Object or seat clamp.
|
|
68
70
|
const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
@@ -211,7 +213,9 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
211
213
|
const scheduleAdmission = () => {
|
|
212
214
|
if (!closed && admissions.length && attaching < 8 && out.buffered() < 512 * 1024 && admissionTimer === null) admissionTimer = setTimeout(pump, 1);
|
|
213
215
|
};
|
|
214
|
-
|
|
216
|
+
// What this Gate has sent the room: frames, the rows in them (a row is a player's message, mostly) and their size.
|
|
217
|
+
const heard = { frames: 0, rows: 0, bytes: 0 };
|
|
218
|
+
const transportFacts = () => ({ ...out.facts(), in: { ...heard }, admissions: admissions.length, attaching });
|
|
215
219
|
const shutdown = () => {
|
|
216
220
|
if (closed) return;
|
|
217
221
|
closed = true; out.close();
|
|
@@ -226,6 +230,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
226
230
|
if (closed) return;
|
|
227
231
|
const rows = parseFrame(event);
|
|
228
232
|
if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
|
|
233
|
+
heard.frames += 1; heard.rows += rows.length; heard.bytes += event.data.length;
|
|
229
234
|
for (const [op, id, value, ip, agent] of expandBatch(rows)) {
|
|
230
235
|
if (op === 'metrics') { onMetrics(id, value); continue; }
|
|
231
236
|
if (op === 'open') {
|
|
@@ -291,26 +296,38 @@ export function acceptMultiplex(request, connect, options) {
|
|
|
291
296
|
* A socket to a slow player has no back-pressure a Worker can read, so a Gate
|
|
292
297
|
* that writes every tick to a link that carries fewer leaves that player
|
|
293
298
|
* seconds behind for good: every frame arrives, each one later than the last.
|
|
294
|
-
* A client names the newest tick it holds (`g`, on its inputs and pings).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
299
|
+
* A client names the newest tick it holds (`g`, on its inputs and pings).
|
|
300
|
+
*
|
|
301
|
+
* Being behind is not the reason to send less: a connection that stalls for a
|
|
302
|
+
* second and then delivers what it held catches up by itself, and sending it
|
|
303
|
+
* less only makes it worse (0.46.2 paced all 600 players of a healthy room
|
|
304
|
+
* when their one home link hiccuped). The reason is being behind and NOT
|
|
305
|
+
* catching up: more than a second behind on two reports half a second apart,
|
|
306
|
+
* the second not a quarter of a second nearer than the first. Then this
|
|
307
|
+
* player's rate halves, down to one a second, and halves again while that
|
|
308
|
+
* stays so. Within half a second of the room, the rate doubles back, a step a
|
|
309
|
+
* second. Each frame is a delta
|
|
310
|
+
* from the last one SENT, so a skipped tick costs nothing later, and the
|
|
311
|
+
* player's own body is in every frame. A client that names nothing is sent
|
|
312
|
+
* every tick, as before.
|
|
300
313
|
*/
|
|
301
314
|
export function deliveryPace(hz = 20) {
|
|
302
|
-
let interval = 1, sent = -Infinity,
|
|
315
|
+
let interval = 1, sent = -Infinity, changedAt = -Infinity, lag = null, behind = null;
|
|
303
316
|
const most = Math.max(1, Math.round(hz));
|
|
304
317
|
return {
|
|
305
318
|
/** The client holds tick `tick`; the room is at `world`. `now` in ms. */
|
|
306
319
|
report(tick, world, now) {
|
|
307
320
|
if (!Number.isFinite(tick) || !Number.isFinite(world)) return;
|
|
308
321
|
// A new room epoch restarts its ticks: a tick from the old one says nothing.
|
|
309
|
-
|
|
310
|
-
lag
|
|
311
|
-
if (lag
|
|
312
|
-
if (lag
|
|
313
|
-
|
|
322
|
+
lag = tick > world ? null : world - tick;
|
|
323
|
+
if (lag === null) { behind = null; return; }
|
|
324
|
+
if (lag <= hz / 2) { behind = null; if (interval > 1 && now - changedAt >= 1000) { interval = Math.max(1, interval / 2); changedAt = now; } return; }
|
|
325
|
+
if (lag <= hz) { behind = null; return; }
|
|
326
|
+
if (behind === null) { behind = { lag, at: now }; return; }
|
|
327
|
+
if (now - behind.at < 500) return;
|
|
328
|
+
// Catching up means a quarter of a second nearer in half a second; anything less is a link that cannot keep this rate.
|
|
329
|
+
if (lag > behind.lag - hz / 4 && now - changedAt >= 500 && interval < most) { interval = Math.min(most, interval * 2); changedAt = now; }
|
|
330
|
+
behind = { lag, at: now };
|
|
314
331
|
},
|
|
315
332
|
due(tick) { return tick < sent || tick - sent >= interval; },
|
|
316
333
|
sent(tick) { sent = tick; },
|
|
@@ -324,8 +341,19 @@ const COASTS = /^\{"t":"hello".{0,2000}?"coast":1[,}]/;
|
|
|
324
341
|
const percentile = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : null;
|
|
325
342
|
|
|
326
343
|
export class Gate {
|
|
327
|
-
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; }
|
|
328
|
-
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0 }; }
|
|
344
|
+
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; this.behind = new Set(); this.idle = null; }
|
|
345
|
+
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0, gaps: [0, 0, 0], late: 0, busy: 0 }; }
|
|
346
|
+
/** This Gate's own lateness: a timer every 20 ms, and how long past it each one came. A Gate that is out of time is late to its own timer. */
|
|
347
|
+
watchLoop() {
|
|
348
|
+
if (this.loopTimer) return;
|
|
349
|
+
let due = Date.now() + 20;
|
|
350
|
+
this.loopTimer = setInterval(() => {
|
|
351
|
+
const now = Date.now(), late = Math.max(0, now - due); due = now + 20;
|
|
352
|
+
this.window.late = Math.max(this.window.late, late); if (late > 20) this.window.busy += 1;
|
|
353
|
+
if (!this.link || !this.clients.size) { clearInterval(this.loopTimer); this.loopTimer = null; }
|
|
354
|
+
}, 20);
|
|
355
|
+
this.loopTimer?.unref?.();
|
|
356
|
+
}
|
|
329
357
|
/** `[sent, received, answered]`: this Gate's clock when its frame left, the room's when it read it and when it answered. */
|
|
330
358
|
roomClock([sent, received, answered]) {
|
|
331
359
|
const now = Date.now(), offset = ((received - sent) + (answered - now)) / 2, delay = (now - sent) - (answered - received);
|
|
@@ -380,6 +408,13 @@ export class Gate {
|
|
|
380
408
|
} catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch {} }
|
|
381
409
|
});
|
|
382
410
|
socket.addEventListener('close', lost); socket.addEventListener('error', lost);
|
|
411
|
+
// A Gate nobody is on gives its channel back after ten seconds: an open channel keeps the room and this Gate in
|
|
412
|
+
// memory (and billed) with nothing to carry. The next player to arrive opens another, as the first did.
|
|
413
|
+
this.rest = () => {
|
|
414
|
+
if (this.link !== link || this.clients.size) return;
|
|
415
|
+
this.link = null; this.worlds = null; link.close();
|
|
416
|
+
try { socket.close(1000, 'no players'); } catch { /* gone */ }
|
|
417
|
+
};
|
|
383
418
|
this.link = link; return link;
|
|
384
419
|
})();
|
|
385
420
|
try { return await this.opening; } finally { this.opening = null; }
|
|
@@ -388,7 +423,7 @@ export class Gate {
|
|
|
388
423
|
deliver(snap, settings, hz, drop) {
|
|
389
424
|
const w = this.window, now = Date.now(); w.worlds++;
|
|
390
425
|
if (w.tickFrom === null) { w.tickFrom = this.worldTick ?? snap.k; w.tickFromAt = this.worldAt ?? now; }
|
|
391
|
-
if (this.worldAt) w.gapMax = Math.max(w.gapMax,
|
|
426
|
+
if (this.worldAt) { const gap = now - this.worldAt; w.gapMax = Math.max(w.gapMax, gap); if (gap > 75) w.gaps[0] += 1; if (gap > 150) w.gaps[1] += 1; if (gap > 300) w.gaps[2] += 1; }
|
|
392
427
|
this.worldTick = snap.k; this.worldAt = now; w.tickAt = now;
|
|
393
428
|
// By this Gate's clock, corrected for what the room's clock is measured to differ by.
|
|
394
429
|
if (Number.isFinite(snap.st)) w.worldAge.push(Math.max(0, now - snap.st + (this.clock?.offsetMs ?? 0)));
|
|
@@ -431,7 +466,7 @@ export class Gate {
|
|
|
431
466
|
async connect(request, server) {
|
|
432
467
|
const link = await this.upstream(new URL(request.url));
|
|
433
468
|
const id = String(++this.sequence);
|
|
434
|
-
this.clients.set(id, server);
|
|
469
|
+
this.clients.set(id, server); clearTimeout(this.idle); this.idle = null; this.watchLoop();
|
|
435
470
|
let opened = false;
|
|
436
471
|
const deadline = setTimeout(() => {
|
|
437
472
|
this.admissions.delete(id);
|
|
@@ -458,12 +493,18 @@ export class Gate {
|
|
|
458
493
|
}
|
|
459
494
|
const now = Date.now();
|
|
460
495
|
const said = REPORTED_TICK.exec(event.data);
|
|
461
|
-
if (said && server.pace && this.worldTick !== null)
|
|
496
|
+
if (said && server.pace && this.worldTick !== null) {
|
|
497
|
+
server.pace.report(Number(said[1]), this.worldTick, now);
|
|
498
|
+
// Counted, never acted on: players more than a second behind at this moment. Many at once on one Gate is a
|
|
499
|
+
// connection they share or something upstream of them all; each is still paced by their own reports alone
|
|
500
|
+
// (a full shared link is cured by sending each of them less, and nothing else cures it).
|
|
501
|
+
if ((server.pace.facts().lag ?? 0) > 20) this.behind.add(id); else this.behind.delete(id);
|
|
502
|
+
}
|
|
462
503
|
this.window.inputs++;
|
|
463
504
|
// Only the public Gate stamps ingress; a player cannot supply it inside their JSON.
|
|
464
505
|
link.send(['data', id, event.data, Number.isFinite(event.receivedAt) ? event.receivedAt : now]);
|
|
465
506
|
});
|
|
466
|
-
const left = () => { clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); };
|
|
507
|
+
const left = () => { this.behind.delete(id); clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); if (!this.clients.size && !this.idle) { this.idle = setTimeout(() => { this.idle = null; this.rest?.(); }, 10_000); this.idle?.unref?.(); } };
|
|
467
508
|
server.addEventListener('close', left); server.addEventListener('error', left);
|
|
468
509
|
}
|
|
469
510
|
/** What this Gate did in the last second, for whoever is measuring the room. */
|
|
@@ -480,15 +521,21 @@ export class Gate {
|
|
|
480
521
|
bytesPerSecond: Math.round(w.bytes / seconds), inputsPerSecond: Math.round(w.inputs / seconds), skipped: w.skipped,
|
|
481
522
|
// Room stamp to this Gate writing it to players, ms (both are Cloudflare's clock at the start of an event).
|
|
482
523
|
worldAgeMs: { p50: percentile(ages, 0.5), p95: percentile(ages, 0.95), max: ages.at(-1) ?? null },
|
|
524
|
+
// Of this second's worlds: how many were over 100, 200 and 400 ms old when this Gate wrote them, and how many came
|
|
525
|
+
// more than 75, 150 and 300 ms after the one before (a world is due every 50).
|
|
526
|
+
worldsOld: { n: ages.length, over100: ages.filter(a => a > 100).length, over200: ages.filter(a => a > 200).length, over400: ages.filter(a => a > 400).length },
|
|
527
|
+
worldGaps: { over75: w.gaps[0], over150: w.gaps[1], over300: w.gaps[2] },
|
|
483
528
|
// How far behind the room players say they are, in ticks, and how many are being sent fewer frames for it.
|
|
484
529
|
clientLagTicks: { reporting: lags.length, p50: percentile(lags, 0.5), p95: percentile(lags, 0.95), max: lags.at(-1) ?? null },
|
|
485
|
-
paced: paces.filter(p => p.interval > 1).length,
|
|
530
|
+
paced: paces.filter(p => p.interval > 1).length, behind: this.behind.size,
|
|
486
531
|
coasting: subscribed.filter(c => c.coasts).length,
|
|
487
532
|
// Ticks the room's worlds advanced a second, by THIS Gate's clock: the room's own clock cannot see itself run late.
|
|
488
533
|
roomTickHz: w.tickFrom !== null && w.tickAt > w.tickFromAt ? Math.round((this.worldTick - w.tickFrom) / (w.tickAt - w.tickFromAt) * 100000) / 100 : null,
|
|
489
534
|
worldGapMs: w.gapMax,
|
|
490
535
|
// The room's clock less this Gate's, and the channel's delay, from stamps exchanged on the channel (as NTP does).
|
|
491
536
|
roomClock: this.clock ?? null,
|
|
537
|
+
// The worst this Gate's own 20 ms timer came late in the second, and how many of the fifty came over 20 ms late.
|
|
538
|
+
loopLateMs: w.late, loopLateTimers: w.busy,
|
|
492
539
|
};
|
|
493
540
|
}
|
|
494
541
|
publishMetrics(url, link) {
|
package/worker/index.mjs
CHANGED
|
@@ -90,7 +90,7 @@ import { appRole, openPath } from './app-format.mjs';
|
|
|
90
90
|
import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
|
|
91
91
|
import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
|
|
92
92
|
import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
|
|
93
|
-
import { roomStore, restoreDelay } from './room-store.mjs';
|
|
93
|
+
import { roomStore, restoreDelay, LOOSE_SAVE } from './room-store.mjs';
|
|
94
94
|
import { appCors, isAppOrigin } from './standalone.mjs';
|
|
95
95
|
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
|
|
96
96
|
import {
|
|
@@ -105,7 +105,7 @@ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs'
|
|
|
105
105
|
import { isLoungePath, loungeRoutes } from './lounge.mjs';
|
|
106
106
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
107
107
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
108
|
-
import { acceptMultiplex, diagnosticsOf, gateFor, nextGate } from './gate.mjs';
|
|
108
|
+
import { acceptMultiplex, diagnosticsOf, gateFor, gatePlayers, nextGate } from './gate.mjs';
|
|
109
109
|
import { worldSource } from './world-wire.mjs';
|
|
110
110
|
export { Gate, Concentrator } from './gate.mjs';
|
|
111
111
|
import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
|
|
@@ -1361,6 +1361,9 @@ export class Table {
|
|
|
1361
1361
|
if (this.saved?.retired === true) this.cleanupPending = true;
|
|
1362
1362
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
1363
1363
|
this.officeSaved = (await ctx.storage.get('office')) ?? null;
|
|
1364
|
+
// A big match room's last unconfirmed checkpoint (worker/room-store.mjs), read once as the object wakes.
|
|
1365
|
+
try { this.looseSave = (await ctx.storage.list?.({ prefix: LOOSE_SAVE })) ?? null; }
|
|
1366
|
+
catch (error) { this.looseSave = null; this.storageProblem(error); }
|
|
1364
1367
|
});
|
|
1365
1368
|
}
|
|
1366
1369
|
|
|
@@ -1434,7 +1437,7 @@ export class Table {
|
|
|
1434
1437
|
try {
|
|
1435
1438
|
const build = hostedBuild(this.game);
|
|
1436
1439
|
const persistent = this.isPersistent();
|
|
1437
|
-
try { this.durable ??=
|
|
1440
|
+
try { this.durable ??= this.openStore(); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
|
|
1438
1441
|
let saved = this.cleanupPending ? null : this.readRoom();
|
|
1439
1442
|
restoring = Boolean(saved);
|
|
1440
1443
|
if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
|
|
@@ -1480,7 +1483,9 @@ export class Table {
|
|
|
1480
1483
|
this.hostRt = startHost(this.game, {
|
|
1481
1484
|
...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
|
|
1482
1485
|
record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
|
|
1483
|
-
store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment) },
|
|
1486
|
+
store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment, true) },
|
|
1487
|
+
// A room behind Gates needs no snapshot as text: nothing is encoded that nobody is sent (room.mjs, hostSnap).
|
|
1488
|
+
snapshotText: () => !this.relayed,
|
|
1484
1489
|
...(build.settings.characters ? { characterFor: (peer) => {
|
|
1485
1490
|
const account = room.clients.get(peer.id)?.player;
|
|
1486
1491
|
return account ? this.characterGrants.get(account) ?? null : null;
|
|
@@ -1520,12 +1525,19 @@ export class Table {
|
|
|
1520
1525
|
}
|
|
1521
1526
|
}
|
|
1522
1527
|
|
|
1523
|
-
|
|
1528
|
+
/** This object's room store, with the unconfirmed checkpoint it woke to (if any). */
|
|
1529
|
+
openStore(persistent = this.isPersistent()) {
|
|
1530
|
+
const loose = this.looseSave; this.looseSave = null;
|
|
1531
|
+
return roomStore(this.ctx.storage, { persistent, loose, onProblem: (error) => this.storageProblem(error) });
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** `periodic`: the rules host's own checkpoint of a tick, as against a save an admission or a pause asked for. */
|
|
1535
|
+
saveRoom(bytes, commitment, periodic = false) {
|
|
1524
1536
|
if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
|
|
1525
1537
|
const now = Date.now();
|
|
1526
1538
|
if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
|
|
1527
1539
|
try {
|
|
1528
|
-
this.durable ??=
|
|
1540
|
+
this.durable ??= this.openStore();
|
|
1529
1541
|
const build = hostedBuild(this.game);
|
|
1530
1542
|
const host = new TextDecoder().decode(bytes);
|
|
1531
1543
|
const net = this.room.saved();
|
|
@@ -1544,6 +1556,10 @@ export class Table {
|
|
|
1544
1556
|
commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
|
|
1545
1557
|
fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
|
|
1546
1558
|
this.durable.commit(saved, commitment);
|
|
1559
|
+
} else if (this.relayed && periodic && !clearing) {
|
|
1560
|
+
// A room behind Gates: its once-a-second checkpoint holds none of its frames (worker/room-store.mjs). A save
|
|
1561
|
+
// an admission asks for is still confirmed before the welcome that names the seat can leave.
|
|
1562
|
+
this.durable.write(saved, { unconfirmed: true });
|
|
1547
1563
|
} else this.durable.write(saved);
|
|
1548
1564
|
if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
|
|
1549
1565
|
this.roomSave = saved;
|
|
@@ -1742,7 +1758,7 @@ export class Table {
|
|
|
1742
1758
|
const room = this.room;
|
|
1743
1759
|
if (!room || !this.hostRt) {
|
|
1744
1760
|
if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
|
|
1745
|
-
this.durable ??=
|
|
1761
|
+
this.durable ??= this.openStore(false);
|
|
1746
1762
|
const saved = this.cleanupPending ? null : this.readRoom(true);
|
|
1747
1763
|
if (saved) {
|
|
1748
1764
|
this.game = saved.game; this.code = saved.room;
|
|
@@ -2118,7 +2134,7 @@ export class Table {
|
|
|
2118
2134
|
// players sent to a Gate in the last ten seconds who have not reached it yet.
|
|
2119
2135
|
const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
|
|
2120
2136
|
for (const [index, due] of this.gatePromised) { const live = due.filter((at) => at > now); if (live.length) { this.gatePromised.set(index, live); promised.set(index, live.length); } else this.gatePromised.delete(index); }
|
|
2121
|
-
const gate = nextGate(gates, this.gateHeld, promised);
|
|
2137
|
+
const gate = nextGate(gates, this.gateHeld, promised, gatePlayers(this.env));
|
|
2122
2138
|
if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
|
|
2123
2139
|
this.gatePromised.get(gate).push(now + 10_000);
|
|
2124
2140
|
return json({ gate });
|
|
@@ -2398,6 +2414,8 @@ export class Table {
|
|
|
2398
2414
|
// A Gate's stamp on a player's message to this object handling it, ms.
|
|
2399
2415
|
inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
|
|
2400
2416
|
durability: this.storageHealth ?? { ok: true },
|
|
2417
|
+
// Checkpoints written without holding the room's frames for them (worker/room-store.mjs), so far.
|
|
2418
|
+
save: { unconfirmed: this.durable?.unconfirmedWrites ?? 0 },
|
|
2401
2419
|
});
|
|
2402
2420
|
const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
|
|
2403
2421
|
.then((r) => r.body?.cancel?.())
|
package/worker/office.mjs
CHANGED
|
@@ -62,7 +62,7 @@ import { toolIdentity } from './tool-identity.mjs';
|
|
|
62
62
|
* from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
|
|
63
63
|
*/
|
|
64
64
|
import { seatsOf } from './seats.mjs';
|
|
65
|
-
import { diagnosticsOf, roomLayout } from './gate.mjs';
|
|
65
|
+
import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
|
|
66
66
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
67
67
|
import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
|
|
68
68
|
import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
|
|
@@ -432,7 +432,7 @@ async function roomFacts(env, game, room, max) {
|
|
|
432
432
|
* small room answers for itself, as it always has.
|
|
433
433
|
*/
|
|
434
434
|
export async function roomDiagnostics(env, game, room, max) {
|
|
435
|
-
if (!env.GATE || roomLayout(max).gates === 1) return roomFacts(env, game, room, max);
|
|
435
|
+
if (!env.GATE || roomLayout(max, gatePlayers(env)).gates === 1) return roomFacts(env, game, room, max);
|
|
436
436
|
let kept = null;
|
|
437
437
|
try {
|
|
438
438
|
const res = await diagnosticsOf(env, game, room).fetch(`https://diagnostics/__diagnostics?room=${enc(room)}`);
|
package/worker/room-store.mjs
CHANGED
|
@@ -12,8 +12,42 @@ import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointW
|
|
|
12
12
|
* Only integer primary keys: no secondary-index write amplification.
|
|
13
13
|
* The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
|
|
14
14
|
*/
|
|
15
|
-
export function roomStore(storage, { persistent = false } = {}) {
|
|
15
|
+
export function roomStore(storage, { persistent = false, loose = null, onProblem = () => {} } = {}) {
|
|
16
16
|
const sql = storage.sql;
|
|
17
|
+
/*
|
|
18
|
+
* THE CHECKPOINT THAT DOES NOT HOLD THE ROOM'S FRAMES. Cloudflare holds everything an object sends after a write
|
|
19
|
+
* until that write is confirmed. A match room behind Gates writes its whole self once a second, and every world
|
|
20
|
+
* and every receipt of the ticks that followed waited on it: one or two worlds a second reached every Gate a
|
|
21
|
+
* tick or more late, at every size. Such a checkpoint is a second old by design, and the worlds between two of
|
|
22
|
+
* them were never durable, so nothing a player sees is being promised. `write(value, { unconfirmed: true })`
|
|
23
|
+
* puts it in the object's key-value storage with `allowUnconfirmed`, which is Cloudflare's own way to say so.
|
|
24
|
+
*
|
|
25
|
+
* Every confirmed write removes it, so one found on disk was written after the last confirmed save, unless the
|
|
26
|
+
* object stopped between a confirmed write and that removal: each save says when it was made (`at`), and a read
|
|
27
|
+
* takes the later of the two. `loose` is what the object read of it when it woke (a Map from
|
|
28
|
+
* `storage.list({ prefix: LOOSE_SAVE })`); reads after that are answered from memory.
|
|
29
|
+
*/
|
|
30
|
+
let looseText = null, looseOnDisk = false, looseWrites = 0;
|
|
31
|
+
if (loose && typeof loose.get === 'function' && loose.get(LOOSE_SAVE)) {
|
|
32
|
+
looseOnDisk = true;
|
|
33
|
+
try {
|
|
34
|
+
const meta = loose.get(LOOSE_SAVE), pieces = [];
|
|
35
|
+
for (let i = 0; i < meta.pieces; i += 1) { const piece = loose.get(`${LOOSE_SAVE}/${i}`); if (!(piece instanceof Uint8Array)) throw new Error('unconfirmed save pieces missing'); pieces.push(piece); }
|
|
36
|
+
const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
|
|
37
|
+
let at = 0;
|
|
38
|
+
for (const piece of pieces) { bytes.set(piece, at); at += piece.length; }
|
|
39
|
+
if (!Number.isInteger(meta.pieces) || meta.pieces < 1 || bytes.length !== meta.bytes || bytes.length > 16_000_000) throw new Error('unconfirmed save does not fit its record');
|
|
40
|
+
looseText = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
41
|
+
JSON.parse(looseText);
|
|
42
|
+
} catch (error) { looseText = null; onProblem(error); }
|
|
43
|
+
}
|
|
44
|
+
/** Removed with whatever confirmed write is being made: no await between them, so they are one transaction. */
|
|
45
|
+
function dropLoose() {
|
|
46
|
+
looseText = null;
|
|
47
|
+
if (!looseOnDisk || typeof storage.delete !== 'function') return;
|
|
48
|
+
looseOnDisk = false;
|
|
49
|
+
Promise.resolve(storage.delete([LOOSE_SAVE, ...Array.from({ length: LOOSE_PIECES }, (_, i) => `${LOOSE_SAVE}/${i}`)])).catch(onProblem);
|
|
50
|
+
}
|
|
17
51
|
sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
|
|
18
52
|
sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
|
|
19
53
|
let journalReady = false;
|
|
@@ -47,13 +81,22 @@ export function roomStore(storage, { persistent = false } = {}) {
|
|
|
47
81
|
const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
|
|
48
82
|
if (total > maxBytes) throw new Error('save exceeds 16 MB');
|
|
49
83
|
const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
|
|
50
|
-
if (!rows.length) return null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
if (!rows.length && looseText === null) return null;
|
|
85
|
+
let value = null;
|
|
86
|
+
if (rows.length) {
|
|
87
|
+
if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
|
|
88
|
+
const pieces = rows.map((r) => new Uint8Array(r.data));
|
|
89
|
+
const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
|
|
90
|
+
let at = 0;
|
|
91
|
+
for (const p of pieces) { bytes.set(p, at); at += p.length; }
|
|
92
|
+
value = JSON.parse(decoder.decode(bytes));
|
|
93
|
+
}
|
|
94
|
+
// An unconfirmed checkpoint is the room when it is the later of the two (above).
|
|
95
|
+
if (looseText !== null && !boot?.persistent) {
|
|
96
|
+
const unconfirmed = JSON.parse(looseText);
|
|
97
|
+
if (!value || !(Number(value.at) >= Number(unconfirmed.at))) value = unconfirmed;
|
|
98
|
+
}
|
|
99
|
+
if (!value) return null;
|
|
57
100
|
if (lasting) journal();
|
|
58
101
|
sequence = floor;
|
|
59
102
|
const journals = lasting ? sql.exec('SELECT id, seq, data FROM world_journal WHERE id > 0 AND seq > ? ORDER BY seq', floor).toArray() : [];
|
|
@@ -127,10 +170,20 @@ export function roomStore(storage, { persistent = false } = {}) {
|
|
|
127
170
|
// Brought up to this tick by what changed in it (each value copied), never by copying the room.
|
|
128
171
|
durable = applyChanges(durable, moved);
|
|
129
172
|
},
|
|
130
|
-
|
|
173
|
+
/** `unconfirmed`: a match room's periodic checkpoint, which no frame waits on (above). Never a lasting world's. */
|
|
174
|
+
write(value, { unconfirmed = false } = {}) {
|
|
131
175
|
if (lasting) journal();
|
|
132
|
-
const
|
|
176
|
+
const text = JSON.stringify(value);
|
|
177
|
+
const bytes = encoder.encode(text);
|
|
133
178
|
if (bytes.length > 16_000_000 && !lasting) throw new Error('save exceeds 16 MB');
|
|
179
|
+
if (unconfirmed && !lasting && typeof storage.put === 'function' && bytes.length <= LOOSE_PIECES * pieceBytes) {
|
|
180
|
+
const entries = { [LOOSE_SAVE]: { pieces: Math.max(1, Math.ceil(bytes.length / pieceBytes)), bytes: bytes.length, at: value.at ?? null } };
|
|
181
|
+
for (let i = 0; i < entries[LOOSE_SAVE].pieces; i += 1) entries[`${LOOSE_SAVE}/${i}`] = bytes.slice(i * pieceBytes, (i + 1) * pieceBytes);
|
|
182
|
+
// One put is one transaction: a reader never finds the record of one checkpoint with the pieces of another.
|
|
183
|
+
Promise.resolve(storage.put(entries, { allowUnconfirmed: true })).catch(onProblem);
|
|
184
|
+
looseText = text; looseOnDisk = true; looseWrites += 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
134
187
|
storage.transactionSync(() => {
|
|
135
188
|
let id = 0;
|
|
136
189
|
for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
|
|
@@ -138,16 +191,23 @@ export function roomStore(storage, { persistent = false } = {}) {
|
|
|
138
191
|
// The checkpoint holds every record so far: raising the floor retires them without touching their rows.
|
|
139
192
|
if (lasting && sequence !== floor) sql.exec("INSERT INTO world_journal (id, seq, data) VALUES (0, ?, x'') ON CONFLICT(id) DO UPDATE SET seq = excluded.seq", sequence);
|
|
140
193
|
});
|
|
194
|
+
dropLoose();
|
|
141
195
|
floor = sequence;
|
|
142
196
|
if (lasting) settle(value);
|
|
143
197
|
},
|
|
198
|
+
/** Checkpoints written without holding the room's frames, so far. */
|
|
199
|
+
get unconfirmedWrites() { return looseWrites; },
|
|
144
200
|
boots() { const value = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); lasting ||= value?.persistent === true; return value; },
|
|
145
201
|
boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify({ ...value, ...(lasting ? { persistent: true } : {}) })); },
|
|
146
202
|
retire() { this.boot({ retired: true }); },
|
|
147
|
-
clear() { if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
|
|
203
|
+
clear() { dropLoose(); if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
|
|
148
204
|
};
|
|
149
205
|
}
|
|
150
206
|
|
|
207
|
+
/** Where a match room's unconfirmed checkpoint is kept in the object's key-value storage: a record, and its pieces under it. */
|
|
208
|
+
export const LOOSE_SAVE = 'room-save';
|
|
209
|
+
const LOOSE_PIECES = 16;
|
|
210
|
+
|
|
151
211
|
/** A repeatable delay spreads rooms that shared a failing isolate over thirty seconds. */
|
|
152
212
|
export function restoreDelay(id) {
|
|
153
213
|
let h = 2166136261;
|
package/worker/room.mjs
CHANGED
|
@@ -148,7 +148,10 @@ export function medianVote(list) {
|
|
|
148
148
|
return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
|
|
149
149
|
}
|
|
150
150
|
const utf8 = new TextEncoder();
|
|
151
|
-
|
|
151
|
+
// A message's size in bytes. Text that is all ASCII (nearly every frame a game sends) is as long as it is wide: it is
|
|
152
|
+
// not encoded to be measured, which at ten inputs a second from a thousand players was a twentieth of a room's second.
|
|
153
|
+
const ASCII = /^[\x00-\x7f]*$/;
|
|
154
|
+
const byteLength = text => ASCII.test(text) ? text.length : utf8.encode(text).length;
|
|
152
155
|
const PALETTE_SIZE = 12;
|
|
153
156
|
const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
154
157
|
|
|
@@ -473,6 +476,12 @@ export class NetRoom {
|
|
|
473
476
|
const w = (c.rttWindow ??= []);
|
|
474
477
|
w.push([now, ms]);
|
|
475
478
|
while (w.length > 256 || (w.length > 1 && w[0][0] < now - 10000)) w.shift();
|
|
479
|
+
// A low percentile of ten seconds does not move in one. For a player behind a Gate (a big room) a full window
|
|
480
|
+
// is read once a second, not once an input: at ten inputs a second from a thousand players, sorting it each time
|
|
481
|
+
// was a tenth of the room's second when its clock moved between frames (0.46.3 against the build before it,
|
|
482
|
+
// test/crowd-room-cost.mjs). A room its players reach directly reads it as before.
|
|
483
|
+
if (c.conn.snapshotGroup && w.length >= 32 && now - (c.rttReadAt ?? 0) < 1000) return;
|
|
484
|
+
c.rttReadAt = now;
|
|
476
485
|
const sorted = w.map((x) => x[1]).sort((a, b) => a - b);
|
|
477
486
|
const low = sorted[Math.floor((sorted.length - 1) * 0.1)];
|
|
478
487
|
if (c.measuredRtt !== undefined && Math.abs(low - c.measuredRtt) < 2 && now - (c.rttToldAt ?? 0) < 1000) return;
|
|
@@ -495,7 +504,10 @@ export class NetRoom {
|
|
|
495
504
|
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
496
505
|
// A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
|
|
497
506
|
// (the host is alive), and replicas are told which it is.
|
|
498
|
-
|
|
507
|
+
// The whole snapshot as text: what a player with no view of their own is sent. The server's host hands it over
|
|
508
|
+
// unless this room sends it to nobody (a room behind Gates); then it is made only if somebody turns out to need it.
|
|
509
|
+
let out = text ?? (c ? JSON.stringify({ t: 'snap', from: c.seat, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) }) : null);
|
|
510
|
+
const whole = () => (out ??= JSON.stringify({ t: 'snap', from: null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) }));
|
|
499
511
|
if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
|
|
500
512
|
this.lastSnapText = out;
|
|
501
513
|
// A server-hosted room behind Gates hands each Gate ONE world a tick; the Gate selects, encodes and paces
|
|
@@ -513,9 +525,10 @@ export class NetRoom {
|
|
|
513
525
|
}
|
|
514
526
|
// A congested socket skips a snapshot rather than queueing a stale one.
|
|
515
527
|
if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
|
|
516
|
-
this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) :
|
|
528
|
+
this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : whole());
|
|
517
529
|
}
|
|
518
530
|
if (groups) for (const group of groups) group.snapshot(snap, this.server.viewSettings ?? {}, this.tickHz);
|
|
531
|
+
if (out !== null) this.lastSnapText = out;
|
|
519
532
|
}
|
|
520
533
|
|
|
521
534
|
hasViewSchedule() {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.47.0';
|