@homie-rocks/studio 0.46.3 → 0.46.6
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 +70 -0
- package/README.md +12 -1
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +23 -0
- package/guides/game/SKILL.md +2 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +2 -0
- package/package.json +1 -1
- package/rules/host.ts +14 -3
- package/rules/rules.ts +9 -3
- package/rules/view.ts +66 -12
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +44 -10
- package/worker/office.mjs +2 -2
- package/worker/pages.mjs +46 -1
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -90,9 +90,9 @@ import { appRole, openPath } from './app-format.mjs';
|
|
|
90
90
|
import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
|
|
91
91
|
import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
|
|
92
92
|
import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
|
|
93
|
-
import { roomStore, restoreDelay } from './room-store.mjs';
|
|
93
|
+
import { roomStore, restoreDelay, LOOSE_SAVE } from './room-store.mjs';
|
|
94
94
|
import { appCors, isAppOrigin } from './standalone.mjs';
|
|
95
|
-
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
95
|
+
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
|
|
96
96
|
import {
|
|
97
97
|
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
98
98
|
setMembership,
|
|
@@ -105,7 +105,7 @@ 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, diagnosticsOf, gateFor, nextGate } from './gate.mjs';
|
|
108
|
+
import { acceptMultiplex, diagnosticsOf, gateFor, gatePlayers, nextGate } from './gate.mjs';
|
|
109
109
|
import { worldSource } from './world-wire.mjs';
|
|
110
110
|
export { Gate, Concentrator } from './gate.mjs';
|
|
111
111
|
import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
|
|
@@ -1226,6 +1226,17 @@ async function route(request, env, ctx) {
|
|
|
1226
1226
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
1227
1227
|
if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
|
|
1228
1228
|
const who = await ticketSub(env, game, url.searchParams.get('t'));
|
|
1229
|
+
// A play page left open across a deploy (0.46.5). One that names what it was made for (`sh`) and is behind is
|
|
1230
|
+
// told so, and reloads itself whole, once, to the same room and seat. One from before that handshake is sent
|
|
1231
|
+
// to a fresh page only when this game cannot work inside it: the game keeps verified results for a player, and
|
|
1232
|
+
// the old page has no player ticket to give its frame (a result would have nobody to be written for).
|
|
1233
|
+
if (url.searchParams.get('pf') === '1' && url.searchParams.get('embed') !== '1' && url.searchParams.get('want') !== 'screen' && !url.searchParams.has('watch')) {
|
|
1234
|
+
const said = url.searchParams.get('sh'), now = shellKey(meta);
|
|
1235
|
+
const ticketless = Boolean(meta.room?.records && env.DB) && !holders(who).some((x) => x.startsWith('p-'));
|
|
1236
|
+
if (said !== null ? said !== now : ticketless) {
|
|
1237
|
+
return new Response(shellStandIn(url.searchParams.get('room') ?? '', said !== null ? now : null), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1229
1240
|
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1230
1241
|
// An agent's frame (hands `self`): the game plays as that AI.
|
|
1231
1242
|
const agentPart = holders(who).find((x) => x.startsWith('a-'));
|
|
@@ -1350,6 +1361,9 @@ export class Table {
|
|
|
1350
1361
|
if (this.saved?.retired === true) this.cleanupPending = true;
|
|
1351
1362
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
1352
1363
|
this.officeSaved = (await ctx.storage.get('office')) ?? null;
|
|
1364
|
+
// A big match room's last unconfirmed checkpoint (worker/room-store.mjs), read once as the object wakes.
|
|
1365
|
+
try { this.looseSave = (await ctx.storage.list?.({ prefix: LOOSE_SAVE })) ?? null; }
|
|
1366
|
+
catch (error) { this.looseSave = null; this.storageProblem(error); }
|
|
1353
1367
|
});
|
|
1354
1368
|
}
|
|
1355
1369
|
|
|
@@ -1423,7 +1437,7 @@ export class Table {
|
|
|
1423
1437
|
try {
|
|
1424
1438
|
const build = hostedBuild(this.game);
|
|
1425
1439
|
const persistent = this.isPersistent();
|
|
1426
|
-
try { this.durable ??=
|
|
1440
|
+
try { this.durable ??= this.openStore(); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
|
|
1427
1441
|
let saved = this.cleanupPending ? null : this.readRoom();
|
|
1428
1442
|
restoring = Boolean(saved);
|
|
1429
1443
|
if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
|
|
@@ -1469,7 +1483,9 @@ export class Table {
|
|
|
1469
1483
|
this.hostRt = startHost(this.game, {
|
|
1470
1484
|
...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
|
|
1471
1485
|
record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
|
|
1472
|
-
store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment) },
|
|
1486
|
+
store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment, true) },
|
|
1487
|
+
// A room behind Gates needs no snapshot as text: nothing is encoded that nobody is sent (room.mjs, hostSnap).
|
|
1488
|
+
snapshotText: () => !this.relayed,
|
|
1473
1489
|
...(build.settings.characters ? { characterFor: (peer) => {
|
|
1474
1490
|
const account = room.clients.get(peer.id)?.player;
|
|
1475
1491
|
return account ? this.characterGrants.get(account) ?? null : null;
|
|
@@ -1509,12 +1525,19 @@ export class Table {
|
|
|
1509
1525
|
}
|
|
1510
1526
|
}
|
|
1511
1527
|
|
|
1512
|
-
|
|
1528
|
+
/** This object's room store, with the unconfirmed checkpoint it woke to (if any). */
|
|
1529
|
+
openStore(persistent = this.isPersistent()) {
|
|
1530
|
+
const loose = this.looseSave; this.looseSave = null;
|
|
1531
|
+
return roomStore(this.ctx.storage, { persistent, loose, onProblem: (error) => this.storageProblem(error) });
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** `periodic`: the rules host's own checkpoint of a tick, as against a save an admission or a pause asked for. */
|
|
1535
|
+
saveRoom(bytes, commitment, periodic = false) {
|
|
1513
1536
|
if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
|
|
1514
1537
|
const now = Date.now();
|
|
1515
1538
|
if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
|
|
1516
1539
|
try {
|
|
1517
|
-
this.durable ??=
|
|
1540
|
+
this.durable ??= this.openStore();
|
|
1518
1541
|
const build = hostedBuild(this.game);
|
|
1519
1542
|
const host = new TextDecoder().decode(bytes);
|
|
1520
1543
|
const net = this.room.saved();
|
|
@@ -1533,6 +1556,10 @@ export class Table {
|
|
|
1533
1556
|
commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
|
|
1534
1557
|
fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
|
|
1535
1558
|
this.durable.commit(saved, commitment);
|
|
1559
|
+
} else if (this.relayed && periodic && !clearing) {
|
|
1560
|
+
// A room behind Gates: its once-a-second checkpoint holds none of its frames (worker/room-store.mjs). A save
|
|
1561
|
+
// an admission asks for is still confirmed before the welcome that names the seat can leave.
|
|
1562
|
+
this.durable.write(saved, { unconfirmed: true });
|
|
1536
1563
|
} else this.durable.write(saved);
|
|
1537
1564
|
if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
|
|
1538
1565
|
this.roomSave = saved;
|
|
@@ -1731,7 +1758,7 @@ export class Table {
|
|
|
1731
1758
|
const room = this.room;
|
|
1732
1759
|
if (!room || !this.hostRt) {
|
|
1733
1760
|
if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
|
|
1734
|
-
this.durable ??=
|
|
1761
|
+
this.durable ??= this.openStore(false);
|
|
1735
1762
|
const saved = this.cleanupPending ? null : this.readRoom(true);
|
|
1736
1763
|
if (saved) {
|
|
1737
1764
|
this.game = saved.game; this.code = saved.room;
|
|
@@ -1778,7 +1805,12 @@ export class Table {
|
|
|
1778
1805
|
sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
|
|
1779
1806
|
const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
|
|
1780
1807
|
const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
|
|
1781
|
-
if(!player)
|
|
1808
|
+
if(!player){
|
|
1809
|
+
// Never dropped in silence (0.46.5): the log says so, and the person is told on their own screen.
|
|
1810
|
+
try{console.error(JSON.stringify({at:new Date().toISOString(),ev:'record-unsaved',why:'no-player',game:this.game??null,room:this.code??null,key,id:value?.id??null}));}catch{}
|
|
1811
|
+
if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1782
1814
|
sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
|
|
1783
1815
|
sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
|
|
1784
1816
|
this.ctx.waitUntil(this.flushRoundRecords());
|
|
@@ -2102,7 +2134,7 @@ export class Table {
|
|
|
2102
2134
|
// players sent to a Gate in the last ten seconds who have not reached it yet.
|
|
2103
2135
|
const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
|
|
2104
2136
|
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);
|
|
2137
|
+
const gate = nextGate(gates, this.gateHeld, promised, gatePlayers(this.env));
|
|
2106
2138
|
if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
|
|
2107
2139
|
this.gatePromised.get(gate).push(now + 10_000);
|
|
2108
2140
|
return json({ gate });
|
|
@@ -2382,6 +2414,8 @@ export class Table {
|
|
|
2382
2414
|
// A Gate's stamp on a player's message to this object handling it, ms.
|
|
2383
2415
|
inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
|
|
2384
2416
|
durability: this.storageHealth ?? { ok: true },
|
|
2417
|
+
// Checkpoints written without holding the room's frames for them (worker/room-store.mjs), so far.
|
|
2418
|
+
save: { unconfirmed: this.durable?.unconfirmedWrites ?? 0 },
|
|
2385
2419
|
});
|
|
2386
2420
|
const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
|
|
2387
2421
|
.then((r) => r.body?.cancel?.())
|
package/worker/office.mjs
CHANGED
|
@@ -62,7 +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
|
+
import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
|
|
66
66
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
67
67
|
import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
|
|
68
68
|
import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
|
|
@@ -432,7 +432,7 @@ async function roomFacts(env, game, room, max) {
|
|
|
432
432
|
* small room answers for itself, as it always has.
|
|
433
433
|
*/
|
|
434
434
|
export async function roomDiagnostics(env, game, room, max) {
|
|
435
|
-
if (!env.GATE || roomLayout(max).gates === 1) return roomFacts(env, game, room, max);
|
|
435
|
+
if (!env.GATE || roomLayout(max, gatePlayers(env)).gates === 1) return roomFacts(env, game, room, max);
|
|
436
436
|
let kept = null;
|
|
437
437
|
try {
|
|
438
438
|
const res = await diagnosticsOf(env, game, room).fetch(`https://diagnostics/__diagnostics?room=${enc(room)}`);
|
package/worker/pages.mjs
CHANGED
|
@@ -8,6 +8,36 @@ import { playerAncestors, PLAYER_SANDBOX, PLAYER_SIZE } from './embed.mjs';
|
|
|
8
8
|
import { esc, layout, gameSocialTags } from './site.mjs';
|
|
9
9
|
import { NET_PALETTE } from './room.mjs';
|
|
10
10
|
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
11
|
+
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a play page was made for (0.46.5): the toolkit that wrote it and the parts of the game's manifest it is built
|
|
15
|
+
* around (who may sit, what the room keeps, whether the player has saves and a ticket). The page hands it to the
|
|
16
|
+
* game's frame (`sh`), and the Worker that serves the frame knows what a page made now would say. A page left open
|
|
17
|
+
* across a deploy says something older: it is brought up to date with one full reload, to the same room and seat,
|
|
18
|
+
* instead of hosting a newer game inside a page that lacks what that game needs (a player ticket, for one).
|
|
19
|
+
* It does not change when only the game's code or art does: that is still a reload of the frame alone.
|
|
20
|
+
*/
|
|
21
|
+
export function shellKey(g) {
|
|
22
|
+
// Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
|
|
23
|
+
// edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
|
|
24
|
+
const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
|
|
25
|
+
const { version: _version, ...netplay } = g?.netplay ?? {};
|
|
26
|
+
const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
|
|
27
|
+
let h = 2166136261;
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
29
|
+
return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The frame's stand-in when its page is older than the game it would host. It tells the page, and loads no game.
|
|
33
|
+
* `key`: the page understands the handshake and reloads itself once. No key: a page from before the handshake, whose
|
|
34
|
+
* only way to load a whole new page is the one it has for leaving a finished room; it is used only when the game cannot
|
|
35
|
+
* work inside that page at all.
|
|
36
|
+
*/
|
|
37
|
+
export function shellStandIn(room, key) {
|
|
38
|
+
const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
|
|
39
|
+
return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
|
|
40
|
+
}
|
|
11
41
|
import { SKILLS } from './agents.mjs';
|
|
12
42
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
13
43
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -180,7 +210,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
180
210
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
181
211
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
182
212
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
183
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
213
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
184
214
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
185
215
|
const joinCorner = corner('join', 'bottom-right');
|
|
186
216
|
const first = places.desk;
|
|
@@ -497,6 +527,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
497
527
|
q.set('b', roomKey);
|
|
498
528
|
if (boot.embed) q.set('name', guestName);
|
|
499
529
|
if (boot.t) q.set('t', boot.t);
|
|
530
|
+
// What this page was made for (0.46.5): the Worker answers with a stand-in when a page made now would differ.
|
|
531
|
+
if (boot.sh) q.set('sh', shellSaid || boot.sh);
|
|
500
532
|
if (!boot.embed && params.get('name')) q.set('name', params.get('name'));
|
|
501
533
|
else if (!boot.embed) {
|
|
502
534
|
// A player with an account (or a named guest) on this studio plays under their own name in every room.
|
|
@@ -517,6 +549,18 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
517
549
|
}
|
|
518
550
|
state.params = {};
|
|
519
551
|
(boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
|
|
552
|
+
// The Worker says this page is older than the game (a deploy while it was open): one full reload, which keeps the
|
|
553
|
+
// room (it is in the address) and the seat (its token is in this tab's storage). Once for each newer page: if the
|
|
554
|
+
// reload came back the same, the frame is asked for as the Worker described it and play goes on.
|
|
555
|
+
var shellSaid = '';
|
|
556
|
+
function shellBehind(key) {
|
|
557
|
+
var tried = '';
|
|
558
|
+
try { tried = sessionStorage.getItem('homie-shell') || ''; } catch (e) {}
|
|
559
|
+
if (tried === key) { shellSaid = key; frame.src = frameUrl(); return; }
|
|
560
|
+
try { sessionStorage.setItem('homie-shell', key); } catch (e) { shellSaid = key; frame.src = frameUrl(); return; }
|
|
561
|
+
state.shellReload = key;
|
|
562
|
+
location.reload();
|
|
563
|
+
}
|
|
520
564
|
var updating = buildRetry(function () {
|
|
521
565
|
state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
|
|
522
566
|
frame.src = frameUrl();
|
|
@@ -532,6 +576,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
532
576
|
var m = ev.data;
|
|
533
577
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
534
578
|
A.message(m);
|
|
579
|
+
if (m.what === 'shell' && typeof m.key === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(m.key)) { shellBehind(m.key); return; }
|
|
535
580
|
if (m.what === 'wait') updating.stop();
|
|
536
581
|
if (m.what === 'build' && updating.acknowledge(m.ver) && toast) toast.hidden = true;
|
|
537
582
|
if (m.what === 'attached' || m.what === 'ready') updating.reported();
|
package/worker/room-store.mjs
CHANGED
|
@@ -12,8 +12,42 @@ import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointW
|
|
|
12
12
|
* Only integer primary keys: no secondary-index write amplification.
|
|
13
13
|
* The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
|
|
14
14
|
*/
|
|
15
|
-
export function roomStore(storage, { persistent = false } = {}) {
|
|
15
|
+
export function roomStore(storage, { persistent = false, loose = null, onProblem = () => {} } = {}) {
|
|
16
16
|
const sql = storage.sql;
|
|
17
|
+
/*
|
|
18
|
+
* THE CHECKPOINT THAT DOES NOT HOLD THE ROOM'S FRAMES. Cloudflare holds everything an object sends after a write
|
|
19
|
+
* until that write is confirmed. A match room behind Gates writes its whole self once a second, and every world
|
|
20
|
+
* and every receipt of the ticks that followed waited on it: one or two worlds a second reached every Gate a
|
|
21
|
+
* tick or more late, at every size. Such a checkpoint is a second old by design, and the worlds between two of
|
|
22
|
+
* them were never durable, so nothing a player sees is being promised. `write(value, { unconfirmed: true })`
|
|
23
|
+
* puts it in the object's key-value storage with `allowUnconfirmed`, which is Cloudflare's own way to say so.
|
|
24
|
+
*
|
|
25
|
+
* Every confirmed write removes it, so one found on disk was written after the last confirmed save, unless the
|
|
26
|
+
* object stopped between a confirmed write and that removal: each save says when it was made (`at`), and a read
|
|
27
|
+
* takes the later of the two. `loose` is what the object read of it when it woke (a Map from
|
|
28
|
+
* `storage.list({ prefix: LOOSE_SAVE })`); reads after that are answered from memory.
|
|
29
|
+
*/
|
|
30
|
+
let looseText = null, looseOnDisk = false, looseWrites = 0;
|
|
31
|
+
if (loose && typeof loose.get === 'function' && loose.get(LOOSE_SAVE)) {
|
|
32
|
+
looseOnDisk = true;
|
|
33
|
+
try {
|
|
34
|
+
const meta = loose.get(LOOSE_SAVE), pieces = [];
|
|
35
|
+
for (let i = 0; i < meta.pieces; i += 1) { const piece = loose.get(`${LOOSE_SAVE}/${i}`); if (!(piece instanceof Uint8Array)) throw new Error('unconfirmed save pieces missing'); pieces.push(piece); }
|
|
36
|
+
const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
|
|
37
|
+
let at = 0;
|
|
38
|
+
for (const piece of pieces) { bytes.set(piece, at); at += piece.length; }
|
|
39
|
+
if (!Number.isInteger(meta.pieces) || meta.pieces < 1 || bytes.length !== meta.bytes || bytes.length > 16_000_000) throw new Error('unconfirmed save does not fit its record');
|
|
40
|
+
looseText = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
41
|
+
JSON.parse(looseText);
|
|
42
|
+
} catch (error) { looseText = null; onProblem(error); }
|
|
43
|
+
}
|
|
44
|
+
/** Removed with whatever confirmed write is being made: no await between them, so they are one transaction. */
|
|
45
|
+
function dropLoose() {
|
|
46
|
+
looseText = null;
|
|
47
|
+
if (!looseOnDisk || typeof storage.delete !== 'function') return;
|
|
48
|
+
looseOnDisk = false;
|
|
49
|
+
Promise.resolve(storage.delete([LOOSE_SAVE, ...Array.from({ length: LOOSE_PIECES }, (_, i) => `${LOOSE_SAVE}/${i}`)])).catch(onProblem);
|
|
50
|
+
}
|
|
17
51
|
sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
|
|
18
52
|
sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
|
|
19
53
|
let journalReady = false;
|
|
@@ -47,13 +81,22 @@ export function roomStore(storage, { persistent = false } = {}) {
|
|
|
47
81
|
const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
|
|
48
82
|
if (total > maxBytes) throw new Error('save exceeds 16 MB');
|
|
49
83
|
const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
|
|
50
|
-
if (!rows.length) return null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
if (!rows.length && looseText === null) return null;
|
|
85
|
+
let value = null;
|
|
86
|
+
if (rows.length) {
|
|
87
|
+
if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
|
|
88
|
+
const pieces = rows.map((r) => new Uint8Array(r.data));
|
|
89
|
+
const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
|
|
90
|
+
let at = 0;
|
|
91
|
+
for (const p of pieces) { bytes.set(p, at); at += p.length; }
|
|
92
|
+
value = JSON.parse(decoder.decode(bytes));
|
|
93
|
+
}
|
|
94
|
+
// An unconfirmed checkpoint is the room when it is the later of the two (above).
|
|
95
|
+
if (looseText !== null && !boot?.persistent) {
|
|
96
|
+
const unconfirmed = JSON.parse(looseText);
|
|
97
|
+
if (!value || !(Number(value.at) >= Number(unconfirmed.at))) value = unconfirmed;
|
|
98
|
+
}
|
|
99
|
+
if (!value) return null;
|
|
57
100
|
if (lasting) journal();
|
|
58
101
|
sequence = floor;
|
|
59
102
|
const journals = lasting ? sql.exec('SELECT id, seq, data FROM world_journal WHERE id > 0 AND seq > ? ORDER BY seq', floor).toArray() : [];
|
|
@@ -127,10 +170,20 @@ export function roomStore(storage, { persistent = false } = {}) {
|
|
|
127
170
|
// Brought up to this tick by what changed in it (each value copied), never by copying the room.
|
|
128
171
|
durable = applyChanges(durable, moved);
|
|
129
172
|
},
|
|
130
|
-
|
|
173
|
+
/** `unconfirmed`: a match room's periodic checkpoint, which no frame waits on (above). Never a lasting world's. */
|
|
174
|
+
write(value, { unconfirmed = false } = {}) {
|
|
131
175
|
if (lasting) journal();
|
|
132
|
-
const
|
|
176
|
+
const text = JSON.stringify(value);
|
|
177
|
+
const bytes = encoder.encode(text);
|
|
133
178
|
if (bytes.length > 16_000_000 && !lasting) throw new Error('save exceeds 16 MB');
|
|
179
|
+
if (unconfirmed && !lasting && typeof storage.put === 'function' && bytes.length <= LOOSE_PIECES * pieceBytes) {
|
|
180
|
+
const entries = { [LOOSE_SAVE]: { pieces: Math.max(1, Math.ceil(bytes.length / pieceBytes)), bytes: bytes.length, at: value.at ?? null } };
|
|
181
|
+
for (let i = 0; i < entries[LOOSE_SAVE].pieces; i += 1) entries[`${LOOSE_SAVE}/${i}`] = bytes.slice(i * pieceBytes, (i + 1) * pieceBytes);
|
|
182
|
+
// One put is one transaction: a reader never finds the record of one checkpoint with the pieces of another.
|
|
183
|
+
Promise.resolve(storage.put(entries, { allowUnconfirmed: true })).catch(onProblem);
|
|
184
|
+
looseText = text; looseOnDisk = true; looseWrites += 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
134
187
|
storage.transactionSync(() => {
|
|
135
188
|
let id = 0;
|
|
136
189
|
for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
|
|
@@ -138,16 +191,23 @@ export function roomStore(storage, { persistent = false } = {}) {
|
|
|
138
191
|
// The checkpoint holds every record so far: raising the floor retires them without touching their rows.
|
|
139
192
|
if (lasting && sequence !== floor) sql.exec("INSERT INTO world_journal (id, seq, data) VALUES (0, ?, x'') ON CONFLICT(id) DO UPDATE SET seq = excluded.seq", sequence);
|
|
140
193
|
});
|
|
194
|
+
dropLoose();
|
|
141
195
|
floor = sequence;
|
|
142
196
|
if (lasting) settle(value);
|
|
143
197
|
},
|
|
198
|
+
/** Checkpoints written without holding the room's frames, so far. */
|
|
199
|
+
get unconfirmedWrites() { return looseWrites; },
|
|
144
200
|
boots() { const value = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); lasting ||= value?.persistent === true; return value; },
|
|
145
201
|
boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify({ ...value, ...(lasting ? { persistent: true } : {}) })); },
|
|
146
202
|
retire() { this.boot({ retired: true }); },
|
|
147
|
-
clear() { if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
|
|
203
|
+
clear() { dropLoose(); if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
|
|
148
204
|
};
|
|
149
205
|
}
|
|
150
206
|
|
|
207
|
+
/** Where a match room's unconfirmed checkpoint is kept in the object's key-value storage: a record, and its pieces under it. */
|
|
208
|
+
export const LOOSE_SAVE = 'room-save';
|
|
209
|
+
const LOOSE_PIECES = 16;
|
|
210
|
+
|
|
151
211
|
/** A repeatable delay spreads rooms that shared a failing isolate over thirty seconds. */
|
|
152
212
|
export function restoreDelay(id) {
|
|
153
213
|
let h = 2166136261;
|
package/worker/room.mjs
CHANGED
|
@@ -148,7 +148,10 @@ export function medianVote(list) {
|
|
|
148
148
|
return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
|
|
149
149
|
}
|
|
150
150
|
const utf8 = new TextEncoder();
|
|
151
|
-
|
|
151
|
+
// A message's size in bytes. Text that is all ASCII (nearly every frame a game sends) is as long as it is wide: it is
|
|
152
|
+
// not encoded to be measured, which at ten inputs a second from a thousand players was a twentieth of a room's second.
|
|
153
|
+
const ASCII = /^[\x00-\x7f]*$/;
|
|
154
|
+
const byteLength = text => ASCII.test(text) ? text.length : utf8.encode(text).length;
|
|
152
155
|
const PALETTE_SIZE = 12;
|
|
153
156
|
const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
154
157
|
|
|
@@ -473,6 +476,12 @@ export class NetRoom {
|
|
|
473
476
|
const w = (c.rttWindow ??= []);
|
|
474
477
|
w.push([now, ms]);
|
|
475
478
|
while (w.length > 256 || (w.length > 1 && w[0][0] < now - 10000)) w.shift();
|
|
479
|
+
// A low percentile of ten seconds does not move in one. For a player behind a Gate (a big room) a full window
|
|
480
|
+
// is read once a second, not once an input: at ten inputs a second from a thousand players, sorting it each time
|
|
481
|
+
// was a tenth of the room's second when its clock moved between frames (0.46.3 against the build before it,
|
|
482
|
+
// test/crowd-room-cost.mjs). A room its players reach directly reads it as before.
|
|
483
|
+
if (c.conn.snapshotGroup && w.length >= 32 && now - (c.rttReadAt ?? 0) < 1000) return;
|
|
484
|
+
c.rttReadAt = now;
|
|
476
485
|
const sorted = w.map((x) => x[1]).sort((a, b) => a - b);
|
|
477
486
|
const low = sorted[Math.floor((sorted.length - 1) * 0.1)];
|
|
478
487
|
if (c.measuredRtt !== undefined && Math.abs(low - c.measuredRtt) < 2 && now - (c.rttToldAt ?? 0) < 1000) return;
|
|
@@ -495,7 +504,10 @@ export class NetRoom {
|
|
|
495
504
|
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
496
505
|
// A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
|
|
497
506
|
// (the host is alive), and replicas are told which it is.
|
|
498
|
-
|
|
507
|
+
// The whole snapshot as text: what a player with no view of their own is sent. The server's host hands it over
|
|
508
|
+
// unless this room sends it to nobody (a room behind Gates); then it is made only if somebody turns out to need it.
|
|
509
|
+
let out = text ?? (c ? JSON.stringify({ t: 'snap', from: c.seat, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) }) : null);
|
|
510
|
+
const whole = () => (out ??= JSON.stringify({ t: 'snap', from: null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) }));
|
|
499
511
|
if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
|
|
500
512
|
this.lastSnapText = out;
|
|
501
513
|
// A server-hosted room behind Gates hands each Gate ONE world a tick; the Gate selects, encodes and paces
|
|
@@ -513,9 +525,10 @@ export class NetRoom {
|
|
|
513
525
|
}
|
|
514
526
|
// A congested socket skips a snapshot rather than queueing a stale one.
|
|
515
527
|
if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
|
|
516
|
-
this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) :
|
|
528
|
+
this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : whole());
|
|
517
529
|
}
|
|
518
530
|
if (groups) for (const group of groups) group.snapshot(snap, this.server.viewSettings ?? {}, this.tickHz);
|
|
531
|
+
if (out !== null) this.lastSnapText = out;
|
|
519
532
|
}
|
|
520
533
|
|
|
521
534
|
hasViewSchedule() {
|
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.6';
|