@homie-rocks/studio 0.45.2 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -1024,12 +1025,18 @@ async function route(request, env, ctx) {
1024
1025
  srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
1025
1026
  }
1026
1027
  const d = await pageDoor(srv);
1028
+ 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
1029
  // The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
1028
1030
  if (srv.id === 'public' && publicHidden() && !d.owner) {
1029
1031
  if (named) return doorPage(cat, meta, srv, { why: 'closed' });
1030
1032
  return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
1031
1033
  }
1032
1034
  if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, days: d.days, origin: url.origin, next: `${url.pathname}${url.search}`, code: url.searchParams.get('invite') ?? '' });
1035
+ let recordsGuest=null;
1036
+ if(meta.room?.records&&!embed&&!screen&&!d.acct&&env.DB){
1037
+ const made=await playerAccounts.recordsGuest(request,env,url);if(made.error)return made.error;
1038
+ recordsGuest=made.session;d.acct='p-'+recordsGuest.player.id;d.holder=joinHolders(d.holder,d.acct);
1039
+ }
1033
1040
  if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
1034
1041
  const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
1035
1042
  const pol = policyFor(srv, named);
@@ -1054,7 +1061,8 @@ async function route(request, env, ctx) {
1054
1061
  }
1055
1062
  await countVisit(request, env, ctx, game, 'play');
1056
1063
  shareDaily(cat, url, ctx);
1057
- return playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
1064
+ const page=playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
1065
+ for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
1058
1066
  }
