@homie-rocks/studio 0.45.3 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { characterStore, characterRequest } from './characters.mjs';
1
2
  export { useFunctions } from './functions.mjs';
2
3
  import { appAccess, appRecordsRoute } from './app-records.mjs';
3
4
  import { appRole, openPath } from './app-format.mjs';
@@ -88,7 +89,7 @@ import { appRole, openPath } from './app-format.mjs';
88
89
  */
89
90
  import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
90
91
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
91
- import { hostedGame, hostedBuild, startHost } from './hosted.mjs';
92
+ import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
92
93
  import { roomStore, restoreDelay } from './room-store.mjs';
93
94
  import { appCors, isAppOrigin } from './standalone.mjs';
94
95
  import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
@@ -104,7 +105,8 @@ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs'
104
105
  import { isLoungePath, loungeRoutes } from './lounge.mjs';
105
106
  import { doorPage, serverPage, serversPage } from './site.mjs';
106
107
  import { isLocalOrigin, qrSvg } from './qr.mjs';
107
- import { acceptMultiplex, gateFor } from './gate.mjs';
108
+ import { acceptMultiplex, diagnosticsOf, gateFor, nextGate } from './gate.mjs';
109
+ import { worldSource } from './world-wire.mjs';
108
110
  export { Gate, Concentrator } from './gate.mjs';
109
111
  import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
