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