1059
1067
  if (sub === 'watch') {
1060
1068
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
@@ -1106,6 +1114,11 @@ async function route(request, env, ctx) {
1106
1114
  // Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
1107
1115
  const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
1108
1116
  if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
1117
+ if (meta.room?.lifetime === 'persistent') {
1118
+ const room = srv.id === 'public' ? 'pub-1' : `s-${srv.id}-1`;
1119
+ if (not.split(',').includes(room)) return json({ ok: false, error: 'world-unavailable', message: 'This world is unavailable. Choose another named world.' }, 409);
1120
+ return json({ room, players: 0, max: humanSeats(pol), server: srv.id });
1121
+ }
1109
1122
  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' });
1110
1123
  }
1111
1124
  if (sub === 'api/agent') {
@@ -1312,6 +1325,8 @@ export class Table {
1312
1325
  * room next needs the object's one alarm (the house guides and the day's counters share it).
1313
1326
  */
1314
1327
  this.hostRt = null;
1328
+ this.characterGrants = new Map();
1329
+ this.accountTokens = {};
1315
1330
  this.roomAlarmAt = 0;
1316
1331
  this.durable = null;
1317
1332
  this.roomSave = null;
@@ -1360,6 +1375,7 @@ export class Table {
1360
1375
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1361
1376
  // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
1362
1377
  this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
1378
+ if (this.isPersistent()) this.room.forgetMs = Infinity;
1363
1379
  return this.room;
1364
1380
  }
1365
1381
 
@@ -1370,6 +1386,8 @@ export class Table {
1370
1386
  * elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
1371
1387
  * this build, or do not fit the contract, do not start: the room refuses joins and says why.
1372
1388
  */
1389
+ isPersistent() { return this.persistentStored === true || hostedBuild(this.game)?.settings?.lifetime === 'persistent'; }
1390
+
1373
1391
  ensureHost(room, hosted) {
1374
1392
  if (this.hostRt || !this.game) return;
1375
1393
  if (!hostedGame(this.game)) {
@@ -1380,9 +1398,11 @@ export class Table {
1380
1398
  let pauseWriteFailed = false;
1381
1399
  try {
1382
1400
  const build = hostedBuild(this.game);
1383
- try { this.durable ??= roomStore(this.ctx.storage); } catch (error) { this.storageProblem(error); }
1401
+ const persistent = this.isPersistent();
1402
+ try { this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() }); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
1384
1403
  let saved = this.cleanupPending ? null : this.readRoom();
1385
1404
  restoring = Boolean(saved);
1405
+ if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
1386
1406
  let boot = { epoch: 0, count: 0 };
1387
1407
  try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
1388
1408
  if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
@@ -1396,7 +1416,8 @@ export class Table {
1396
1416
  const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
1397
1417
  const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
1398
1418
  const expired = Date.now() >= restoredEnd;
1399
- if (saved && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
1419
+ 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.');
1420
+ if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
1400
1421
  const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
1401
1422
  const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
1402
1423
  this.finishRoom(why);
@@ -1406,15 +1427,16 @@ export class Table {
1406
1427
  this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
1407
1428
  }
1408
1429
  this.roomSave = saved;
1430
+ this.accountTokens = saved?.accountTokens ?? {};
1409
1431
  if (saved) {
1410
1432
  // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
1411
1433
  room.seats.clear(); room.state.clear(); room.stateBytes = 0;
1412
1434
  room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
1413
- this.pausedAt = restoredEnd - ROOM_PAUSE_MS;
1435
+ this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
1414
1436
  this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
1415
1437
  saved.pausedAt = this.pausedAt;
1416
1438
  // A failed pause write must never be mistaken for an invalid host save.
1417
- try { this.durable.write(saved); } catch (error) { pauseWriteFailed = true; this.storageProblem(error); }
1439
+ try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
1418
1440
  }
1419
1441
  const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
1420
1442
  if (saved) this.durable.boot({ epoch, count: boot.count });
@@ -1422,7 +1444,12 @@ export class Table {
1422
1444
  let ticks = 0;
1423
1445
  this.hostRt = startHost(this.game, {
1424
1446
  ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
1425
- store: { save: (bytes) => this.saveRoom(bytes) },
1447
+ record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
1448
+ store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment) },
1449
+ ...(build.settings.characters ? { characterFor: (peer) => {
1450
+ const account = room.clients.get(peer.id)?.player;
1451
+ return account ? this.characterGrants.get(account) ?? null : null;
1452
+ } } : {}),
1426
1453
  onTick: () => {
1427
1454
  if (!saved) return;
1428
1455
  ticks += 1;
@@ -1447,7 +1474,7 @@ export class Table {
1447
1474
  this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
1448
1475
  } catch (error) {
1449
1476
  this.hostRt?.stop(); this.hostRt = null;
1450
- if (restoring) {
1477
+ if (restoring && !this.isPersistent()) {
1451
1478
  this.storageProblem(error);
1452
1479
  room.forget(); this.clearRoom();
1453
1480
  this.skipSave = true;
@@ -1458,19 +1485,35 @@ export class Table {
1458
1485
  }
1459
1486
  }
1460
1487
 
1461
- saveRoom(bytes) {
1488
+ saveRoom(bytes, commitment) {
1489
+ if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
1462
1490
  const now = Date.now();
1463
1491
  if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
1464
1492
  try {
1465
- this.durable ??= roomStore(this.ctx.storage);
1493
+ this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() });
1466
1494
  const build = hostedBuild(this.game);
1467
1495
  const host = new TextDecoder().decode(bytes);
1468
- 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() };
1496
+ const net = this.room.saved();
1497
+ if (build.settings.characters) for (const client of this.room.clients.values()) {
1498
+ if (client.player && client.seat !== null && client.token) this.accountTokens[client.player] = client.token;
1499
+ }
1500
+ if (build.settings.characters) {
1501
+ const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
1502
+ for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
1503
+ }
1504
+ 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 };
1469
1505
  const clearing = this.cleanupPending;
1470
1506
  if (clearing) this.durable.clear();
1471
- this.durable.write(saved);
1507
+ if (this.isPersistent()) {
1508
+ const c = compiledFor(this.game);
1509
+ commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
1510
+ fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
1511
+ this.durable.commit(saved, commitment);
1512
+ } else this.durable.write(saved);
1472
1513
  if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
1473
- this.roomSave = saved; this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
1514
+ this.roomSave = saved;
1515
+ if (build.settings.characters) this.ctx.waitUntil(this.flushCharacters().catch(error => this.storageProblem(error)));
1516
+ this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
1474
1517
  this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
1475
1518
  clearTimeout(this.saveRetry); this.saveRetry = null;
1476
1519
  this.storageHealth = { ok: !this.pendingBoot };
@@ -1479,7 +1522,8 @@ export class Table {
1479
1522
  const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
1480
1523
  this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
1481
1524
  this.storageProblem(error);
1482
- if (!this.saveRetry) {
1525
+ if (this.isPersistent()) this.endHosted('storage-failed');
1526
+ if (!this.isPersistent() && !this.saveRetry) {
1483
1527
  this.saveRetry = setTimeout(() => {
1484
1528
  this.saveRetry = null;
1485
1529
  if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
@@ -1500,7 +1544,9 @@ export class Table {
1500
1544
  readRoom(validateHost = false) {
1501
1545
  if (this.skipSave) { this.skipSave = false; return null; }
1502
1546
  try {
1503
- if (this.durable?.boots()?.retired === true) { this.clearRoom(); return null; }
1547
+ const boot = this.durable?.boots();
1548
+ this.persistentStored = boot?.persistent === true;
1549
+ if (boot?.retired === true) { this.clearRoom(); return null; }
1504
1550
  const saved = this.durable?.read();
1505
1551
  if (!saved) return null;
1506
1552
  if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
@@ -1522,10 +1568,11 @@ export class Table {
1522
1568
  probe.stop();
1523
1569
  } else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
1524
1570
  return saved;
1525
- } catch (error) { this.storageProblem(error); this.clearRoom(); return null; }
1571
+ } catch (error) { this.storageProblem(error); if (this.isPersistent()) throw error; this.clearRoom(); return null; }
1526
1572
  }
1527
1573
 
1528
1574
  clearRoom() {
1575
+ if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
1529
1576
  this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
1530
1577
  this.roomSave = null; this.saved = null; this.pendingBoot = null;
1531
1578
  try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
@@ -1574,6 +1621,7 @@ export class Table {
1574
1621
  }
1575
1622
 
1576
1623
  finishRoom(why) {
1624
+ if (this.isPersistent()) { this.hostRt?.stop(); this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`); return; }
1577
1625
  this.hostRt?.stop(); this.hostRt = null;
1578
1626
  clearInterval(this.timer); this.timer = null;
1579
1627
  clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
@@ -1592,6 +1640,7 @@ export class Table {
1592
1640
 
1593
1641
  /** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
1594
1642
  armRoom(at) {
1643
+ if (this.isPersistent()) return;
1595
1644
  this.roomAlarmAt = at;
1596
1645
  this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
1597
1646
  const cur = await this.ctx.storage.getAlarm();
@@ -1613,6 +1662,14 @@ export class Table {
1613
1662
  * five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
1614
1663
  */
1615
1664
  endHosted(why) {
1665
+ if (this.isPersistent()) {
1666
+ this.persistentFailed = true;
1667
+ // The host's own stop would checkpoint a healthy world; this one is refused by `persistentFailed`, and the committed save stands.
1668
+ try { this.hostRt?.stop(); } catch { /* stopped all the same */ }
1669
+ clearTimeout(this.saveRetry); this.saveRetry = null;
1670
+ this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`);
1671
+ return;
1672
+ }
1616
1673
  const room = this.room;
1617
1674
  if (!room) return;
1618
1675
  this.finishRoom(why);
@@ -1654,6 +1711,7 @@ export class Table {
1654
1711
  const saved = this.cleanupPending ? null : this.readRoom(true);
1655
1712
  if (saved) {
1656
1713
  this.game = saved.game; this.code = saved.room;
1714
+ if (this.isPersistent()) return;
1657
1715
  if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
1658
1716
  // No saved pause means people were present. The first cold wake starts the absence period.
1659
1717
  if (saved.pausedAt === null) {
@@ -1668,6 +1726,7 @@ export class Table {
1668
1726
  return;
1669
1727
  }
1670
1728
  if (!this.hostRt) return;
1729
+ if (this.isPersistent()) { await this.flushCharacters(); return; }
1671
1730
  const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
1672
1731
  if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
1673
1732
  if (now < endAt) { this.armRoom(endAt); return; }
@@ -1683,12 +1742,32 @@ export class Table {
1683
1742
  * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
1684
1743
  */
1685
1744
  say(line) {
1745
+ if(line?.ev==='hello'&&line.seat!==null&&this.ctx.storage.sql){const c=this.room?.clients.get(line.id),owner='p'+this.room?.seats.get(line.seat)?.occ;if(c?.player){this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');this.ctx.storage.sql.exec('INSERT OR REPLACE INTO round_players VALUES(?,?)',owner,c.player);}}
1686
1746
  if(line?.ev==='hello'&&!line.resumed&&line.seat!==null) this.queueFunctionEvent('player.joined',{game:this.game,room:line.room,player:this.room?.clients.get(line.id)?.conn?.player??null,seat:line.seat});
1687
1747
  const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
1688
1748
  if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
1689
1749
  try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
1690
1750
  }
1691
1751
 
1752
+ queueRoundRecord(owner,key,value) {
1753
+ const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
1754
+ sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
1755
+ const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
1756
+ const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
1757
+ if(!player)return;
1758
+ sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
1759
+ sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
1760
+ this.ctx.waitUntil(this.flushRoundRecords());
1761
+ }
1762
+ async flushRoundRecords(){
1763
+ const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
1764
+ if(!sql.exec("SELECT name FROM sqlite_master WHERE name='round_outbox'").toArray().length)return;
1765
+ const {appendServerRecord}=await import('./saves.mjs');
1766
+ for(const row of sql.exec('SELECT * FROM round_outbox LIMIT 100').toArray()){
1767
+ try{await appendServerRecord(this.env,row.player,this.game,row.key,JSON.parse(row.value));sql.exec('DELETE FROM round_outbox WHERE id=?',row.id);}
1768
+ catch(error){this.say({ev:'record-retry',error:String(error)});this.armRoom(Date.now()+60000);return;}
1769
+ }
1770
+ }
1692
1771
  queueFunctionEvent(type,data) {
1693
1772
  if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
1694
1773
  this.ctx.waitUntil((async()=>{
@@ -1928,6 +2007,7 @@ export class Table {
1928
2007
 
1929
2008
  /** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
1930
2009
  async alarm() {
2010
+ await this.flushRoundRecords();
1931
2011
  await this.flushFunctionEvents();
1932
2012
  // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
1933
2013
  try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
@@ -1935,7 +2015,63 @@ export class Table {
1935
2015
  try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
1936
2016
  }
1937
2017
 
2018
+ async flushCharacters() {
2019
+ if (this.characterFlush) { await this.characterFlush; return; }
2020
+ const saved = this.roomSave;
2021
+ if (!saved) return;
2022
+ const host = typeof saved.host === 'string' ? JSON.parse(saved.host) : saved.host;
2023
+ const returns = (host.characters ?? []).filter(c => c.returning);
2024
+ if (!returns.length) return;
2025
+ this.characterFlush = (async () => {
2026
+ for (const item of returns) {
2027
+ await characterRequest(this.env, item.account, { op: 'return', game: this.game, holder: this.code, lease: item.lease, character: item.character });
2028
+ this.hostRt?.characterReturned(item.account, item.lease);
2029
+ if (this.characterGrants.get(item.account)?.lease === item.lease) this.characterGrants.delete(item.account);
2030
+ }
2031
+ })();
2032
+ try { await this.characterFlush; } finally { this.characterFlush = null; }
2033
+ }
2034
+
2035
+ // Network awaits may interleave another join or recall. Serialize each account
2036
+ // within this room so a delayed acquisition cannot revive a returned lease.
2037
+ async withCharacter(account, operation) {
2038
+ this.characterOperations ??= new Map();
2039
+ const previous = this.characterOperations.get(account) ?? Promise.resolve();
2040
+ const current = previous.catch(() => {}).then(operation);
2041
+ this.characterOperations.set(account, current);
2042
+ try { return await current; }
2043
+ finally { if (this.characterOperations.get(account) === current) this.characterOperations.delete(account); }
2044
+ }
2045
+
2046
+ acquireCharacter(account) { return this.withCharacter(account, () => this.acquireCharacterOnce(account)); }
2047
+
2048
+ async acquireCharacterOnce(account) {
2049
+ for (const c of [...this.room.clients.values()]) if (c.player === account && c.helloed) this.room.kick(c, 'character-moved', 4001);
2050
+ await this.flushCharacters();
2051
+ const body = { op: 'acquire', game: this.game, holder: this.code, schema: hostedBuild(this.game).stateHash };
2052
+ let grant = await characterRequest(this.env, account, body);
2053
+ if (grant.error === 'character-held') {
2054
+ const old = this.env.TABLE.get(this.env.TABLE.idFromName(`${this.game}/${grant.holder}`));
2055
+ const url = `https://room/__recall?game=${encodeURIComponent(this.game)}&room=${encodeURIComponent(grant.holder)}`;
2056
+ const response = await old.fetch(url, { method: 'POST', body: JSON.stringify({ account }) });
2057
+ if (!response.ok) throw new Error('Finishing your last session; its room is unavailable');
2058
+ grant = await characterRequest(this.env, account, body);
2059
+ }
2060
+ if (!grant.ok) throw new Error(grant.error);
2061
+ const held = { account, lease: grant.lease, seat: -1, character: grant.character };
2062
+ this.characterGrants.set(account, held);
2063
+ this.hostRt.reserveCharacter(held);
2064
+ this.saveRoom(this.hostRt.save());
2065
+ }
2066
+
1938
2067
  async fetch(request) {
2068
+ if (new URL(request.url).pathname === '/__character') {
2069
+ const body = await request.json();
2070
+ this.characters ??= characterStore(this.ctx.storage);
2071
+ if (body.op === 'acquire') return json(this.characters.acquire(body.game, body.holder, body.schema));
2072
+ if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
2073
+ return json({ ok: false }, 400);
2074
+ }
1939
2075
  if (new URL(request.url).pathname === '/__multiplex') {
1940
2076
  return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
1941
2077
  }
@@ -1954,6 +2090,23 @@ export class Table {
1954
2090
  return json({ ok: true });
1955
2091
  }
1956
2092
  const room = this.roomFor(game, code, max);
2093
+ if (url.pathname === '/__recall') {
2094
+ const { account } = await request.json();
2095
+ return this.withCharacter(account, async () => {
2096
+ this.ensureHost(room, true);
2097
+ if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
2098
+ await this.flushCharacters();
2099
+ if (!this.hostRt.characters.has(account)) {
2100
+ const grant = await characterRequest(this.env, account, { op: 'acquire', game, holder: code, schema: hostedBuild(game).stateHash });
2101
+ if (!grant.ok) return json({ ok: true });
2102
+ this.hostRt.reserveCharacter({ account, lease: grant.lease, seat: -1, character: grant.character });
2103
+ }
2104
+ this.hostRt.releaseCharacter(account);
2105
+ for (const c of [...room.clients.values()]) if (c.player === account) room.kick(c, 'character-moved', 4001);
2106
+ await this.flushCharacters();
2107
+ return json({ ok: true });
2108
+ });
2109
+ }
1957
2110
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
1958
2111
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
1959
2112
  if (url.pathname === '/__facts') return json({ ...room.officeFacts(), durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
@@ -2016,6 +2169,12 @@ export class Table {
2016
2169
  const agent = decodeFacts(url.searchParams.get('ag'));
2017
2170
  // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
2018
2171
  await this.hydrateHistory(room);
2172
+ const account = holders(url.searchParams.get('via')).find(p => p.startsWith('p-'))?.slice(2) ?? null;
2173
+ if (hostedBuild(game)?.settings.characters && url.pathname !== '/__watch') {
2174
+ if (!account || url.searchParams.get('acct') !== '1') return new Response('Sign in to keep your character', { status: 401 });
2175
+ try { await this.acquireCharacter(account); }
2176
+ catch (error) { return new Response(String(error.message), { status: 409 }); }
2177
+ }
2019
2178
  const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
2020
2179
  server.accept();
2021
2180
  const via = url.searchParams.get('via');
@@ -2068,7 +2227,12 @@ export class Table {
2068
2227
  // seats, so it is not counted twice). One counter write per room.
2069
2228
  const fresh = room.seats.size === 0;
2070
2229
  server.addEventListener('message', (e) => {
2071
- h.onMessage(typeof e.data === 'string' ? e.data : '');
2230
+ let text = typeof e.data === 'string' ? e.data : '';
2231
+ if (hostedBuild(game)?.settings.characters && account) {
2232
+ 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
+ }
2234
+ h.onMessage(text);
2235
+ if (hostedBuild(game)?.settings.characters && account) { const joined = room.clients.get(h.id); if (joined?.token) this.accountTokens[account] = joined.token; }
2072
2236
  // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
2073
2237
  if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.report();
2074
2238
  if (fresh && !this.openCounted && room.seats.size > 0) {
@@ -388,6 +388,7 @@ export const players = Object.freeze({
388
388
  if(identity?.id && !identity.id.startsWith('office-')) { const p=await env.DB.prepare('SELECT id,name,guest,owner FROM players WHERE id=?1').bind(identity.id).first();return p?{...p,guest:Boolean(p.guest),owner:Boolean(p.owner)}:null; }
389
389
  try { const s = await sessionOf(request, env); return s ? { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner } : null; } catch { return null; }
390
390
  },
391
+ async recordsGuest(request,env,url){return newGuest(request,env,url);},
391
392
  /** Shop guest creation uses the studio's new-guest address rate and the daily player limit. */
392
393
  async shopGuest(request, env, url, addressLimit) { return newGuest(request, env, url, addressLimit, 'shop-new'); },
393
394
  /** True when the request carries the session of an account its owner marked (`homie-studio players owner`). */
@@ -548,7 +549,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
548
549
  if (head === 'stats') return withCookies(json({ ok: true, game: game.id, stats: s ? await readPlayerStats(env, s.player.id, game.id) : {} }), s?.cookies);
549
550
  if (third && parts.length === 3) {
550
551
  if (!s) return json({ ok: true, key: third, value: null, version: 0, updatedAt: null });
551
- const r = await getSave(env, s.player.id, game.id, third);
552
+ let key;try{key=decodeURIComponent(third);}catch{return fail(400,'key','invalid save key');}
553
+ const r = await getSave(env, s.player.id, game.id, key);
552
554
  return withCookies(json(r, r.ok ? 200 : 400), s.cookies);
553
555
  }
554
556
  if (!s) return json({ ok: true, game: game.id, player: null, keys: [], used: { keys: 0, bytes: 0, blobBytes: 0 }, limits: SAVE_LIMITS });
@@ -1,28 +1,77 @@
1
+ import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointWorld, changes, applyChanges } from './world-journal.mjs';
1
2
  /**
2
- * A whole server-hosted room in SQLite. A small room updates one row; a large one uses pieces in the same synchronous
3
- * transaction. No index besides the integer primary key: the default is one row written per second, not two.
3
+ * Match rooms keep their whole save and its cadence. A lasting world (`persistent`) writes what the game declared
4
+ * durable the moment it changes, as one small journal transaction, and folds the rest of the room into a chunked
5
+ * checkpoint on an interval and on the way down. Restore is the last checkpoint and then the journal after it.
6
+ *
7
+ * The journal is a ring. Row 0 holds the floor: the last sequence number the checkpoint already contains, written in
8
+ * the checkpoint's own transaction. A record's row id is its sequence less the floor, so the records after a
9
+ * checkpoint overwrite the ones before it and a checkpoint never pays a row for each record it folds. A read takes
10
+ * only the rows above the floor, in order, and refuses a gap.
11
+ *
12
+ * Only integer primary keys: no secondary-index write amplification.
4
13
  * The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
5
14
  */
6
- export function roomStore(storage) {
15
+ export function roomStore(storage, { persistent = false } = {}) {
7
16
  const sql = storage.sql;
8
17
  sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
9
18
  sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
19
+ let journalReady = false;
20
+ /** Opened once: a store that writes before it reads still continues the sequence on disk, so its checkpoint retires what is there. */
21
+ function journal() {
22
+ if (journalReady) return;
23
+ sql.exec('CREATE TABLE IF NOT EXISTS world_journal (id INTEGER PRIMARY KEY, seq INTEGER NOT NULL, data BLOB NOT NULL)');
24
+ floor = sql.exec('SELECT seq FROM world_journal WHERE id = 0').toArray()[0]?.seq ?? 0;
25
+ sequence = Math.max(floor, sql.exec('SELECT MAX(seq) AS seq FROM world_journal WHERE id > 0').toArray()[0]?.seq ?? 0);
26
+ journalReady = true;
27
+ }
28
+ // `base` is the world as last read or checkpointed, kept whole until a commit says which fields are durable;
29
+ // `durable` is the projection of the last world a commit saw, and is what the next one is compared with.
30
+ let sequence = 0, floor = 0, base = null, durable = null, checkpoint = null, checkpointAt = 0, checkpointTick = 0, committedInputs = null;
31
+ let lasting = persistent;
32
+ if (persistent) { const old = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); if (!old?.persistent) sql.exec('INSERT INTO boots(id,data) VALUES (1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', JSON.stringify({ epoch: 0, count: 0, ...old, persistent: true })); }
10
33
  const encoder = new TextEncoder();
11
34
  const decoder = new TextDecoder('utf-8', { fatal: true });
12
35
  const pieceBytes = 1_000_000;
36
+ const inputState = v => ({inputs:v.host?.inputs ?? [],queues:v.host?.queues ?? []});
37
+ /** What is on disk now equals `value`: the next commit is compared with it. */
38
+ function settle(value) {
39
+ base = normalizeWorld(structuredClone(value)); durable = null;
40
+ checkpoint = JSON.stringify(checkpointWorld(base)); checkpointAt = value.at ?? 0; checkpointTick = base.host?.core?.tick ?? 0; committedInputs = inputState(base);
41
+ }
13
42
  return {
14
43
  read() {
15
- if (this.boots()?.retired === true) return null;
44
+ const boot = this.boots();
45
+ if (boot?.retired === true) return null;
46
+ const maxBytes = boot?.persistent ? Infinity : 16_000_000;
16
47
  const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
17
- if (total > 16_000_000) throw new Error('save exceeds 16 MB');
48
+ if (total > maxBytes) throw new Error('save exceeds 16 MB');
18
49
  const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
19
50
  if (!rows.length) return null;
20
- if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > 16_000_000) throw new Error('save pieces missing or save exceeds 16 MB');
51
+ if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
21
52
  const pieces = rows.map((r) => new Uint8Array(r.data));
22
53
  const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
23
54
  let at = 0;
24
55
  for (const p of pieces) { bytes.set(p, at); at += p.length; }
25
- const value = JSON.parse(decoder.decode(bytes));
56
+ let value = JSON.parse(decoder.decode(bytes));
57
+ if (lasting) journal();
58
+ sequence = floor;
59
+ const journals = lasting ? sql.exec('SELECT id, seq, data FROM world_journal WHERE id > 0 AND seq > ? ORDER BY seq', floor).toArray() : [];
60
+ if (journals.length) {
61
+ let normalized = normalizeWorld(value);
62
+ let pending = '';
63
+ const stream = new TextDecoder('utf-8', {fatal:true});
64
+ for (const row of journals) {
65
+ if (row.seq !== sequence + 1 || row.id !== row.seq - floor) throw new Error('World journal has a gap');
66
+ pending += stream.decode(new Uint8Array(row.data), {stream:true}); sequence = row.seq;
67
+ let end;
68
+ while ((end = pending.indexOf('\n')) >= 0) { normalized = applyChanges(normalized, JSON.parse(pending.slice(0,end))); pending = pending.slice(end+1); }
69
+ }
70
+ pending += stream.decode();
71
+ if (pending) throw new Error('Incomplete world journal transaction');
72
+ value = denormalizeWorld(normalized);
73
+ }
74
+ if (lasting) settle(value);
26
75
  if (typeof value.host === 'string') {
27
76
  const hostText = value.host;
28
77
  value.host = JSON.parse(hostText);
@@ -30,19 +79,72 @@ export function roomStore(storage) {
30
79
  }
31
80
  return value;
32
81
  },
82
+ /**
83
+ * A lasting world's tick. Nothing durable changed and no checkpoint is due: no write. Something durable changed:
84
+ * one journal transaction holding just that. The interval has passed (or `force`) and the room differs from its
85
+ * checkpoint: the checkpoint, which takes the journal with it.
86
+ */
87
+ commit(value, { force = false, seconds = 30, tickHz = 20, fields }) {
88
+ journal();
89
+ if (!base) { this.write(value); return; }
90
+ const next = normalizeWorld(value);
91
+ durable ??= ownDurable(base, fields);
92
+ const moved = changes(durable, durableWorld(next, fields));
93
+ // The comparison is over sparse field maps. What is written is the full validated representation at each
94
+ // path, so a new entity is recorded with its initial pose.
95
+ const patches = moved.map((patch) => {
96
+ if (patch.length === 1) return patch;
97
+ let full = next;
98
+ for (const key of patch[0]) full = full[key];
99
+ return [patch[0], full];
100
+ });
101
+ if (force || value.at - checkpointAt >= seconds * 1000 || next.host.core.tick - checkpointTick >= seconds * tickHz) {
102
+ if (patches.length || JSON.stringify(checkpointWorld(next)) !== checkpoint) { this.write(value); return; }
103
+ // Still the checkpoint's own room: it is asked again an interval from now, not on every tick until then.
104
+ checkpointAt = value.at; checkpointTick = next.host.core.tick;
105
+ }
106
+ if (patches.length) {
107
+ // Input acknowledgements and pending pulses share the value transaction.
108
+ // A restart must not consume a checkpoint's already-spent pulse again.
109
+ const inputs = inputState(next);
110
+ changes(committedInputs, inputs, ['host'], patches);
111
+ // Advance logical time and RNG with a commitment, never once per tick.
112
+ for (const key of ['tick','rng','nextId','seq']) patches.push([['host','core',key],next.host.core[key]]);
113
+ patches.push([['at'],next.at]);
114
+ // One transaction may span several bounded rows. A read folds the whole
115
+ // transaction; no prefix can become visible across a crash.
116
+ const bytes = encoder.encode(JSON.stringify(patches) + '\n');
117
+ let last = sequence;
118
+ storage.transactionSync(() => {
119
+ for (let at = 0; at < bytes.length; at += pieceBytes) {
120
+ last += 1;
121
+ sql.exec('INSERT INTO world_journal (id, seq, data) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET seq = excluded.seq, data = excluded.data', last - floor, last, bytes.slice(at,at+pieceBytes));
122
+ }
123
+ });
124
+ sequence = last;
125
+ committedInputs = structuredClone(inputs);
126
+ }
127
+ // Brought up to this tick by what changed in it (each value copied), never by copying the room.
128
+ durable = applyChanges(durable, moved);
129
+ },
33
130
  write(value) {
131
+ if (lasting) journal();
34
132
  const bytes = encoder.encode(JSON.stringify(value));
35
- if (bytes.length > 16_000_000) throw new Error('save exceeds 16 MB');
133
+ if (bytes.length > 16_000_000 && !lasting) throw new Error('save exceeds 16 MB');
36
134
  storage.transactionSync(() => {
37
135
  let id = 0;
38
136
  for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
39
137
  sql.exec('DELETE FROM save WHERE id >= ?', id);
138
+ // The checkpoint holds every record so far: raising the floor retires them without touching their rows.
139
+ if (lasting && sequence !== floor) sql.exec("INSERT INTO world_journal (id, seq, data) VALUES (0, ?, x'') ON CONFLICT(id) DO UPDATE SET seq = excluded.seq", sequence);
40
140
  });
141
+ floor = sequence;
142
+ if (lasting) settle(value);
41
143
  },
42
- boots() { return JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); },
43
- boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify(value)); },
144
+ boots() { const value = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); lasting ||= value?.persistent === true; return value; },
145
+ boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify({ ...value, ...(lasting ? { persistent: true } : {}) })); },
44
146
  retire() { this.boot({ retired: true }); },
45
- clear() { storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); }); },
147
+ clear() { if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
46
148
  };
47
149
  }
48
150
 
package/worker/room.mjs CHANGED
@@ -484,15 +484,16 @@ export class NetRoom {
484
484
 
485
485
  hasViewSchedule() {
486
486
  const view = this.server?.viewSettings;
487
- return this.server?.viewRadiusM != null || Boolean(view?.precisionM > 0 || view?.nearM != null && view?.farHz != null);
487
+ return this.server?.privateDelivery || this.server?.viewRadiusM != null || Boolean(view?.precisionM > 0 || view?.nearM != null && view?.farHz != null);
488
488
  }
489
489
 
490
490
  /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
491
491
  playerSnapshot(client, snap, welcome = false) {
492
492
  if (!snap || !this.hasViewSchedule()) return snap;
493
493
  client.viewSchedule ??= scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz);
494
- const selected = client.viewSchedule(snap, client.seat);
495
- client.snapEncoder ??= snapshotEncoder(this.tickHz, true);
494
+ let selected = client.viewSchedule(snap, client.seat);
495
+ if(this.server?.projectSnapshot) selected=this.server.projectSnapshot(selected,client.seat);
496
+ client.snapEncoder ??= snapshotEncoder(this.tickHz, !this.server?.privateDelivery);
496
497
  if (welcome) client.snapEncoder.reset();
497
498
  return client.snapEncoder.encode(selected, client.seat);
498
499
  }
@@ -918,11 +919,19 @@ export class NetRoom {
918
919
  this.onChat({ client: c }, m);
919
920
  return;
920
921
  }
922
+ case 'probeAck': {
923
+ if (c.probe && m.n === c.probe.n) {
924
+ 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}); }
926
+ }
927
+ return;
928
+ }
921
929
  case 'ping': {
922
930
  if (this.rules && c.rules && Number.isFinite(m.readySpeed)) { c.readySpeed = Math.max(0, Math.min(1, m.readySpeed)); c.readyAt = now; }
923
931
  const wasHidden = c.hidden;
924
932
  c.hidden = m.hid === true;
925
933
  this.send(c, { t: 'pong', c: m.c, st: now });
934
+ if(this.server && (!c.probe || now-c.probe.at>10000)){c.probe={n:crypto.randomUUID(),at:now};this.send(c,{t:'probe',n:c.probe.n});}
926
935
  // A host whose tab went to the background will stop ticking: hand the round on now.
927
936
  if (c.id === this.hostId && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
928
937
  return;