110
112
  import {
@@ -122,6 +124,7 @@ import {
122
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
123
125
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
124
126
  usePlayers(playerAccounts);
127
+ import { STUDIO_BUILD } from './build-id.mjs';
125
128
  import { STUDIO_VERSION_TAG } from './version.mjs';
126
129
  import { reconcileOrders, readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
127
130
  import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
@@ -711,6 +714,7 @@ async function route(request, env, ctx) {
711
714
  return json({
712
715
  v: 1,
713
716
  kind: 'homie-studio',
717
+ toolkit: STUDIO_BUILD,
714
718
  name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
715
719
  slug: cat.studio?.slug ?? null,
716
720
  ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
@@ -1024,6 +1028,7 @@ async function route(request, env, ctx) {
1024
1028
  srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
1025
1029
  }
1026
1030
  const d = await pageDoor(srv);
1031
+ if (meta.room?.characters && !screen && !embed && (!d.acct || !(await chatWho(env, game, srv, d.acct)).acct)) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 303);
1027
1032
  // The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
1028
1033
  if (srv.id === 'public' && publicHidden() && !d.owner) {
1029
1034
  if (named) return doorPage(cat, meta, srv, { why: 'closed' });
@@ -1112,6 +1117,11 @@ async function route(request, env, ctx) {
1112
1117
  // Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
1113
1118
  const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
1114
1119
  if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
1120
+ if (meta.room?.lifetime === 'persistent') {
1121
+ const room = srv.id === 'public' ? 'pub-1' : `s-${srv.id}-1`;
1122
+ if (not.split(',').includes(room)) return json({ ok: false, error: 'world-unavailable', message: 'This world is unavailable. Choose another named world.' }, 409);
1123
+ return json({ room, players: 0, max: humanSeats(pol), server: srv.id });
1124
+ }
1115
1125
  return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
1116
1126
  }
1117
1127
  if (sub === 'api/agent') {
@@ -1318,6 +1328,12 @@ export class Table {
1318
1328
  * room next needs the object's one alarm (the house guides and the day's counters share it).
1319
1329
  */
1320
1330
  this.hostRt = null;
1331
+ this.characterGrants = new Map();
1332
+ this.accountTokens = {};
1333
+ this.inputTransit = [];
1334
+ this.relayed = false;
1335
+ this.gateHeld = new Map();
1336
+ this.gatePromised = new Map();
1321
1337
  this.roomAlarmAt = 0;
1322
1338
  this.durable = null;
1323
1339
  this.roomSave = null;
@@ -1345,7 +1361,21 @@ export class Table {
1345
1361
  maxPlayers: max,
1346
1362
  perIp: perAddress(max),
1347
1363
  store: {
1348
- 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
+ },
1349
1379
  clear: () => { this.clearRoom(); },
1350
1380
  },
1351
1381
  // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
@@ -1353,6 +1383,7 @@ export class Table {
1353
1383
  // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
1354
1384
  log: (line) => this.say(line),
1355
1385
  });
1386
+ this.room.gateFacts = () => [...(this.gateMetrics?.values() ?? [])];
1356
1387
  if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
1357
1388
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1358
1389
  this.vocabRead = this.readVocab(game).catch(() => false);
@@ -1366,6 +1397,7 @@ export class Table {
1366
1397
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1367
1398
  // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
1368
1399
  this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
1400
+ if (this.isPersistent()) this.room.forgetMs = Infinity;
1369
1401
  return this.room;
1370
1402
  }
1371
1403
 
@@ -1376,6 +1408,8 @@ export class Table {
1376
1408
  * elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
1377
1409
  * this build, or do not fit the contract, do not start: the room refuses joins and says why.
1378
1410
  */
1411
+ isPersistent() { return this.persistentStored === true || hostedBuild(this.game)?.settings?.lifetime === 'persistent'; }
1412
+
1379
1413
  ensureHost(room, hosted) {
1380
1414
  if (this.hostRt || !this.game) return;
1381
1415
  if (!hostedGame(this.game)) {
@@ -1386,9 +1420,11 @@ export class Table {
1386
1420
  let pauseWriteFailed = false;
1387
1421
  try {
1388
1422
  const build = hostedBuild(this.game);
1389
- try { this.durable ??= roomStore(this.ctx.storage); } catch (error) { this.storageProblem(error); }
1423
+ const persistent = this.isPersistent();
1424
+ try { this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() }); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
1390
1425
  let saved = this.cleanupPending ? null : this.readRoom();
1391
1426
  restoring = Boolean(saved);
1427
+ if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
1392
1428
  let boot = { epoch: 0, count: 0 };
1393
1429
  try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
1394
1430
  if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
@@ -1402,7 +1438,8 @@ export class Table {
1402
1438
  const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
1403
1439
  const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
1404
1440
  const expired = Date.now() >= restoredEnd;
1405
- if (saved && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
1441
+ if (saved && persistent && saved.stateHash !== build.stateHash) throw new Error('This lasting world needs its previous state schema. Restore the compatible build; the saved world has been retained.');
1442
+ if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
1406
1443
  const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
1407
1444
  const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
1408
1445
  this.finishRoom(why);
@@ -1412,15 +1449,16 @@ export class Table {
1412
1449
  this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
1413
1450
  }
1414
1451
  this.roomSave = saved;
1452
+ this.accountTokens = saved?.accountTokens ?? {};
1415
1453
  if (saved) {
1416
1454
  // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
1417
1455
  room.seats.clear(); room.state.clear(); room.stateBytes = 0;
1418
1456
  room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
1419
- this.pausedAt = restoredEnd - ROOM_PAUSE_MS;
1457
+ this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
1420
1458
  this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
1421
1459
  saved.pausedAt = this.pausedAt;
1422
1460
  // A failed pause write must never be mistaken for an invalid host save.
1423
- try { this.durable.write(saved); } catch (error) { pauseWriteFailed = true; this.storageProblem(error); }
1461
+ try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
1424
1462
  }
1425
1463
  const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
1426
1464
  if (saved) this.durable.boot({ epoch, count: boot.count });
@@ -1429,7 +1467,11 @@ export class Table {
1429
1467
  this.hostRt = startHost(this.game, {
1430
1468
  ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
1431
1469
  record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
1432
- store: { save: (bytes) => this.saveRoom(bytes) },
1470
+ store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment) },
1471
+ ...(build.settings.characters ? { characterFor: (peer) => {
1472
+ const account = room.clients.get(peer.id)?.player;
1473
+ return account ? this.characterGrants.get(account) ?? null : null;
1474
+ } } : {}),
1433
1475
  onTick: () => {
1434
1476
  if (!saved) return;
1435
1477
  ticks += 1;
@@ -1454,7 +1496,7 @@ export class Table {
1454
1496
  this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
1455
1497
  } catch (error) {
1456
1498
  this.hostRt?.stop(); this.hostRt = null;
1457
- if (restoring) {
1499
+ if (restoring && !this.isPersistent()) {
1458
1500
  this.storageProblem(error);
1459
1501
  room.forget(); this.clearRoom();
1460
1502
  this.skipSave = true;
@@ -1465,19 +1507,35 @@ export class Table {
1465
1507
  }
1466
1508
  }
1467
1509
 
1468
- saveRoom(bytes) {
1510
+ saveRoom(bytes, commitment) {
1511
+ if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
1469
1512
  const now = Date.now();
1470
1513
  if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
1471
1514
  try {
1472
- this.durable ??= roomStore(this.ctx.storage);
1515
+ this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() });
1473
1516
  const build = hostedBuild(this.game);
1474
1517
  const host = new TextDecoder().decode(bytes);
1475
- const saved = { v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net: this.room.saved() };
1518
+ const net = this.room.saved();
1519
+ if (build.settings.characters) for (const client of this.room.clients.values()) {
1520
+ if (client.player && client.seat !== null && client.token) this.accountTokens[client.player] = client.token;
1521
+ }
1522
+ if (build.settings.characters) {
1523
+ const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
1524
+ for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
1525
+ }
1526
+ const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net };
1476
1527
  const clearing = this.cleanupPending;
1477
1528
  if (clearing) this.durable.clear();
1478
- this.durable.write(saved);
1529
+ if (this.isPersistent()) {
1530
+ const c = compiledFor(this.game);
1531
+ commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
1532
+ fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
1533
+ this.durable.commit(saved, commitment);
1534
+ } else this.durable.write(saved);
1479
1535
  if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
1480
- this.roomSave = saved; this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
1536
+ this.roomSave = saved;
1537
+ if (build.settings.characters) this.ctx.waitUntil(this.flushCharacters().catch(error => this.storageProblem(error)));
1538
+ this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
1481
1539
  this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
1482
1540
  clearTimeout(this.saveRetry); this.saveRetry = null;
1483
1541
  this.storageHealth = { ok: !this.pendingBoot };
@@ -1486,7 +1544,8 @@ export class Table {
1486
1544
  const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
1487
1545
  this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
1488
1546
  this.storageProblem(error);
1489
- if (!this.saveRetry) {
1547
+ if (this.isPersistent()) this.endHosted('storage-failed');
1548
+ if (!this.isPersistent() && !this.saveRetry) {
1490
1549
  this.saveRetry = setTimeout(() => {
1491
1550
  this.saveRetry = null;
1492
1551
  if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
@@ -1507,7 +1566,9 @@ export class Table {
1507
1566
  readRoom(validateHost = false) {
1508
1567
  if (this.skipSave) { this.skipSave = false; return null; }
1509
1568
  try {
1510
- if (this.durable?.boots()?.retired === true) { this.clearRoom(); return null; }
1569
+ const boot = this.durable?.boots();
1570
+ this.persistentStored = boot?.persistent === true;
1571
+ if (boot?.retired === true) { this.clearRoom(); return null; }
1511
1572
  const saved = this.durable?.read();
1512
1573
  if (!saved) return null;
1513
1574
  if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
@@ -1529,10 +1590,11 @@ export class Table {
1529
1590
  probe.stop();
1530
1591
  } else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
1531
1592
  return saved;
1532
- } catch (error) { this.storageProblem(error); this.clearRoom(); return null; }
1593
+ } catch (error) { this.storageProblem(error); if (this.isPersistent()) throw error; this.clearRoom(); return null; }
1533
1594
  }
1534
1595
 
1535
1596
  clearRoom() {
1597
+ if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
1536
1598
  this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
1537
1599
  this.roomSave = null; this.saved = null; this.pendingBoot = null;
1538
1600
  try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
@@ -1581,6 +1643,7 @@ export class Table {
1581
1643
  }
1582
1644
 
1583
1645
  finishRoom(why) {
1646
+ if (this.isPersistent()) { this.hostRt?.stop(); this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`); return; }
1584
1647
  this.hostRt?.stop(); this.hostRt = null;
1585
1648
  clearInterval(this.timer); this.timer = null;
1586
1649
  clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
@@ -1599,6 +1662,7 @@ export class Table {
1599
1662
 
1600
1663
  /** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
1601
1664
  armRoom(at) {
1665
+ if (this.isPersistent()) return;
1602
1666
  this.roomAlarmAt = at;
1603
1667
  this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
1604
1668
  const cur = await this.ctx.storage.getAlarm();
@@ -1620,6 +1684,14 @@ export class Table {
1620
1684
  * five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
1621
1685
  */
1622
1686
  endHosted(why) {
1687
+ if (this.isPersistent()) {
1688
+ this.persistentFailed = true;
1689
+ // The host's own stop would checkpoint a healthy world; this one is refused by `persistentFailed`, and the committed save stands.
1690
+ try { this.hostRt?.stop(); } catch { /* stopped all the same */ }
1691
+ clearTimeout(this.saveRetry); this.saveRetry = null;
1692
+ this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`);
1693
+ return;
1694
+ }
1623
1695
  const room = this.room;
1624
1696
  if (!room) return;
1625
1697
  this.finishRoom(why);
@@ -1661,6 +1733,7 @@ export class Table {
1661
1733
  const saved = this.cleanupPending ? null : this.readRoom(true);
1662
1734
  if (saved) {
1663
1735
  this.game = saved.game; this.code = saved.room;
1736
+ if (this.isPersistent()) return;
1664
1737
  if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
1665
1738
  // No saved pause means people were present. The first cold wake starts the absence period.
1666
1739
  if (saved.pausedAt === null) {
@@ -1675,6 +1748,7 @@ export class Table {
1675
1748
  return;
1676
1749
  }
1677
1750
  if (!this.hostRt) return;
1751
+ if (this.isPersistent()) { await this.flushCharacters(); return; }
1678
1752
  const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
1679
1753
  if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
1680
1754
  if (now < endAt) { this.armRoom(endAt); return; }
@@ -1963,9 +2037,83 @@ export class Table {
1963
2037
  try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
1964
2038
  }
1965
2039
 
2040
+ async flushCharacters() {
2041
+ if (this.characterFlush) { await this.characterFlush; return; }
2042
+ const saved = this.roomSave;
2043
+ if (!saved) return;
2044
+ const host = typeof saved.host === 'string' ? JSON.parse(saved.host) : saved.host;
2045
+ const returns = (host.characters ?? []).filter(c => c.returning);
2046
+ if (!returns.length) return;
2047
+ this.characterFlush = (async () => {
2048
+ for (const item of returns) {
2049
+ await characterRequest(this.env, item.account, { op: 'return', game: this.game, holder: this.code, lease: item.lease, character: item.character });
2050
+ this.hostRt?.characterReturned(item.account, item.lease);
2051
+ if (this.characterGrants.get(item.account)?.lease === item.lease) this.characterGrants.delete(item.account);
2052
+ }
2053
+ })();
2054
+ try { await this.characterFlush; } finally { this.characterFlush = null; }
2055
+ }
2056
+
2057
+ // Network awaits may interleave another join or recall. Serialize each account
2058
+ // within this room so a delayed acquisition cannot revive a returned lease.
2059
+ async withCharacter(account, operation) {
2060
+ this.characterOperations ??= new Map();
2061
+ const previous = this.characterOperations.get(account) ?? Promise.resolve();
2062
+ const current = previous.catch(() => {}).then(operation);
2063
+ this.characterOperations.set(account, current);
2064
+ try { return await current; }
2065
+ finally { if (this.characterOperations.get(account) === current) this.characterOperations.delete(account); }
2066
+ }
2067
+
2068
+ acquireCharacter(account) { return this.withCharacter(account, () => this.acquireCharacterOnce(account)); }
2069
+
2070
+ async acquireCharacterOnce(account) {
2071
+ for (const c of [...this.room.clients.values()]) if (c.player === account && c.helloed) this.room.kick(c, 'character-moved', 4001);
2072
+ await this.flushCharacters();
2073
+ const body = { op: 'acquire', game: this.game, holder: this.code, schema: hostedBuild(this.game).stateHash };
2074
+ let grant = await characterRequest(this.env, account, body);
2075
+ if (grant.error === 'character-held') {
2076
+ const old = this.env.TABLE.get(this.env.TABLE.idFromName(`${this.game}/${grant.holder}`));
2077
+ const url = `https://room/__recall?game=${encodeURIComponent(this.game)}&room=${encodeURIComponent(grant.holder)}`;
2078
+ const response = await old.fetch(url, { method: 'POST', body: JSON.stringify({ account }) });
2079
+ if (!response.ok) throw new Error('Finishing your last session; its room is unavailable');
2080
+ grant = await characterRequest(this.env, account, body);
2081
+ }
2082
+ if (!grant.ok) throw new Error(grant.error);
2083
+ const held = { account, lease: grant.lease, seat: -1, character: grant.character };
2084
+ this.characterGrants.set(account, held);
2085
+ this.hostRt.reserveCharacter(held);
2086
+ this.saveRoom(this.hostRt.save());
2087
+ }
2088
+
1966
2089
  async fetch(request) {
1967
- if (new URL(request.url).pathname === '/__multiplex') {
1968
- return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
2090
+ if (new URL(request.url).pathname === '/__character') {
2091
+ const body = await request.json();
2092
+ this.characters ??= characterStore(this.ctx.storage);
2093
+ if (body.op === 'acquire') return json(this.characters.acquire(body.game, body.holder, body.schema));
2094
+ if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
2095
+ return json({ ok: false }, 400);
2096
+ }
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
+ } });
1969
2117
  }
1970
2118
  return this.connect(request);
1971
2119
  }
@@ -1982,9 +2130,26 @@ export class Table {
1982
2130
  return json({ ok: true });
1983
2131
  }
1984
2132
  const room = this.roomFor(game, code, max);
2133
+ if (url.pathname === '/__recall') {
2134
+ const { account } = await request.json();
2135
+ return this.withCharacter(account, async () => {
2136
+ this.ensureHost(room, true);
2137
+ if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
2138
+ await this.flushCharacters();
2139
+ if (!this.hostRt.characters.has(account)) {
2140
+ const grant = await characterRequest(this.env, account, { op: 'acquire', game, holder: code, schema: hostedBuild(game).stateHash });
2141
+ if (!grant.ok) return json({ ok: true });
2142
+ this.hostRt.reserveCharacter({ account, lease: grant.lease, seat: -1, character: grant.character });
2143
+ }
2144
+ this.hostRt.releaseCharacter(account);
2145
+ for (const c of [...room.clients.values()]) if (c.player === account) room.kick(c, 'character-moved', 4001);
2146
+ await this.flushCharacters();
2147
+ return json({ ok: true });
2148
+ });
2149
+ }
1985
2150
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
1986
2151
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
1987
- 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() } : {}) });
1988
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.
1989
2154
  if (url.pathname === '/__chat') {
1990
2155
  const id = url.searchParams.get('id');
@@ -2044,6 +2209,12 @@ export class Table {
2044
2209
  const agent = decodeFacts(url.searchParams.get('ag'));
2045
2210
  // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
2046
2211
  await this.hydrateHistory(room);
2212
+ const account = holders(url.searchParams.get('via')).find(p => p.startsWith('p-'))?.slice(2) ?? null;
2213
+ if (hostedBuild(game)?.settings.characters && url.pathname !== '/__watch') {
2214
+ if (!account || url.searchParams.get('acct') !== '1') return new Response('Sign in to keep your character', { status: 401 });
2215
+ try { await this.acquireCharacter(account); }
2216
+ catch (error) { return new Response(String(error.message), { status: 409 }); }
2217
+ }
2047
2218
  const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
2048
2219
  server.accept();
2049
2220
  const via = url.searchParams.get('via');
@@ -2075,7 +2246,9 @@ export class Table {
2075
2246
  ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
2076
2247
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
2077
2248
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
2078
- 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 } : {}),
2079
2252
  };
2080
2253
  if (url.pathname === '/__watch') {
2081
2254
  // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
@@ -2096,9 +2269,18 @@ export class Table {
2096
2269
  // seats, so it is not counted twice). One counter write per room.
2097
2270
  const fresh = room.seats.size === 0;
2098
2271
  server.addEventListener('message', (e) => {
2099
- h.onMessage(typeof e.data === 'string' ? e.data : '');
2272
+ this.relayFrame = Boolean(server.snapshot);
2273
+ try {
2274
+ let text = typeof e.data === 'string' ? e.data : '';
2275
+ if (hostedBuild(game)?.settings.characters && account) {
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 */ }
2277
+ }
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);
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; }
2100
2282
  // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
2101
- 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();
2102
2284
  if (fresh && !this.openCounted && room.seats.size > 0) {
2103
2285
  this.openCounted = true;
2104
2286
  if (conn.qa) return;
@@ -2114,7 +2296,7 @@ export class Table {
2114
2296
  }
2115
2297
  // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
2116
2298
  h.onClose(via);
2117
- 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) }); }
2118
2300
  };
2119
2301
  server.addEventListener('close', () => left('close'));
2120
2302
  // A lost network arrives here, not as a close: said as what it is, with the client it was, then handled as the
@@ -2143,7 +2325,7 @@ export class Table {
2143
2325
  // A launch change the room applied by itself (after its round): its stored state says so too.
2144
2326
  if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
2145
2327
  n += 1;
2146
- 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(); }
2147
2329
  // Room chat's counts, once a minute (section 19).
2148
2330
  if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
2149
2331
  // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
@@ -2178,6 +2360,40 @@ export class Table {
2178
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(() => {}));
2179
2361
  }
2180
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
+
2181
2397
  /** Tell the Lobby how many players this room has, when it changes. */
2182
2398
  report() {
2183
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);