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