@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/CHANGELOG.md +102 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +65 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build-identity.mjs +43 -0
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +14 -8
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +18 -5
- package/package.json +4 -2
- package/rules/core.ts +12 -6
- package/rules/host.ts +132 -17
- package/rules/interest.mjs +320 -51
- package/rules/rules.ts +32 -9
- package/rules/timing.mjs +20 -0
- package/worker/build-id.mjs +2 -0
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +241 -25
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +85 -16
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
- package/worker/world-wire.mjs +110 -0
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 ? `¬=${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) => {
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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.
|
|
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;
|
|
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 (
|
|
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
|
-
|
|
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 === '/
|
|
1968
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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;
|
package/worker/office-page.mjs
CHANGED
|
@@ -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);
|