@homie-rocks/studio 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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';
@@ -712,6 +714,7 @@ async function route(request, env, ctx) {
712
714
  return json({
713
715
  v: 1,
714
716
  kind: 'homie-studio',
717
+ toolkit: STUDIO_BUILD,
715
718
  name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
716
719
  slug: cat.studio?.slug ?? null,
717
720
  ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
@@ -1327,6 +1330,10 @@ export class Table {
1327
1330
  this.hostRt = null;
1328
1331
  this.characterGrants = new Map();
1329
1332
  this.accountTokens = {};
1333
+ this.inputTransit = [];
1334
+ this.relayed = false;
1335
+ this.gateHeld = new Map();
1336
+ this.gatePromised = new Map();
1330
1337
  this.roomAlarmAt = 0;
1331
1338
  this.durable = null;
1332
1339
  this.roomSave = null;
@@ -1354,7 +1361,21 @@ export class Table {
1354
1361
  maxPlayers: max,
1355
1362
  perIp: perAddress(max),
1356
1363
  store: {
1357
- save: (o) => { if (this.hostRt) this.saveRoom(this.hostRt.save()); else if (!hostedGame(game)) storage.put('net', o).catch(() => {}); },
1364
+ save: (o) => {
1365
+ if (this.hostRt && !this.relayFrame) this.saveRoom(this.hostRt.save());
1366
+ else if (this.hostRt) {
1367
+ // All admissions in one relay turn share a checkpoint. The microtask
1368
+ // runs before the relay's timer flush, retaining the storage output gate.
1369
+ if (!this.admissionSaveQueued) {
1370
+ this.admissionSaveQueued = true;
1371
+ const host = this.hostRt;
1372
+ queueMicrotask(() => {
1373
+ this.admissionSaveQueued = false;
1374
+ if (this.hostRt === host) { try { this.saveRoom(host.save()); } catch (error) { this.storageProblem(error); } }
1375
+ });
1376
+ }
1377
+ } else if (!hostedGame(game)) storage.put('net', o).catch(() => {});
1378
+ },
1358
1379
  clear: () => { this.clearRoom(); },
1359
1380
  },
1360
1381
  // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
@@ -1362,6 +1383,7 @@ export class Table {
1362
1383
  // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
1363
1384
  log: (line) => this.say(line),
1364
1385
  });
1386
+ this.room.gateFacts = () => [...(this.gateMetrics?.values() ?? [])];
1365
1387
  if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
1366
1388
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1367
1389
  this.vocabRead = this.readVocab(game).catch(() => false);
@@ -2072,8 +2094,26 @@ export class Table {
2072
2094
  if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
2073
2095
  return json({ ok: false }, 400);
2074
2096
  }
2075
- if (new URL(request.url).pathname === '/__multiplex') {
2076
- return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
2097
+ const path = new URL(request.url).pathname;
2098
+ if (path === '/__gate') {
2099
+ // The door asks which Gate takes the next player (worker/gate.mjs, nextGate): the lowest with room, counting
2100
+ // players sent to a Gate in the last ten seconds who have not reached it yet.
2101
+ const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
2102
+ 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); }
2103
+ const gate = nextGate(gates, this.gateHeld, promised);
2104
+ if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
2105
+ this.gatePromised.get(gate).push(now + 10_000);
2106
+ return json({ gate });
2107
+ }
2108
+ if (path === '/__multiplex') {
2109
+ this.relayed = true;
2110
+ const index = Number(new URL(request.url).searchParams.get('gate'));
2111
+ // One encoding of each world for every Gate link (worker/world-wire.mjs).
2112
+ return acceptMultiplex(request, (req, socket) => this.connect(req, socket), { source: (this.worldSource ??= worldSource()), onCount: (n) => {
2113
+ // A player arrived: a promise to that Gate is kept. The Gate's count is what the next join is placed by.
2114
+ if (n > (this.gateHeld.get(index) ?? 0)) this.gatePromised.get(index)?.shift();
2115
+ if (n > 0) this.gateHeld.set(index, n); else this.gateHeld.delete(index);
2116
+ } });
2077
2117
  }
2078
2118
  return this.connect(request);
2079
2119
  }
@@ -2109,7 +2149,7 @@ export class Table {
2109
2149
  }
2110
2150
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
2111
2151
  // 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() } : {}) });
2152
+ if (url.pathname === '/__facts') return json({ ...room.officeFacts(), gates: [...(this.gateMetrics?.values() ?? [])], durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
2113
2153
  // 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
2154
  if (url.pathname === '/__chat') {
2115
2155
  const id = url.searchParams.get('id');
@@ -2206,7 +2246,9 @@ export class Table {
2206
2246
  ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
2207
2247
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
2208
2248
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
2209
- buffered: () => 0,
2249
+ buffered: () => server.buffered?.() ?? 0,
2250
+ ...(server.snapshotGroup ? { snapshotGroup: server.snapshotGroup, subscribe: seat => server.subscribe(seat) } : {}),
2251
+ ...(server.snapshot ? { snapshot: (...args) => server.snapshot(...args), transportFacts: server.transportFacts } : {}),
2210
2252
  };
2211
2253
  if (url.pathname === '/__watch') {
2212
2254
  // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
@@ -2227,14 +2269,18 @@ export class Table {
2227
2269
  // seats, so it is not counted twice). One counter write per room.
2228
2270
  const fresh = room.seats.size === 0;
2229
2271
  server.addEventListener('message', (e) => {
2272
+ this.relayFrame = Boolean(server.snapshot);
2273
+ try {
2230
2274
  let text = typeof e.data === 'string' ? e.data : '';
2231
2275
  if (hostedBuild(game)?.settings.characters && account) {
2232
2276
  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
2277
  }
2234
- h.onMessage(text);
2278
+ if (Number.isFinite(e.receivedAt)) this.inputTransit.push(Math.max(0, Date.now() - e.receivedAt));
2279
+ h.onMessage(text, this.relayFrame ? e.receivedAt : undefined);
2235
2280
  if (hostedBuild(game)?.settings.characters && account) { const joined = room.clients.get(h.id); if (joined?.token) this.accountTokens[account] = joined.token; }
2281
+ } finally { this.relayFrame = false; }
2236
2282
  // 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();
2283
+ if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.scheduleReport();
2238
2284
  if (fresh && !this.openCounted && room.seats.size > 0) {
2239
2285
  this.openCounted = true;
2240
2286
  if (conn.qa) return;
@@ -2250,7 +2296,7 @@ export class Table {
2250
2296
  }
2251
2297
  // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
2252
2298
  h.onClose(via);
2253
- try { this.report(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
2299
+ try { this.scheduleReport(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
2254
2300
  };
2255
2301
  server.addEventListener('close', () => left('close'));
2256
2302
  // 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 +2325,7 @@ export class Table {
2279
2325
  // A launch change the room applied by itself (after its round): its stored state says so too.
2280
2326
  if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
2281
2327
  n += 1;
2282
- if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
2328
+ if (n % 4 === 0) { this.publishDiagnostics(); room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
2283
2329
  // Room chat's counts, once a minute (section 19).
2284
2330
  if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
2285
2331
  // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
@@ -2314,6 +2360,40 @@ export class Table {
2314
2360
  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
2361
  }
2316
2362
 
2363
+ /**
2364
+ * A big room's diagnostics leave it once a second for the object that answers readers (worker/gate.mjs,
2365
+ * `diagnosticsFetch`): nothing per player, one post outstanding at most, never awaited by a tick or an admission.
2366
+ * A room that every player reaches directly (one Gate's worth of seats) answers for itself and posts nothing.
2367
+ */
2368
+ publishDiagnostics() {
2369
+ const transit = this.inputTransit.sort((a, b) => a - b); this.inputTransit = [];
2370
+ if (!this.env.GATE || !this.relayed || this.diagnosticsPending || !this.room) return;
2371
+ const facts = this.room.diagnosticFacts(), now = Date.now(), last = this.diagnosticsLast;
2372
+ const at = (p) => transit.length ? transit[Math.min(transit.length - 1, Math.floor(transit.length * p))] : null;
2373
+ const tick = facts.ticks?.tick ?? null;
2374
+ this.diagnosticsLast = { now, tick };
2375
+ this.diagnosticsPending = true;
2376
+ const body = JSON.stringify({
2377
+ ...facts,
2378
+ // Ticks the rules ran per second of this object's clock since the last post.
2379
+ tickRate: last && tick !== null && last.tick !== null && now > last.now ? Math.round((tick - last.tick) / (now - last.now) * 100000) / 100 : null,
2380
+ // A Gate's stamp on a player's message to this object handling it, ms.
2381
+ inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
2382
+ durability: this.storageHealth ?? { ok: true },
2383
+ });
2384
+ const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
2385
+ .then((r) => r.body?.cancel?.())
2386
+ .catch((error) => { this.say({ ev: 'diagnostics-publish-failed', error: errorLine(error) }); })
2387
+ .finally(() => { this.diagnosticsPending = false; });
2388
+ this.ctx.waitUntil(work);
2389
+ }
2390
+
2391
+ scheduleReport() {
2392
+ if (this.reportQueued) return;
2393
+ this.reportQueued = true;
2394
+ queueMicrotask(() => { this.reportQueued = false; this.report(); });
2395
+ }
2396
+
2317
2397
  /** Tell the Lobby how many players this room has, when it changes. */
2318
2398
  report() {
2319
2399
  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);
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;
@@ -474,12 +477,24 @@ export class NetRoom {
474
477
  const out = text ?? JSON.stringify({ t: 'snap', from: c ? c.seat : null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
475
478
  if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
476
479
  this.lastSnapText = out;
480
+ // A server-hosted room behind Gates hands each Gate ONE world a tick; the Gate selects, encodes and paces
481
+ // every player it holds (worker/gate.mjs). This object's tick does no per-player selection or serialisation:
482
+ // a player costs it one comparison here. A game with fields only their owner may see is the exception: the
483
+ // room alone knows who may see what, so it keeps each player's view to itself and the Gates only carry it.
484
+ const shared = !c && this.server && !this.server.privateDelivery;
485
+ const groups = shared ? new Set() : null;
477
486
  for (const o of this.others(c)) {
478
487
  if (this.lite(o)) continue; // an agent with no game client draws nothing
488
+ if (shared && o.conn.snapshotGroup) {
489
+ if (o.conn.snapshotSeat !== o.seat) { o.conn.snapshotSeat = o.seat; o.conn.subscribe(o.seat); }
490
+ groups.add(o.conn.snapshotGroup);
491
+ continue;
492
+ }
479
493
  // A congested socket skips a snapshot rather than queueing a stale one.
480
494
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
481
495
  this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
482
496
  }
497
+ if (groups) for (const group of groups) group.snapshot(snap, this.server.viewSettings ?? {}, this.tickHz);
483
498
  }
484
499
 
485
500
  hasViewSchedule() {
@@ -490,6 +505,12 @@ export class NetRoom {
490
505
  /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
491
506
  playerSnapshot(client, snap, welcome = false) {
492
507
  if (!snap || !this.hasViewSchedule()) return snap;
508
+ // Relayed clients get a complete welcome, then Gates own their delivery
509
+ // baselines. Retaining a welcome-only encoder here pins a second set of
510
+ // per-player rows for the lifetime of every seat, without ever using it.
511
+ if (welcome && client.conn.snapshotGroup && !this.server.privateDelivery) {
512
+ return scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz)(snap, client.seat);
513
+ }
493
514
  client.viewSchedule ??= scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz);
494
515
  let selected = client.viewSchedule(snap, client.seat);
495
516
  if(this.server?.projectSnapshot) selected=this.server.projectSnapshot(selected,client.seat);
@@ -601,7 +622,7 @@ export class NetRoom {
601
622
  this.clients.set(c.id, c);
602
623
  return {
603
624
  id: c.id,
604
- onMessage: (text) => this.guard(c, 'message', () => this.onMessage(c, text), text),
625
+ onMessage: (text, receivedAt) => this.guard(c, 'message', () => this.onMessage(c, text, receivedAt), text),
605
626
  /** `via`: how the transport learned it (`close`, or `error`: the socket failed, which is how a lost network arrives). */
606
627
  onClose: (via = 'close') => this.guard(c, 'close', () => this.onClose(c, 'closed', via)),
607
628
  };
@@ -664,7 +685,9 @@ export class NetRoom {
664
685
  sendText(conn, text) {
665
686
  try { conn.send(text); this.stats.bytesOut += this.rules ? byteLength(text) : text.length; } catch { /* closing */ }
666
687
  }
667
- send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
688
+ send(c, msg) {
689
+ this.sendText(c.conn, JSON.stringify(msg));
690
+ }
668
691
  error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
669
692
  live() { return [...this.clients.values()].filter((c) => c.helloed); }
670
693
  others(c) { return this.live().filter((o) => o !== c); }
@@ -754,7 +777,7 @@ export class NetRoom {
754
777
  return true;
755
778
  }
756
779
 
757
- allow(c, t, bytes, view = false) {
780
+ allow(c, t, bytes, view = false, receivedAt, kind = null) {
758
781
  // 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
782
  const cap = t === 'ev' && c.seat === null && (view || c.id !== this.hostId) ? 512 : capOf(t, this.seatCap);
760
783
  const rulesOutput = !view && this.rules && c.rules && c.id === this.hostId;
@@ -765,10 +788,14 @@ export class NetRoom {
765
788
  if (rulesOutput) this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room size cap: ${detail}. Join again for a fresh room.`);
766
789
  return false;
767
790
  }
768
- const now = this.now();
791
+ // Trusted Gate ingress time measures the sender, not a delayed batch's
792
+ // arrival at the Table. Direct sockets keep the Table clock. Monotonicity
793
+ // prevents a relay clock adjustment from resetting an existing rate window.
794
+ const now = Number.isFinite(receivedAt) ? (c.ingressRateAt = Math.max(c.ingressRateAt ?? receivedAt, receivedAt)) : this.now();
769
795
  if (rulesOutput) {
770
796
  c.rulesBuckets ??= new Map();
771
- const rate = rulesRates(this.tickHz, this.seatCap)[t];
797
+ const rate = this.rates?.[t] ?? rulesRates(this.tickHz, this.seatCap)[t];
798
+ if (!(Number.isFinite(rate) && rate > 0)) return true;
772
799
  if (!takeRulesToken(c.rulesBuckets, t, rate, now)) return true;
773
800
  this.stats.drops += 1;
774
801
  if (t === 'snap') return false;
@@ -779,7 +806,16 @@ export class NetRoom {
779
806
  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
807
  return false;
781
808
  }
782
- const limit = t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
809
+ // Homie paces nothing a player sends a game whose rules run on the server: the rules hold every input to
810
+ // its declared type and their own budget, and a batch a slow network delivered late is not a flood (0.45.3
811
+ // dropped honest input at 60 a second in a room of 1,000). A studio that wants a pace names one
812
+ // (`room.rates` in game.json, messages a second per player; 0 lifts one). What players say to each other
813
+ // (speech, chat, reactions) keeps its pace, and so does every room a player's own browser hosts, where the
814
+ // caps are what stands between that phone and the rest of the room.
815
+ const social = t === 'say' || t === 'react' || t === 'ev' && SPEECH.test(kind ?? '');
816
+ const preset = this.server && !social ? null : t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
817
+ const limit = this.rates?.[t] ?? preset;
818
+ if (!(Number.isFinite(limit) && limit > 0)) return true;
783
819
  const rateKey = view ? `${t}:view` : t;
784
820
  const list = c.rates.get(rateKey) ?? [];
785
821
  while (list.length && list[0] <= now - 1000) list.shift();
@@ -797,7 +833,7 @@ export class NetRoom {
797
833
  return true;
798
834
  }
799
835
 
800
- onMessage(c, text) {
836
+ onMessage(c, text, receivedAt) {
801
837
  if (this.clients.get(c.id) !== c) return;
802
838
  text = String(text);
803
839
  const now = this.now();
@@ -809,7 +845,7 @@ export class NetRoom {
809
845
  if (!m || typeof m.t !== 'string') return;
810
846
  if (!c.helloed) { if (m.t === 'hello' && bytes <= LIMITS.hello) this.hello(c, m); return; }
811
847
  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;
848
+ if (!this.allow(c, m.t, bytes, view, receivedAt, m.t === 'ev' ? String(m.k ?? '') : null)) return;
813
849
  const isHost = c.id === this.hostId && !view;
814
850
  switch (m.t) {
815
851
  case 'rules-end': {
@@ -1816,9 +1852,9 @@ export class NetRoom {
1816
1852
  }
1817
1853
 
1818
1854
  /** The seat a slot names (its own, or the one its agent sits in), and what holds it. */
1819
- holderOf(seat) {
1855
+ holderOf(seat, holders) {
1820
1856
  if (!Number.isInteger(seat)) return { kind: 'none' };
1821
- const c = this.live().find((o) => o.seat === seat);
1857
+ const c = holders ? holders.get(seat) : this.live().find((o) => o.seat === seat);
1822
1858
  const s = this.seats.get(seat);
1823
1859
  if (c && c.agent) return { kind: 'agent', agent: c.agent, name: c.name };
1824
1860
  if (c) return { kind: 'human', name: c.name };
@@ -1832,21 +1868,29 @@ export class NetRoom {
1832
1868
  * 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
1869
  * named with an AI mark. A host's game cannot present an AI as a person.
1834
1870
  */
1871
+ holderIndex() {
1872
+ const holders = new Map();
1873
+ for (const client of this.live()) if (!holders.has(client.seat)) holders.set(client.seat, client);
1874
+ return holders;
1875
+ }
1876
+
1835
1877
  labelRoster(slots) {
1836
- return slots.map((raw) => this.labelOne(raw, false)).filter(Boolean);
1878
+ const holders = this.holderIndex();
1879
+ return slots.map((raw) => this.labelOne(raw, false, holders)).filter(Boolean);
1837
1880
  }
1838
1881
 
1839
1882
  /** A round's results, labelled the same way (`agent: true` on every AI's row). */
1840
1883
  labelResults(rows) {
1841
- return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1884
+ const holders = this.holderIndex();
1885
+ return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true, holders)).filter(Boolean);
1842
1886
  }
1843
1887
 
1844
- labelOne(raw, result) {
1888
+ labelOne(raw, result, holders) {
1845
1889
  if (!raw || typeof raw !== 'object') return null;
1846
1890
  if (this.rules) {
1847
1891
  if (!Number.isInteger(raw.slot) || raw.slot < 0 || raw.slot >= this.seatCap) return null;
1848
1892
  raw = { ...raw, seat: raw.slot };
1849
- const holder = this.holderOf(raw.slot);
1893
+ const holder = this.holderOf(raw.slot, holders);
1850
1894
  if (holder.kind !== 'human' && holder.kind !== 'agent') {
1851
1895
  if (raw.slot >= this.maxPlayers - this.reserve())
1852
1896
  raw.agent = { seat: null, role: raw.slot >= this.maxPlayers - this.policy.guides ? 'guide' : 'party', hands: 'host' };
@@ -1856,7 +1900,7 @@ export class NetRoom {
1856
1900
  const out = { ...raw };
1857
1901
  const seat = Number.isInteger(raw.seat) ? raw.seat : null;
1858
1902
  const agentSeat = !result && raw.agent && Number.isInteger(raw.agent.seat) ? raw.agent.seat : null;
1859
- const who = this.holderOf(seat ?? agentSeat);
1903
+ const who = this.holderOf(seat ?? agentSeat, holders);
1860
1904
  const role = (a) => (['party', 'guide', 'player'].includes(a?.role) ? a.role : 'party');
1861
1905
  if (who.kind === 'agent') {
1862
1906
  out.name = who.name || aiName(raw.name);
@@ -2403,6 +2447,9 @@ export class NetRoom {
2403
2447
  const live = this.live();
2404
2448
  const agents = live.filter((c) => c.agent && c.seat !== null).length;
2405
2449
  return {
2450
+ transport: [...new Set(this.live().map(c => c.conn.transportFacts).filter(Boolean))].map(f => f()),
2451
+ toolkit: STUDIO_BUILD,
2452
+ gates: this.gateFacts?.() ?? [],
2406
2453
  t: 'net', v: NET_VERSION, rev: NET_REVISION, room: this.code, st: now,
2407
2454
  host: this.hostRef(),
2408
2455
  openedAt: this.openedAt || null,
@@ -2448,6 +2495,28 @@ export class NetRoom {
2448
2495
  };
2449
2496
  }
2450
2497
 
2498
+ /** What a measurement of a big room reads each second: its counts, clock and links, and nothing per player. */
2499
+ diagnosticFacts() {
2500
+ const now = this.now(), live = this.live();
2501
+ while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
2502
+ return {
2503
+ toolkit: STUDIO_BUILD, room: this.code, st: now,
2504
+ transport: [...new Set(live.map(c => c.conn.transportFacts).filter(Boolean))].map(f => f()),
2505
+ counts: {
2506
+ players: live.filter((c) => c.seat !== null && !c.agent).length,
2507
+ agents: live.filter((c) => c.agent && c.seat !== null).length,
2508
+ screens: live.filter((c) => c.seat === null).length,
2509
+ waiting: live.filter((c) => c.waiting).length,
2510
+ shells: this.watchers.size,
2511
+ maxPlayers: this.maxPlayers,
2512
+ },
2513
+ snapHz: this.snapTimes.length,
2514
+ ...(this.server ? { hosted: 'server', ticks: this.server.facts?.() ?? null } : {}),
2515
+ stats: { ins: this.stats.ins, snaps: this.stats.snaps, drops: this.stats.drops, oversize: this.stats.oversize },
2516
+ snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
2517
+ };
2518
+ }
2519
+
2451
2520
  tellWatchers() {
2452
2521
  if (!this.watchers.size) return;
2453
2522
  const text = JSON.stringify(this.facts());
@@ -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.46.0';
2
+ export const STUDIO_VERSION_TAG = '0.46.2';
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The world between the room and a Gate (0.46.2).
3
+ *
4
+ * The room used to send every Gate the whole world as JSON each tick: 150 KB for a thousand players, to sixteen
5
+ * Gates, each of which parsed it. Most of that text was six numbers a body (where it is, how fast, which way), and
6
+ * the rest (its fields, its seat) changes rarely. So the motion goes as one block of numbers, the same block for
7
+ * every Gate, and each Gate is told only the rest of a body when it has changed since that Gate last heard: about
8
+ * 33 KB a tick for a thousand players, and one string to read. The numbers are exact: 32-bit when every one of
9
+ * them is (a rules host's are), 64-bit when any is not. The block travels inside the frame, as base64: a frame
10
+ * of bytes beside the text would have to arrive just before it, and not every path between two objects keeps a
11
+ * binary frame and the text after it together (a local run of 300 players lost four links that way).
12
+ *
13
+ * `worldSource()` belongs to the room, one for all its Gate links. `worldSink()` belongs to a Gate.
14
+ */
15
+ const sameValue = (a, b) => {
16
+ if (a === b) return true;
17
+ if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (!sameValue(a[i], b[i])) return false; return true; }
18
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
19
+ return JSON.stringify(a) === JSON.stringify(b);
20
+ };
21
+ /** Everything of a body but its motion: the same unless a field, a revision, a seat or a driver changed. */
22
+ const sameTail = (a, b) => {
23
+ if (a.length !== b.length || a[1] !== b[1] || a[2] !== b[2] || a[6] !== b[6]) return false;
24
+ for (let i = 7; i < a.length; i++) if (!sameValue(a[i], b[i])) return false;
25
+ return true;
26
+ };
27
+ const base64 = (bytes) => { let text = ''; for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(text); };
28
+ const bytesOf = (text) => { const raw = atob(text), out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; };
29
+ const numbers = (v) => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
30
+
31
+ export function worldSource() {
32
+ const known = new Map();
33
+ let seq = 0, orderSeq = 0, restSeq = 0, ids = null, restText = null, last = null, lastOut = null;
34
+ return {
35
+ /** Once a world, whichever link asks first: the block of motion, and what changed since the world before. */
36
+ encode(snap) {
37
+ if (snap === last) return lastOut;
38
+ const entities = snap.d?.[1];
39
+ // A world this cannot carry exactly (no bodies array, a body without three vectors of one size) goes as it is.
40
+ const dims = entities?.[0]?.[3]?.length ?? 2;
41
+ if (!Array.isArray(entities) || !entities.every((e) => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string' && numbers(e[3]) && numbers(e[4]) && numbers(e[5]) && e[3].length === dims && e[4].length === dims && e[5].length === dims)) { last = snap; lastOut = null; return null; }
42
+ seq += 1;
43
+ const n = entities.length, width = dims * 3, wide = new Float64Array(n * width), order = new Array(n), changed = [];
44
+ let same = ids !== null && ids.length === n, narrow = true;
45
+ for (let i = 0; i < n; i++) {
46
+ const e = entities[i], id = e[0]; order[i] = id;
47
+ if (same && ids[i] !== id) same = false;
48
+ for (let a = 0; a < dims; a++) { wide[i * width + a] = e[3][a]; wide[i * width + dims + a] = e[4][a]; wide[i * width + 2 * dims + a] = e[5][a]; }
49
+ const held = known.get(id);
50
+ if (!held) { known.set(id, { row: e, changed: seq }); changed.push(i); }
51
+ else { if (held.row !== e && !sameTail(held.row, e)) { held.changed = seq; changed.push(i); } held.row = e; }
52
+ }
53
+ if (!same) {
54
+ orderSeq = seq;
55
+ if (known.size > n) { const live = new Set(order); for (const id of known.keys()) if (!live.has(id)) known.delete(id); }
56
+ }
57
+ ids = order;
58
+ for (let i = 0; i < wide.length; i++) if (Math.fround(wide[i]) !== wide[i]) { narrow = false; break; }
59
+ const text = snap.d.length > 2 ? JSON.stringify(snap.d.slice(2)) : '';
60
+ if (text !== restText) { restText = text; restSeq = seq; }
61
+ last = snap;
62
+ const motion = narrow ? Float32Array.from(wide).buffer : wide.buffer;
63
+ lastOut = { seq, n, dims, narrow, motion, block: base64(new Uint8Array(motion)), entities, changed, control: new Map((snap.c ?? []).map((row) => [row[0], row])), snap };
64
+ return lastOut;
65
+ },
66
+ /** What one Gate needs beside the block: the bodies whose rest changed since world `synced`, and its own players' control rows. */
67
+ header(world, synced, seats) {
68
+ const { snap, entities } = world, tails = [];
69
+ const tail = (i) => { const e = entities[i].slice(); e[3] = e[4] = e[5] = 0; tails.push([i, e]); };
70
+ if (synced === world.seq - 1) for (const i of world.changed) tail(i);
71
+ else for (let i = 0; i < entities.length; i++) if (known.get(entities[i][0]).changed > synced) tail(i);
72
+ const c = [];
73
+ for (const seat of seats) { const row = world.control.get(seat); if (row) c.push(row); }
74
+ return {
75
+ k: snap.k, st: snap.st, ...(snap.e !== undefined ? { e: snap.e } : {}), round: snap.d[0], c,
76
+ w: { seq: world.seq, n: world.n, dims: world.dims, narrow: world.narrow, ...(orderSeq > synced ? { ids } : {}), tails, ...(restSeq > synced ? { rest: snap.d.slice(2) } : {}) },
77
+ };
78
+ },
79
+ };
80
+ }
81
+
82
+ export function worldSink() {
83
+ let ids = [], tails = [], rest = [];
84
+ return {
85
+ /** The world a header and its block describe, as the room held it. Throws when they do not fit each other. */
86
+ decode(header, block64) {
87
+ const w = header.w;
88
+ if (w.ids) {
89
+ const held = new Map(ids.map((id, i) => [id, tails[i]]));
90
+ ids = w.ids; tails = ids.map((id) => held.get(id));
91
+ }
92
+ for (const [i, tail] of w.tails) tails[i] = tail;
93
+ if (w.rest) rest = w.rest;
94
+ const width = w.dims * 3, bytes = typeof block64 === 'string' ? bytesOf(block64) : null;
95
+ if (!bytes || ids.length !== w.n || bytes.length !== w.n * width * (w.narrow ? 4 : 8)) throw new Error(`world block does not fit its header (${bytes?.length} bytes for ${w.n} bodies, ${ids.length} known)`);
96
+ const block = bytes.buffer;
97
+ const a = w.narrow ? new Float32Array(block) : new Float64Array(block), entities = new Array(w.n), d = w.dims;
98
+ for (let i = 0; i < w.n; i++) {
99
+ const tail = tails[i];
100
+ if (!tail) throw new Error('world names a body this Gate was never told of');
101
+ const row = tail.slice(), o = i * width;
102
+ row[3] = d === 2 ? [a[o], a[o + 1]] : [a[o], a[o + 1], a[o + 2]];
103
+ row[4] = d === 2 ? [a[o + d], a[o + d + 1]] : [a[o + d], a[o + d + 1], a[o + d + 2]];
104
+ row[5] = d === 2 ? [a[o + 2 * d], a[o + 2 * d + 1]] : [a[o + 2 * d], a[o + 2 * d + 1], a[o + 2 * d + 2]];
105
+ entities[i] = row;
106
+ }
107
+ return { k: header.k, st: header.st, ...(header.e !== undefined ? { e: header.e } : {}), d: [header.round, entities, ...rest], c: header.c };
108
+ },
109
+ };
110
+ }