@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +3 -2
  3. package/docs/rooms-milestone-2-notes.md +345 -0
  4. package/guides/game/RULES.md +115 -5
  5. package/guides/game/SKILL.md +5 -0
  6. package/lib/build-identity.mjs +43 -0
  7. package/lib/cloudflare.mjs +13 -11
  8. package/lib/crowd-cloud.mjs +88 -0
  9. package/lib/crowd-diagnostics.mjs +73 -0
  10. package/lib/crowd-load.mjs +603 -0
  11. package/lib/crowd-probe/README.md +61 -0
  12. package/lib/crowd-probe/client.mjs +96 -0
  13. package/lib/crowd-probe/worker.mjs +72 -0
  14. package/lib/crowd-probe/wrangler.jsonc +12 -0
  15. package/lib/crowd-summary.mjs +140 -0
  16. package/lib/crowd-worker.mjs +496 -0
  17. package/lib/rules-check-worker.mjs +1 -1
  18. package/lib/rules-check.mjs +2 -2
  19. package/lib/rules-types.mjs +1 -1
  20. package/lib/scaffold.mjs +5 -5
  21. package/lib/template-history.json +84 -0
  22. package/netplay/netplay.ts +19 -6
  23. package/package.json +4 -2
  24. package/rules/core.ts +29 -4
  25. package/rules/host.ts +119 -21
  26. package/rules/interest.mjs +320 -51
  27. package/rules/pack.ts +1 -0
  28. package/rules/rules.ts +29 -8
  29. package/rules/timing.mjs +20 -0
  30. package/rules/types.ts +5 -0
  31. package/rules/view.ts +229 -25
  32. package/saves/SAVES.md +83 -3
  33. package/saves/saves.ts +2 -0
  34. package/worker/build-id.mjs +2 -0
  35. package/worker/gate.mjs +436 -53
  36. package/worker/index.mjs +92 -10
  37. package/worker/office-page.mjs +11 -0
  38. package/worker/office.mjs +27 -0
  39. package/worker/players.mjs +13 -15
  40. package/worker/room.mjs +113 -19
  41. package/worker/saves-shell.mjs +22 -2
  42. package/worker/saves.mjs +282 -19
  43. package/worker/version.mjs +1 -1
  44. package/worker/world-wire.mjs +110 -0
package/worker/index.mjs CHANGED
@@ -105,7 +105,8 @@ 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, gateFor } from './gate.mjs';
108
+ import { acceptMultiplex, diagnosticsOf, gateFor, nextGate } from './gate.mjs';
109
+ import { worldSource } from './world-wire.mjs';
109
110
  export { Gate, Concentrator } from './gate.mjs';
110
111
  import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
111
112
  import {
@@ -123,6 +124,7 @@ import {
123
124
  // The back office knows the studio's player accounts: a signed-in player is named in the office and held by a kick on
124
125
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
125
126
  usePlayers(playerAccounts);
127
+ import { STUDIO_BUILD } from './build-id.mjs';
126
128
  import { STUDIO_VERSION_TAG } from './version.mjs';
127
129
  import { reconcileOrders, readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
128
130
  import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
@@ -141,6 +143,8 @@ export { usePlayers } from './office.mjs';
141
143
  export { countVisit } from './stats.mjs';
142
144
  /** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
143
145
  export { players, cleanName } from './players.mjs';
146
+ /** How a guest's saves combine with the account's when they sign in (saves/SAVES.md): the studio's Worker calls it once, at load. */
147
+ export { mergeSaves } from './saves.mjs';
144
148
 
145
149
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
146
150
  const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@ -712,6 +716,7 @@ async function route(request, env, ctx) {
712
716
  return json({
713
717
  v: 1,
714
718
  kind: 'homie-studio',
719
+ toolkit: STUDIO_BUILD,
715
720
  name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
716
721
  slug: cat.studio?.slug ?? null,
717
722
  ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
@@ -1327,6 +1332,10 @@ export class Table {
1327
1332
  this.hostRt = null;
1328
1333
  this.characterGrants = new Map();
1329
1334
  this.accountTokens = {};
1335
+ this.inputTransit = [];
1336
+ this.relayed = false;
1337
+ this.gateHeld = new Map();
1338
+ this.gatePromised = new Map();
1330
1339
  this.roomAlarmAt = 0;
1331
1340
  this.durable = null;
1332
1341
  this.roomSave = null;
@@ -1354,7 +1363,21 @@ export class Table {
1354
1363
  maxPlayers: max,
1355
1364
  perIp: perAddress(max),
1356
1365
  store: {
1357
- save: (o) => { if (this.hostRt) this.saveRoom(this.hostRt.save()); else if (!hostedGame(game)) storage.put('net', o).catch(() => {}); },
1366
+ save: (o) => {
1367
+ if (this.hostRt && !this.relayFrame) this.saveRoom(this.hostRt.save());
1368
+ else if (this.hostRt) {
1369
+ // All admissions in one relay turn share a checkpoint. The microtask
1370
+ // runs before the relay's timer flush, retaining the storage output gate.
1371
+ if (!this.admissionSaveQueued) {
1372
+ this.admissionSaveQueued = true;
1373
+ const host = this.hostRt;
1374
+ queueMicrotask(() => {
1375
+ this.admissionSaveQueued = false;
1376
+ if (this.hostRt === host) { try { this.saveRoom(host.save()); } catch (error) { this.storageProblem(error); } }
1377
+ });
1378
+ }
1379
+ } else if (!hostedGame(game)) storage.put('net', o).catch(() => {});
1380
+ },
1358
1381
  clear: () => { this.clearRoom(); },
1359
1382
  },
1360
1383
  // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
@@ -1362,6 +1385,7 @@ export class Table {
1362
1385
  // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
1363
1386
  log: (line) => this.say(line),
1364
1387
  });
1388
+ this.room.gateFacts = () => [...(this.gateMetrics?.values() ?? [])];
1365
1389
  if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
1366
1390
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1367
1391
  this.vocabRead = this.readVocab(game).catch(() => false);
@@ -2072,8 +2096,26 @@ export class Table {
2072
2096
  if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
2073
2097
  return json({ ok: false }, 400);
2074
2098
  }
2075
- if (new URL(request.url).pathname === '/__multiplex') {
2076
- return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
2099
+ const path = new URL(request.url).pathname;
2100
+ if (path === '/__gate') {
2101
+ // The door asks which Gate takes the next player (worker/gate.mjs, nextGate): the lowest with room, counting
2102
+ // players sent to a Gate in the last ten seconds who have not reached it yet.
2103
+ const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
2104
+ 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); }
2105
+ const gate = nextGate(gates, this.gateHeld, promised);
2106
+ if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
2107
+ this.gatePromised.get(gate).push(now + 10_000);
2108
+ return json({ gate });
2109
+ }
2110
+ if (path === '/__multiplex') {
2111
+ this.relayed = true;
2112
+ const index = Number(new URL(request.url).searchParams.get('gate'));
2113
+ // One encoding of each world for every Gate link (worker/world-wire.mjs).
2114
+ return acceptMultiplex(request, (req, socket) => this.connect(req, socket), { source: (this.worldSource ??= worldSource()), onCount: (n) => {
2115
+ // A player arrived: a promise to that Gate is kept. The Gate's count is what the next join is placed by.
2116
+ if (n > (this.gateHeld.get(index) ?? 0)) this.gatePromised.get(index)?.shift();
2117
+ if (n > 0) this.gateHeld.set(index, n); else this.gateHeld.delete(index);
2118
+ } });
2077
2119
  }
2078
2120
  return this.connect(request);
2079
2121
  }
@@ -2109,7 +2151,7 @@ export class Table {
2109
2151
  }
2110
2152
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
2111
2153
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
2112
- if (url.pathname === '/__facts') return json({ ...room.officeFacts(), durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
2154
+ if (url.pathname === '/__facts') return json({ ...room.officeFacts(), gates: [...(this.gateMetrics?.values() ?? [])], durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
2113
2155
  // A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
2114
2156
  if (url.pathname === '/__chat') {
2115
2157
  const id = url.searchParams.get('id');
@@ -2206,7 +2248,9 @@ export class Table {
2206
2248
  ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
2207
2249
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
2208
2250
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
2209
- buffered: () => 0,
2251
+ buffered: () => server.buffered?.() ?? 0,
2252
+ ...(server.snapshotGroup ? { snapshotGroup: server.snapshotGroup, subscribe: seat => server.subscribe(seat) } : {}),
2253
+ ...(server.snapshot ? { snapshot: (...args) => server.snapshot(...args), transportFacts: server.transportFacts } : {}),
2210
2254
  };
2211
2255
  if (url.pathname === '/__watch') {
2212
2256
  // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
@@ -2227,14 +2271,18 @@ export class Table {
2227
2271
  // seats, so it is not counted twice). One counter write per room.
2228
2272
  const fresh = room.seats.size === 0;
2229
2273
  server.addEventListener('message', (e) => {
2274
+ this.relayFrame = Boolean(server.snapshot);
2275
+ try {
2230
2276
  let text = typeof e.data === 'string' ? e.data : '';
2231
2277
  if (hostedBuild(game)?.settings.characters && account) {
2232
2278
  try { const message = JSON.parse(text); if (message.t === 'hello') { delete message.token; if (this.accountTokens[account]) message.token = this.accountTokens[account]; text = JSON.stringify(message); } } catch { /* normal parser diagnoses it */ }
2233
2279
  }
2234
- h.onMessage(text);
2280
+ if (Number.isFinite(e.receivedAt)) this.inputTransit.push(Math.max(0, Date.now() - e.receivedAt));
2281
+ h.onMessage(text, this.relayFrame ? e.receivedAt : undefined);
2235
2282
  if (hostedBuild(game)?.settings.characters && account) { const joined = room.clients.get(h.id); if (joined?.token) this.accountTokens[account] = joined.token; }
2283
+ } finally { this.relayFrame = false; }
2236
2284
  // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
2237
- if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.report();
2285
+ if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.scheduleReport();
2238
2286
  if (fresh && !this.openCounted && room.seats.size > 0) {
2239
2287
  this.openCounted = true;
2240
2288
  if (conn.qa) return;
@@ -2250,7 +2298,7 @@ export class Table {
2250
2298
  }
2251
2299
  // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
2252
2300
  h.onClose(via);
2253
- try { this.report(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
2301
+ try { this.scheduleReport(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
2254
2302
  };
2255
2303
  server.addEventListener('close', () => left('close'));
2256
2304
  // A lost network arrives here, not as a close: said as what it is, with the client it was, then handled as the
@@ -2279,7 +2327,7 @@ export class Table {
2279
2327
  // A launch change the room applied by itself (after its round): its stored state says so too.
2280
2328
  if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
2281
2329
  n += 1;
2282
- if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
2330
+ if (n % 4 === 0) { this.publishDiagnostics(); room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
2283
2331
  // Room chat's counts, once a minute (section 19).
2284
2332
  if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
2285
2333
  // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
@@ -2314,6 +2362,40 @@ export class Table {
2314
2362
  this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players: 0, people: 0, ...extra }) }).catch(() => {}));
2315
2363
  }
2316
2364
 
2365
+ /**
2366
+ * A big room's diagnostics leave it once a second for the object that answers readers (worker/gate.mjs,
2367
+ * `diagnosticsFetch`): nothing per player, one post outstanding at most, never awaited by a tick or an admission.
2368
+ * A room that every player reaches directly (one Gate's worth of seats) answers for itself and posts nothing.
2369
+ */
2370
+ publishDiagnostics() {
2371
+ const transit = this.inputTransit.sort((a, b) => a - b); this.inputTransit = [];
2372
+ if (!this.env.GATE || !this.relayed || this.diagnosticsPending || !this.room) return;
2373
+ const facts = this.room.diagnosticFacts(), now = Date.now(), last = this.diagnosticsLast;
2374
+ const at = (p) => transit.length ? transit[Math.min(transit.length - 1, Math.floor(transit.length * p))] : null;
2375
+ const tick = facts.ticks?.tick ?? null;
2376
+ this.diagnosticsLast = { now, tick };
2377
+ this.diagnosticsPending = true;
2378
+ const body = JSON.stringify({
2379
+ ...facts,
2380
+ // Ticks the rules ran per second of this object's clock since the last post.
2381
+ tickRate: last && tick !== null && last.tick !== null && now > last.now ? Math.round((tick - last.tick) / (now - last.now) * 100000) / 100 : null,
2382
+ // A Gate's stamp on a player's message to this object handling it, ms.
2383
+ inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
2384
+ durability: this.storageHealth ?? { ok: true },
2385
+ });
2386
+ const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
2387
+ .then((r) => r.body?.cancel?.())
2388
+ .catch((error) => { this.say({ ev: 'diagnostics-publish-failed', error: errorLine(error) }); })
2389
+ .finally(() => { this.diagnosticsPending = false; });
2390
+ this.ctx.waitUntil(work);
2391
+ }
2392
+
2393
+ scheduleReport() {
2394
+ if (this.reportQueued) return;
2395
+ this.reportQueued = true;
2396
+ queueMicrotask(() => { this.reportQueued = false; this.report(); });
2397
+ }
2398
+
2317
2399
  /** Tell the Lobby how many players this room has, when it changes. */
2318
2400
  report() {
2319
2401
  const room = this.room;
@@ -722,6 +722,17 @@ export const OFFICE_SCRIPT = String.raw`(function () {
722
722
  function room(g, r, now) {
723
723
  var box = el('div', 'room');
724
724
  var key = g.id + '/' + r.room;
725
+ if (r.telemetry) {
726
+ if (r.telemetry.toolkit) box.appendChild(el('div', 'faint', 'Toolkit ' + r.telemetry.toolkit.version + ' · build ' + r.telemetry.toolkit.sha256));
727
+ var timing = r.telemetry.ticks && r.telemetry.ticks.timing;
728
+ var stages = timing && timing.ms || {};
729
+ box.appendChild(el('div', 'faint', 'Server telemetry · ' + Object.keys(stages).map(function(k) { return k + ' p95 ' + (stages[k] ? stages[k].p95.toFixed(2) + ' ms' : 'unavailable'); }).join(' · ') + ' · queued relay rows ' + (r.telemetry.transport || []).reduce(function(n, t) { return n + t.queueRows; }, 0)));
730
+ (r.telemetry.gates || []).forEach(function(g) {
731
+ var stages = g.view && g.view.ms || {};
732
+ var timing = ['view','encode','send'].map(function(k) { return k + ' p95 ' + (stages[k] ? stages[k].p95.toFixed(2) + ' ms' : 'unavailable'); }).join(' · ');
733
+ box.appendChild(el('div', 'faint', g.id + ' · ' + g.clients + ' clients · ' + timing + ' · queue ' + g.queueRows + ' reliable / ' + g.pendingViews + ' views · receipt ' + g.ackMs + ' ms'));
734
+ });
735
+ }
725
736
  var head = el('div', 'rhead');
726
737
  head.onclick = function (e) { if (e.target.closest('button,select')) return; S.open[key] = !S.open[key]; render(); };
727
738
  var round = r.round ? 'Round ' + (r.round.n || '?') + (r.round.phase === 'live' && r.round.endsAt ? ' · ' + clock(r.round.endsAt - now) + ' left' : r.round.phase === 'over' ? ' · results' : '') : 'no round yet';
package/worker/office.mjs CHANGED
@@ -62,6 +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
66
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
66
67
  import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
67
68
  import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
@@ -424,6 +425,24 @@ async function roomFacts(env, game, room, max) {
424
425
  } catch { return null; }
425
426
  }
426
427
 
428
+ /**
429
+ * One named room's diagnostics, for a measurement that polls it. A room big enough to run behind Gates is read from
430
+ * the object its room and Gates post to each second, so a poll neither waits behind the room's ticks nor costs them
431
+ * one; the answer carries its age. Only when that copy is missing or old is the room asked, and not for long. A
432
+ * small room answers for itself, as it always has.
433
+ */
434
+ export async function roomDiagnostics(env, game, room, max) {
435
+ if (!env.GATE || roomLayout(max).gates === 1) return roomFacts(env, game, room, max);
436
+ let kept = null;
437
+ try {
438
+ const res = await diagnosticsOf(env, game, room).fetch(`https://diagnostics/__diagnostics?room=${enc(room)}`);
439
+ kept = res.ok ? await res.json() : null;
440
+ } catch { /* the room is asked below */ }
441
+ if (kept?.diagnostics?.ageMs != null && kept.diagnostics.ageMs < 5000) return kept;
442
+ const direct = await Promise.race([roomFacts(env, game, room, max), new Promise((done) => setTimeout(() => done(null), 1500))]);
443
+ return direct ? { ...direct, gates: kept?.gates?.length ? kept.gates : direct.gates, diagnostics: { cached: false, at: direct.st ?? null, ageMs: 0 } } : kept;
444
+ }
445
+
427
446
  /** Hand one room one signed control; what the room says back. */
428
447
  async function roomControl(env, meta, settings, room, op, args) {
429
448
  const ctl = await signControl(env, { op, game: meta.id, room, args });
@@ -471,6 +490,7 @@ async function roomRow(env, meta, room, max) {
471
490
  round: f.round && typeof f.round === 'object' ? { n: f.round.n ?? null, phase: f.round.phase ?? null, startedAt: f.round.startedAt ?? null, endsAt: f.round.endsAt ?? null } : null,
472
491
  openedAt: office.openedAt ?? f.openedAt ?? null, closedUntil: office.closedUntil ?? null, snapHz: f.snapHz ?? 0,
473
492
  regate: office.regate ?? null,
493
+ telemetry: { source: 'server', toolkit: f.toolkit ?? null, ticks: f.ticks ?? null, transport: f.transport ?? [], gates: f.gates ?? [] },
474
494
  host: f.host ?? null, announce: f.announce ?? null,
475
495
  slots: slots.map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
476
496
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
@@ -1213,6 +1233,13 @@ async function api(request, env, url, cat) {
1213
1233
  if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ ok: false, error: 'json' }, 400);
1214
1234
  } else if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
1215
1235
 
1236
+ if (path === '/_studio/api/room' && request.method === 'GET') {
1237
+ const game = url.searchParams.get('game'), room = url.searchParams.get('room');
1238
+ const meta = (cat.games ?? []).find(g => g.id === game);
1239
+ if (!meta || !ROOM_ID.test(room ?? '')) return json({ ok: false, error: 'room' }, 400);
1240
+ const facts = await roomDiagnostics(env, game, room, seatsFor(meta, await settingsOf(env)));
1241
+ return json({ ok: Boolean(facts), facts });
1242
+ }
1216
1243
  if (path === '/_studio/api/office' && request.method === 'GET') {
1217
1244
  try { return json({ ...(await officeView(env, cat, url.origin)), by: who }); } catch (error) {
1218
1245
  return json({ ok: false, error: 'no-office', message: `The office is not in this studio's D1 yet: \`npm run deploy\` applies migration 0005_studio_office.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
@@ -28,7 +28,7 @@ import { toolIdentity } from './tool-identity.mjs';
28
28
  import { handleFor } from './room.mjs';
29
29
  import { isAiName } from './agents.mjs';
30
30
  import { cookieValue } from './stats.mjs';
31
- import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
31
+ import { adoptGuest, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
32
32
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
33
33
  import { ACCOUNT_JS, accountPage } from './account-page.mjs';
34
34
  import { adoptShopStatements, migrated as shopMigrated, forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
@@ -693,21 +693,19 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
693
693
  session.statement,
694
694
  ];
695
695
  let adopted = null;
696
- if (current && current.player.id !== key.player) {
697
- writes.push(env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(current.hash));
698
- // A guest signing in to their account: what only the guest had moves over, then the guest is gone.
699
- if (current.player.guest) {
700
- adopted = await adoptStatements(env, current.player.id, key.player);
701
- writes.push(...adopted.statements, ...(await adoptShopStatements(env, current.player.id, key.player)),
702
- env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(current.player.id),
703
- env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(current.player.id),
704
- env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(current.player.id));
705
- }
706
- }
707
- await env.DB.batch(writes);
708
- if (adopted) await adopted.after();
696
+ if (current && current.player.id !== key.player) writes.push(env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(current.hash));
697
+ if (current && current.player.id !== key.player && current.player.guest) {
698
+ // A guest signing in to their account: what only the guest had moves over, what both have is combined by the
699
+ // game's rules (mergeSaves), then the guest is gone. One batch; read and tried again if a room wrote in between.
700
+ const guest = current.player.id;
701
+ const shop = await adoptShopStatements(env, guest, key.player);
702
+ adopted = await adoptGuest(env, guest, key.player, (statements) => [...writes, ...statements, ...shop,
703
+ env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(guest),
704
+ env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(guest),
705
+ env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(guest)]);
706
+ } else await env.DB.batch(writes);
709
707
  const player = { id: key.player, name: key.name, named: Number(key.named) === 1, guest: false, owner: Number(key.owner) === 1, since: Number(key.created_at) };
710
- return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted) }), [session.cookie]);
708
+ return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted), ...(adopted ? { adopted } : {}) }), [session.cookie]);
711
709
  }
712
710
 
713
711
  /* ---------------- a recovery email, signed out: the link in the mail is the proof */
package/worker/room.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { STUDIO_BUILD } from './build-id.mjs';
1
2
  import { interestSnapshot, snapshotEncoder, scheduledView } from '../rules/interest.mjs';
2
3
  /*
3
4
  * room.mjs — the netplay v1 relay semantics, with no transport in it.
@@ -260,8 +261,9 @@ export class NetRoom {
260
261
  constructor({
261
262
  code, rules = false, tickHz = 20, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
262
263
  holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
263
- agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
264
+ rates = null, agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
264
265
  }) {
266
+ this.rates = rates;
265
267
  this.agentsAloneMs = agentsAloneMs;
266
268
  this.voteMs = voteMs;
267
269
  this.voteCooldownMs = voteCooldownMs;
@@ -386,6 +388,7 @@ export class NetRoom {
386
388
  /** The host runtime of a server-hosted room (`{ frame(m), facts() }`), or null. Seats already here are said as joins. */
387
389
  setServerHost(h) {
388
390
  this.server = h;
391
+ if (h?.rates) this.rates = h.rates;
389
392
  this.hostFailed = null;
390
393
  this.hostId = null;
391
394
  this.preferHost = null;
@@ -449,7 +452,8 @@ export class NetRoom {
449
452
  this.stats.evs += 1;
450
453
  const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
451
454
  const line = JSON.stringify(out);
452
- if (Number.isInteger(m.to)) { for (const o of this.live()) if (o.seat === m.to) this.sendText(o.conn, line); return; }
455
+ // `to: -1` (0.46.3): everyone with no seat, a watcher. An effect with an audience reaches them whole.
456
+ if (Number.isInteger(m.to)) { for (const o of this.live()) if (m.to === -1 ? o.seat === null : o.seat === m.to) this.sendText(o.conn, line); return; }
453
457
  for (const o of this.live()) if (!this.lite(o) || this.liteHears(kind)) this.sendText(o.conn, line);
454
458
  return;
455
459
  }
@@ -457,6 +461,25 @@ export class NetRoom {
457
461
  }
458
462
  }
459
463
 
464
+ /**
465
+ * One measurement of a connection's round trip (0.46.3), and what the rules are told: a LOW PERCENTILE of the last ten
466
+ * seconds of them. A measurement is never shorter than the real round trip, so the low end of a window is close to
467
+ * it and one slow answer does not raise it; a window, so a connection that really got slower or faster is followed.
468
+ * A client can still pretend to be slower by answering late every time: what that buys is bounded by the game's
469
+ * `room.rewindSeconds`, and with acknowledgements on the input frames it has to send its input late as well.
470
+ */
471
+ latencySample(c, ms, now) {
472
+ if (!(ms >= 0 && ms <= 10000)) return;
473
+ const w = (c.rttWindow ??= []);
474
+ w.push([now, ms]);
475
+ while (w.length > 256 || (w.length > 1 && w[0][0] < now - 10000)) w.shift();
476
+ const sorted = w.map((x) => x[1]).sort((a, b) => a - b);
477
+ const low = sorted[Math.floor((sorted.length - 1) * 0.1)];
478
+ if (c.measuredRtt !== undefined && Math.abs(low - c.measuredRtt) < 2 && now - (c.rttToldAt ?? 0) < 1000) return;
479
+ c.measuredRtt = low; c.rttToldAt = now;
480
+ if (c.seat !== null) this.toServer({ t: 'latency', seat: c.seat, ms: low });
481
+ }
482
+
460
483
  /** A snapshot from the host (a browser's socket `c`, or the server: `c` null), kept for joiners and fanned out. */
461
484
  hostSnap(c, m, now, text = null) {
462
485
  // The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
@@ -465,6 +488,7 @@ export class NetRoom {
465
488
  // Revision 10: a rules game's snapshot names the room's epoch.
466
489
  if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
467
490
  this.lastSnap = snap;
491
+ if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
468
492
  if (c) c.lastSnapAt = now;
469
493
  this.stats.snaps += 1;
470
494
  this.snapTimes.push(now);
@@ -474,12 +498,24 @@ export class NetRoom {
474
498
  const out = text ?? JSON.stringify({ t: 'snap', from: c ? c.seat : null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
475
499
  if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
476
500
  this.lastSnapText = out;
501
+ // A server-hosted room behind Gates hands each Gate ONE world a tick; the Gate selects, encodes and paces
502
+ // every player it holds (worker/gate.mjs). This object's tick does no per-player selection or serialisation:
503
+ // a player costs it one comparison here. A game with fields only their owner may see is the exception: the
504
+ // room alone knows who may see what, so it keeps each player's view to itself and the Gates only carry it.
505
+ const shared = !c && this.server && !this.server.privateDelivery;
506
+ const groups = shared ? new Set() : null;
477
507
  for (const o of this.others(c)) {
478
508
  if (this.lite(o)) continue; // an agent with no game client draws nothing
509
+ if (shared && o.conn.snapshotGroup) {
510
+ if (o.conn.snapshotSeat !== o.seat) { o.conn.snapshotSeat = o.seat; o.conn.subscribe(o.seat); }
511
+ groups.add(o.conn.snapshotGroup);
512
+ continue;
513
+ }
479
514
  // A congested socket skips a snapshot rather than queueing a stale one.
480
515
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
481
516
  this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
482
517
  }
518
+ if (groups) for (const group of groups) group.snapshot(snap, this.server.viewSettings ?? {}, this.tickHz);
483
519
  }
484
520
 
485
521
  hasViewSchedule() {
@@ -490,6 +526,12 @@ export class NetRoom {
490
526
  /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
491
527
  playerSnapshot(client, snap, welcome = false) {
492
528
  if (!snap || !this.hasViewSchedule()) return snap;
529
+ // Relayed clients get a complete welcome, then Gates own their delivery
530
+ // baselines. Retaining a welcome-only encoder here pins a second set of
531
+ // per-player rows for the lifetime of every seat, without ever using it.
532
+ if (welcome && client.conn.snapshotGroup && !this.server.privateDelivery) {
533
+ return scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz)(snap, client.seat);
534
+ }
493
535
  client.viewSchedule ??= scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz);
494
536
  let selected = client.viewSchedule(snap, client.seat);
495
537
  if(this.server?.projectSnapshot) selected=this.server.projectSnapshot(selected,client.seat);
@@ -601,7 +643,7 @@ export class NetRoom {
601
643
  this.clients.set(c.id, c);
602
644
  return {
603
645
  id: c.id,
604
- onMessage: (text) => this.guard(c, 'message', () => this.onMessage(c, text), text),
646
+ onMessage: (text, receivedAt) => this.guard(c, 'message', () => this.onMessage(c, text, receivedAt), text),
605
647
  /** `via`: how the transport learned it (`close`, or `error`: the socket failed, which is how a lost network arrives). */
606
648
  onClose: (via = 'close') => this.guard(c, 'close', () => this.onClose(c, 'closed', via)),
607
649
  };
@@ -664,7 +706,9 @@ export class NetRoom {
664
706
  sendText(conn, text) {
665
707
  try { conn.send(text); this.stats.bytesOut += this.rules ? byteLength(text) : text.length; } catch { /* closing */ }
666
708
  }
667
- send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
709
+ send(c, msg) {
710
+ this.sendText(c.conn, JSON.stringify(msg));
711
+ }
668
712
  error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
669
713
  live() { return [...this.clients.values()].filter((c) => c.helloed); }
670
714
  others(c) { return this.live().filter((o) => o !== c); }
@@ -754,7 +798,7 @@ export class NetRoom {
754
798
  return true;
755
799
  }
756
800
 
757
- allow(c, t, bytes, view = false) {
801
+ allow(c, t, bytes, view = false, receivedAt, kind = null) {
758
802
  // A spectator screen has little to say: its events are capped at 512 B (16 screens x 2/s x 512 B at most reach the host).
759
803
  const cap = t === 'ev' && c.seat === null && (view || c.id !== this.hostId) ? 512 : capOf(t, this.seatCap);
760
804
  const rulesOutput = !view && this.rules && c.rules && c.id === this.hostId;
@@ -765,10 +809,14 @@ export class NetRoom {
765
809
  if (rulesOutput) this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room size cap: ${detail}. Join again for a fresh room.`);
766
810
  return false;
767
811
  }
768
- const now = this.now();
812
+ // Trusted Gate ingress time measures the sender, not a delayed batch's
813
+ // arrival at the Table. Direct sockets keep the Table clock. Monotonicity
814
+ // prevents a relay clock adjustment from resetting an existing rate window.
815
+ const now = Number.isFinite(receivedAt) ? (c.ingressRateAt = Math.max(c.ingressRateAt ?? receivedAt, receivedAt)) : this.now();
769
816
  if (rulesOutput) {
770
817
  c.rulesBuckets ??= new Map();
771
- const rate = rulesRates(this.tickHz, this.seatCap)[t];
818
+ const rate = this.rates?.[t] ?? rulesRates(this.tickHz, this.seatCap)[t];
819
+ if (!(Number.isFinite(rate) && rate > 0)) return true;
772
820
  if (!takeRulesToken(c.rulesBuckets, t, rate, now)) return true;
773
821
  this.stats.drops += 1;
774
822
  if (t === 'snap') return false;
@@ -779,7 +827,16 @@ export class NetRoom {
779
827
  else { this.hostId = c.id; this.stopBrowserRules(c, 'rate', 'No player can host this room after its host failed. Join again for a fresh room.'); }
780
828
  return false;
781
829
  }
782
- const limit = t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
830
+ // Homie paces nothing a player sends a game whose rules run on the server: the rules hold every input to
831
+ // its declared type and their own budget, and a batch a slow network delivered late is not a flood (0.45.3
832
+ // dropped honest input at 60 a second in a room of 1,000). A studio that wants a pace names one
833
+ // (`room.rates` in game.json, messages a second per player; 0 lifts one). What players say to each other
834
+ // (speech, chat, reactions) keeps its pace, and so does every room a player's own browser hosts, where the
835
+ // caps are what stands between that phone and the rest of the room.
836
+ const social = t === 'say' || t === 'react' || t === 'ev' && SPEECH.test(kind ?? '');
837
+ const preset = this.server && !social ? null : t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
838
+ const limit = this.rates?.[t] ?? preset;
839
+ if (!(Number.isFinite(limit) && limit > 0)) return true;
783
840
  const rateKey = view ? `${t}:view` : t;
784
841
  const list = c.rates.get(rateKey) ?? [];
785
842
  while (list.length && list[0] <= now - 1000) list.shift();
@@ -797,7 +854,7 @@ export class NetRoom {
797
854
  return true;
798
855
  }
799
856
 
800
- onMessage(c, text) {
857
+ onMessage(c, text, receivedAt) {
801
858
  if (this.clients.get(c.id) !== c) return;
802
859
  text = String(text);
803
860
  const now = this.now();
@@ -809,7 +866,7 @@ export class NetRoom {
809
866
  if (!m || typeof m.t !== 'string') return;
810
867
  if (!c.helloed) { if (m.t === 'hello' && bytes <= LIMITS.hello) this.hello(c, m); return; }
811
868
  const view = this.rules && !(c.rules && c.id === this.hostId && m.rules === true && RULES_FRAMES.includes(m.t));
812
- if (!this.allow(c, m.t, bytes, view)) return;
869
+ if (!this.allow(c, m.t, bytes, view, receivedAt, m.t === 'ev' ? String(m.k ?? '') : null)) return;
813
870
  const isHost = c.id === this.hostId && !view;
814
871
  switch (m.t) {
815
872
  case 'rules-end': {
@@ -834,6 +891,9 @@ export class NetRoom {
834
891
  if (this.server) {
835
892
  if (!Array.isArray(m.s)) return;
836
893
  this.stats.ins += 1;
894
+ // 0.46.3: the frame names the newest snapshot this browser had when it sent it (`g`). From when that snapshot
895
+ // left here to when this input arrived is this connection's round trip, measured on the input's own path.
896
+ if (Number.isInteger(m.g)) { c.inBand = true; const sent = this.snapAt?.get(m.g); if (sent !== undefined) this.latencySample(c, now - sent, now); }
837
897
  this.toServer({ t: 'in', from: c.seat, e: Number(m.e) || 0, k: Number(m.k) || 0, s: m.s.slice(0, 64), r: Number(m.r) || 0 });
838
898
  return;
839
899
  }
@@ -873,7 +933,7 @@ export class NetRoom {
873
933
  const recipients = isHost && this.rules && c.rules ? this.live() : this.others(c);
874
934
  if (isHost) {
875
935
  if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && (o !== c || (this.rules && c.rules))) this.send(o, out); }
876
- else if (Number.isInteger(m.to)) { for (const o of recipients) if (o.seat === m.to) this.send(o, out); }
936
+ else if (Number.isInteger(m.to)) { for (const o of recipients) if (m.to === -1 ? o.seat === null : o.seat === m.to) this.send(o, out); }
877
937
  // An agent with no game client hears only what is addressed to its seat (the lite feed), and the party's lines.
878
938
  else for (const o of recipients) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
879
939
  } else {
@@ -922,7 +982,8 @@ export class NetRoom {
922
982
  case 'probeAck': {
923
983
  if (c.probe && m.n === c.probe.n) {
924
984
  const ms = now - c.probe.at; c.probe = null;
925
- if(ms>=0 && ms<=10000){ c.measuredRtt=Math.min(c.measuredRtt??Infinity,ms); if(c.seat!==null)this.toServer({t:'latency',seat:c.seat,ms:c.measuredRtt}); }
985
+ // A browser that acknowledges on its input frames is measured there; this separate answer is for one that does not.
986
+ if(ms>=0 && ms<=10000 && !c.inBand) this.latencySample(c, ms, now);
926
987
  }
927
988
  return;
928
989
  }
@@ -1816,9 +1877,9 @@ export class NetRoom {
1816
1877
  }
1817
1878
 
1818
1879
  /** The seat a slot names (its own, or the one its agent sits in), and what holds it. */
1819
- holderOf(seat) {
1880
+ holderOf(seat, holders) {
1820
1881
  if (!Number.isInteger(seat)) return { kind: 'none' };
1821
- const c = this.live().find((o) => o.seat === seat);
1882
+ const c = holders ? holders.get(seat) : this.live().find((o) => o.seat === seat);
1822
1883
  const s = this.seats.get(seat);
1823
1884
  if (c && c.agent) return { kind: 'agent', agent: c.agent, name: c.name };
1824
1885
  if (c) return { kind: 'human', name: c.name };
@@ -1832,21 +1893,29 @@ export class NetRoom {
1832
1893
  * slot with no seat is a bot; a slot naming a seat nobody holds is a bot; a person is never marked AI and never
1833
1894
  * named with an AI mark. A host's game cannot present an AI as a person.
1834
1895
  */
1896
+ holderIndex() {
1897
+ const holders = new Map();
1898
+ for (const client of this.live()) if (!holders.has(client.seat)) holders.set(client.seat, client);
1899
+ return holders;
1900
+ }
1901
+
1835
1902
  labelRoster(slots) {
1836
- return slots.map((raw) => this.labelOne(raw, false)).filter(Boolean);
1903
+ const holders = this.holderIndex();
1904
+ return slots.map((raw) => this.labelOne(raw, false, holders)).filter(Boolean);
1837
1905
  }
1838
1906
 
1839
1907
  /** A round's results, labelled the same way (`agent: true` on every AI's row). */
1840
1908
  labelResults(rows) {
1841
- return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1909
+ const holders = this.holderIndex();
1910
+ return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true, holders)).filter(Boolean);
1842
1911
  }
1843
1912
 
1844
- labelOne(raw, result) {
1913
+ labelOne(raw, result, holders) {
1845
1914
  if (!raw || typeof raw !== 'object') return null;
1846
1915
  if (this.rules) {
1847
1916
  if (!Number.isInteger(raw.slot) || raw.slot < 0 || raw.slot >= this.seatCap) return null;
1848
1917
  raw = { ...raw, seat: raw.slot };
1849
- const holder = this.holderOf(raw.slot);
1918
+ const holder = this.holderOf(raw.slot, holders);
1850
1919
  if (holder.kind !== 'human' && holder.kind !== 'agent') {
1851
1920
  if (raw.slot >= this.maxPlayers - this.reserve())
1852
1921
  raw.agent = { seat: null, role: raw.slot >= this.maxPlayers - this.policy.guides ? 'guide' : 'party', hands: 'host' };
@@ -1856,7 +1925,7 @@ export class NetRoom {
1856
1925
  const out = { ...raw };
1857
1926
  const seat = Number.isInteger(raw.seat) ? raw.seat : null;
1858
1927
  const agentSeat = !result && raw.agent && Number.isInteger(raw.agent.seat) ? raw.agent.seat : null;
1859
- const who = this.holderOf(seat ?? agentSeat);
1928
+ const who = this.holderOf(seat ?? agentSeat, holders);
1860
1929
  const role = (a) => (['party', 'guide', 'player'].includes(a?.role) ? a.role : 'party');
1861
1930
  if (who.kind === 'agent') {
1862
1931
  out.name = who.name || aiName(raw.name);
@@ -2403,6 +2472,9 @@ export class NetRoom {
2403
2472
  const live = this.live();
2404
2473
  const agents = live.filter((c) => c.agent && c.seat !== null).length;
2405
2474
  return {
2475
+ transport: [...new Set(this.live().map(c => c.conn.transportFacts).filter(Boolean))].map(f => f()),
2476
+ toolkit: STUDIO_BUILD,
2477
+ gates: this.gateFacts?.() ?? [],
2406
2478
  t: 'net', v: NET_VERSION, rev: NET_REVISION, room: this.code, st: now,
2407
2479
  host: this.hostRef(),
2408
2480
  openedAt: this.openedAt || null,
@@ -2448,6 +2520,28 @@ export class NetRoom {
2448
2520
  };
2449
2521
  }
2450
2522
 
2523
+ /** What a measurement of a big room reads each second: its counts, clock and links, and nothing per player. */
2524
+ diagnosticFacts() {
2525
+ const now = this.now(), live = this.live();
2526
+ while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
2527
+ return {
2528
+ toolkit: STUDIO_BUILD, room: this.code, st: now,
2529
+ transport: [...new Set(live.map(c => c.conn.transportFacts).filter(Boolean))].map(f => f()),
2530
+ counts: {
2531
+ players: live.filter((c) => c.seat !== null && !c.agent).length,
2532
+ agents: live.filter((c) => c.agent && c.seat !== null).length,
2533
+ screens: live.filter((c) => c.seat === null).length,
2534
+ waiting: live.filter((c) => c.waiting).length,
2535
+ shells: this.watchers.size,
2536
+ maxPlayers: this.maxPlayers,
2537
+ },
2538
+ snapHz: this.snapTimes.length,
2539
+ ...(this.server ? { hosted: 'server', ticks: this.server.facts?.() ?? null } : {}),
2540
+ stats: { ins: this.stats.ins, snaps: this.stats.snaps, drops: this.stats.drops, oversize: this.stats.oversize },
2541
+ snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
2542
+ };
2543
+ }
2544
+
2451
2545
  tellWatchers() {
2452
2546
  if (!this.watchers.size) return;
2453
2547
  const text = JSON.stringify(this.facts());