@homie-rocks/studio 0.32.1 → 0.33.1
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 +67 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +22 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -83,9 +83,12 @@
|
|
|
83
83
|
* on the Table's alarm: Workers AI through the optional AI binding (deploy adds
|
|
84
84
|
* it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
|
|
85
85
|
*/
|
|
86
|
+
import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
|
|
86
87
|
import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
|
|
88
|
+
import { hostedGame, hostedBuild, startHost } from './hosted.mjs';
|
|
89
|
+
import { roomStore, restoreDelay } from './room-store.mjs';
|
|
87
90
|
import { appCors, isAppOrigin } from './standalone.mjs';
|
|
88
|
-
import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
91
|
+
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
89
92
|
import {
|
|
90
93
|
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
91
94
|
setMembership,
|
|
@@ -124,6 +127,8 @@ import { licenseOf } from './license.mjs';
|
|
|
124
127
|
import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
|
|
125
128
|
|
|
126
129
|
export { SEAT_MAX } from './seats.mjs';
|
|
130
|
+
// Server-hosted games (NETPLAY.md section 29): the studio's site/src/worker.mjs hands the build's rules over with this.
|
|
131
|
+
export { hostRules } from './hosted.mjs';
|
|
127
132
|
/** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
|
|
128
133
|
export { usePlayers } from './office.mjs';
|
|
129
134
|
/** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
|
|
@@ -145,11 +150,17 @@ const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(
|
|
|
145
150
|
});
|
|
146
151
|
|
|
147
152
|
/** The catalogue `homie-studio build` writes into the site (games.json). */
|
|
153
|
+
const catalogueCache = new WeakMap();
|
|
148
154
|
async function catalogue(env, origin) {
|
|
149
155
|
try {
|
|
150
156
|
const res = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
|
|
151
157
|
if (!res.ok) return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] };
|
|
152
|
-
|
|
158
|
+
const tag = res.headers.get('etag');
|
|
159
|
+
const hit = catalogueCache.get(env.ASSETS);
|
|
160
|
+
if (tag && hit?.tag === tag && hit.origin === origin) { await res.body?.cancel(); return hit.value; }
|
|
161
|
+
const value = await res.json();
|
|
162
|
+
if (tag) catalogueCache.set(env.ASSETS, { tag, origin, value });
|
|
163
|
+
return value;
|
|
153
164
|
} catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
|
|
154
165
|
}
|
|
155
166
|
|
|
@@ -448,7 +459,10 @@ function notAHomie(request) {
|
|
|
448
459
|
* frame was opened with an agent's ticket (section 17): the game plays as that AI, named "<label> · AI".
|
|
449
460
|
*/
|
|
450
461
|
async function gameDocument(request, env, url, game, meta, cat, { agent = null } = {}) {
|
|
462
|
+
const embedded = url.searchParams.get('embed') === '1' && playerEnabled(cat, meta);
|
|
451
463
|
const asked = url.searchParams.get('room');
|
|
464
|
+
// A player card's game joins a public room, or `main`: the room Play itself falls back to when the lobby does not answer.
|
|
465
|
+
if (embedded && (!/^(?:pub-[1-9][0-9]*|main)$/.test(asked || '') || url.searchParams.has('t') || url.searchParams.has('watch') || url.searchParams.get('want') === 'screen')) return new Response('Player cards join public rooms through the player address.\n', { status: 400 });
|
|
452
466
|
// A room code the relay cannot use is refused here too, never swapped for another room.
|
|
453
467
|
if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
454
468
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
@@ -496,11 +510,11 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
496
510
|
// game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
|
|
497
511
|
...(meta?.saves && want === 'play' ? { saves: true } : {}),
|
|
498
512
|
// The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
|
|
499
|
-
// watcher has no shop; the shell
|
|
513
|
+
// watcher has no shop; the shell follows the studio shop policy for kids servers and television checkout.
|
|
500
514
|
...(!agent && !watching && shellShop(cat, game) ? { shop: true } : {}),
|
|
501
515
|
};
|
|
502
516
|
let html = await res.text();
|
|
503
|
-
const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
517
|
+
const head = `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
504
518
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
505
519
|
return new Response(html, {
|
|
506
520
|
headers: {
|
|
@@ -508,7 +522,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
508
522
|
'cache-control': 'no-store, no-transform',
|
|
509
523
|
'access-control-allow-origin': '*',
|
|
510
524
|
// The game runs in an opaque origin: it cannot read this site's storage or cookies.
|
|
511
|
-
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${frameAncestors(cat)}`,
|
|
525
|
+
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
|
|
512
526
|
},
|
|
513
527
|
});
|
|
514
528
|
}
|
|
@@ -567,7 +581,8 @@ function finish(res, path) {
|
|
|
567
581
|
const cache = res.headers.get('cache-control') ?? '';
|
|
568
582
|
const addNt = !/no-transform/i.test(cache);
|
|
569
583
|
// GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
|
|
570
|
-
const
|
|
584
|
+
const playerFrame = /^\/[a-z0-9][a-z0-9-]{0,39}\/play\/embed$/.test('/' + path.split('/').filter(Boolean).join('/')) && res.status === 200 && /frame-ancestors/.test(res.headers.get('content-security-policy') ?? '');
|
|
585
|
+
const addXfo = !playerFrame && !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
|
|
571
586
|
if (!addNt && !addXfo) return res;
|
|
572
587
|
const headers = new Headers(res.headers);
|
|
573
588
|
if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
|
|
@@ -770,7 +785,7 @@ async function route(request, env, ctx) {
|
|
|
770
785
|
: want === '/' ? ldScript(homeLd(cat, url.origin))
|
|
771
786
|
: game && parts.length === 1 ? ldScript(landingLd(cat, game, url.origin, { shop: await sellingShop(env, cat) }))
|
|
772
787
|
: ldScript([studioNode(cat, url.origin, { full: true })]);
|
|
773
|
-
return customPage(cat, html, { active, schema });
|
|
788
|
+
return customPage(cat, html, { active, schema, origin: url.origin, playerGame: game && parts.length === 1 ? game : null });
|
|
774
789
|
}
|
|
775
790
|
}
|
|
776
791
|
}
|
|
@@ -836,6 +851,12 @@ async function route(request, env, ctx) {
|
|
|
836
851
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
837
852
|
const cat = await getCat();
|
|
838
853
|
let sub = parts.slice(1).join('/');
|
|
854
|
+
if (sub === '__restart') {
|
|
855
|
+
if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
|
|
856
|
+
const room = url.searchParams.get('room') ?? '';
|
|
857
|
+
if (!ROOM_ID.test(room)) return new Response('invalid room', { status: 400 });
|
|
858
|
+
return env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__restart?game=${game}&room=${room}`, { method: 'POST' });
|
|
859
|
+
}
|
|
839
860
|
// Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
|
|
840
861
|
const settings = await settingsOf(env, { fresh: true });
|
|
841
862
|
// The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
|
|
@@ -933,9 +954,12 @@ async function route(request, env, ctx) {
|
|
|
933
954
|
if (body.join !== false && !d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
|
|
934
955
|
try { return json(await setMembership(env, game, srv.id, acct.slice(2), { join: body.join !== false, home: body.home === true })); } catch { return json({ ok: false, error: 'not-migrated', message: 'This studio\'s database has no servers yet (migration 0006).' }, 503); }
|
|
935
956
|
}
|
|
936
|
-
if (sub === '
|
|
937
|
-
|
|
938
|
-
const
|
|
957
|
+
if (sub === 'play/preview' && env.HOMIE_EMBED_PREVIEW === '1' && isLocalOrigin(url.origin)) return embedPreview(cat, meta, url.origin);
|
|
958
|
+
if (sub === 'tv' || sub === 'play' || sub === 'play/embed') {
|
|
959
|
+
const embed = sub === 'play/embed';
|
|
960
|
+
if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('This game does not play inside a post. Open its studio page to play.', cat);
|
|
961
|
+
const screen = !embed && (sub === 'tv' || url.searchParams.get('screen') === '1');
|
|
962
|
+
const asked = embed ? null : askedRoom;
|
|
939
963
|
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
|
|
940
964
|
if (!door.ok) return shut();
|
|
941
965
|
// Which server: the path's, the room's, else (Play) the player's home server, else public.
|
|
@@ -943,7 +967,7 @@ async function route(request, env, ctx) {
|
|
|
943
967
|
if (asked !== null && !srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
|
|
944
968
|
const named = asked !== null && !pooledRoom(asked);
|
|
945
969
|
if (!srv) {
|
|
946
|
-
const acct = await accountSub(request, env);
|
|
970
|
+
const acct = embed ? null : await accountSub(request, env);
|
|
947
971
|
const home = acct?.startsWith('p-') ? await homeOf(env, game, acct.slice(2)) : null;
|
|
948
972
|
const h = home ? serverById(home) : null;
|
|
949
973
|
srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
|
|
@@ -971,15 +995,15 @@ async function route(request, env, ctx) {
|
|
|
971
995
|
const local = isLocalOrigin(url.origin);
|
|
972
996
|
let qr = null;
|
|
973
997
|
if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
974
|
-
//
|
|
998
|
+
// Television checkout and kids-server visibility follow the studio shop policy.
|
|
975
999
|
const sh = shellShop(cat, game, { kids: pol.kids });
|
|
976
1000
|
let shopQr = null;
|
|
977
1001
|
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
978
|
-
return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
1002
|
+
return playPage(cat, meta, { origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
979
1003
|
}
|
|
980
1004
|
await countVisit(request, env, ctx, game, 'play');
|
|
981
1005
|
shareDaily(cat, url, ctx);
|
|
982
|
-
return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
|
|
1006
|
+
return playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
|
|
983
1007
|
}
|
|
984
1008
|
if (sub === 'watch') {
|
|
985
1009
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
@@ -996,7 +1020,7 @@ async function route(request, env, ctx) {
|
|
|
996
1020
|
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
997
1021
|
await countVisit(request, env, ctx, game, 'watch');
|
|
998
1022
|
shareDaily(cat, url, ctx);
|
|
999
|
-
return watchPage(cat, meta, { room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
1023
|
+
return watchPage(cat, meta, { origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
1000
1024
|
}
|
|
1001
1025
|
if (sub === 'api/watch') {
|
|
1002
1026
|
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -1030,6 +1054,7 @@ async function route(request, env, ctx) {
|
|
|
1030
1054
|
// game that named none then) and is matched with copies of that build. What it says is its own word, so the
|
|
1031
1055
|
// Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
|
|
1032
1056
|
const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
|
|
1057
|
+
if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
|
|
1033
1058
|
return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
|
|
1034
1059
|
}
|
|
1035
1060
|
if (sub === 'api/agent') {
|
|
@@ -1117,7 +1142,7 @@ async function route(request, env, ctx) {
|
|
|
1117
1142
|
const cur = versionOf(meta?.netplay?.version) ?? '';
|
|
1118
1143
|
const gv = versionOf(url.searchParams.get('gv')) ?? '';
|
|
1119
1144
|
const stall = Number(meta?.netplay?.stallMs) || 0;
|
|
1120
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}`;
|
|
1145
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
1121
1146
|
// The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
|
|
1122
1147
|
// tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
|
|
1123
1148
|
// uncaught error with nothing on it.
|
|
@@ -1130,6 +1155,7 @@ async function route(request, env, ctx) {
|
|
|
1130
1155
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
1131
1156
|
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
1132
1157
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
1158
|
+
if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
|
|
1133
1159
|
const who = await ticketSub(env, game, url.searchParams.get('t'));
|
|
1134
1160
|
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' } });
|
|
1135
1161
|
// An agent's frame (hands `self`): the game plays as that AI.
|
|
@@ -1182,6 +1208,18 @@ async function fallback(request, env, url, getCat) {
|
|
|
1182
1208
|
}
|
|
1183
1209
|
|
|
1184
1210
|
|
|
1211
|
+
/** A server-hosted room in play keeps the alarm this far ahead; a paused one ends this long after its pause (the relay's `forgetMs`). */
|
|
1212
|
+
const ROOM_ALARM_MS = 10 * 60_000;
|
|
1213
|
+
const ROOM_PAUSE_MS = 60_000;
|
|
1214
|
+
|
|
1215
|
+
/** Watchers, AI seats and restoration never extend an absence. */
|
|
1216
|
+
export function roomEndAt({ people, pausedAt, createdAt, restoredAt = null, savedAt = null, alarmAt = null }) {
|
|
1217
|
+
// Only people keep play alive. With no durable alarm, the last save bounds an unrecorded departure.
|
|
1218
|
+
const absence = pausedAt ?? (restoredAt !== null && alarmAt === null ? savedAt : null) ?? restoredAt ?? createdAt;
|
|
1219
|
+
return people > 0 ? Infinity : absence + ROOM_PAUSE_MS;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
|
|
1185
1223
|
/**
|
|
1186
1224
|
* TABLE — one public room: the netplay relay (room.mjs) for every browser in it.
|
|
1187
1225
|
* It keeps nothing per frame; seats and a checkpoint go to storage (a deploy
|
|
@@ -1218,8 +1256,23 @@ export class Table {
|
|
|
1218
1256
|
this.chatReadAt = 0;
|
|
1219
1257
|
this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
|
|
1220
1258
|
this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
|
|
1259
|
+
/**
|
|
1260
|
+
* Revision 10 (NETPLAY.md section 29): the host runtime of a server-hosted room (rules/host.ts), and when this
|
|
1261
|
+
* room next needs the object's one alarm (the house guides and the day's counters share it).
|
|
1262
|
+
*/
|
|
1263
|
+
this.hostRt = null;
|
|
1264
|
+
this.roomAlarmAt = 0;
|
|
1265
|
+
this.durable = null;
|
|
1266
|
+
this.roomSave = null;
|
|
1267
|
+
this.endedRoom = null;
|
|
1268
|
+
this.pausedAt = null;
|
|
1221
1269
|
ctx.blockConcurrencyWhile(async () => {
|
|
1270
|
+
// Only a confirmed missing alarm permits the save-age fallback. A read failure is not evidence of absence.
|
|
1271
|
+
this.recoveredAlarmAt = Infinity;
|
|
1272
|
+
try { this.recoveredAlarmAt = (await ctx.storage.getAlarm?.()) ?? null; }
|
|
1273
|
+
catch (error) { this.storageProblem(error); }
|
|
1222
1274
|
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
1275
|
+
if (this.saved?.retired === true) this.cleanupPending = true;
|
|
1223
1276
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
1224
1277
|
this.officeSaved = (await ctx.storage.get('office')) ?? null;
|
|
1225
1278
|
});
|
|
@@ -1235,15 +1288,15 @@ export class Table {
|
|
|
1235
1288
|
maxPlayers: max,
|
|
1236
1289
|
perIp: perAddress(max),
|
|
1237
1290
|
store: {
|
|
1238
|
-
save: (o) => { storage.put('net', o).catch(() => {}); },
|
|
1239
|
-
clear: () => {
|
|
1291
|
+
save: (o) => { if (this.hostRt) this.saveRoom(this.hostRt.save()); else if (!hostedGame(game)) storage.put('net', o).catch(() => {}); },
|
|
1292
|
+
clear: () => { this.clearRoom(); },
|
|
1240
1293
|
},
|
|
1241
1294
|
// The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
|
|
1242
1295
|
// election, a build change, and `failed` (a room operation that threw). A departure and a failure seconds apart
|
|
1243
1296
|
// are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
|
|
1244
1297
|
log: (line) => this.say(line),
|
|
1245
1298
|
});
|
|
1246
|
-
if (this.saved) this.room.restore(this.saved);
|
|
1299
|
+
if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
|
|
1247
1300
|
if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
|
|
1248
1301
|
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
1249
1302
|
// Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
|
|
@@ -1254,9 +1307,334 @@ export class Table {
|
|
|
1254
1307
|
this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
|
|
1255
1308
|
// A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
|
|
1256
1309
|
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
1310
|
+
// A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
|
|
1311
|
+
this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
|
|
1257
1312
|
return this.room;
|
|
1258
1313
|
}
|
|
1259
1314
|
|
|
1315
|
+
/* ------------------------------------------------------------ the server as host (NETPLAY.md section 29) */
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* A room of a server-hosted game gets its host runtime before its first socket is attached, so no browser is ever
|
|
1319
|
+
* elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
|
|
1320
|
+
* this build, or do not fit the contract, do not start: the room refuses joins and says why.
|
|
1321
|
+
*/
|
|
1322
|
+
ensureHost(room, hosted) {
|
|
1323
|
+
if (this.hostRt || !this.game) return;
|
|
1324
|
+
if (!hostedGame(this.game)) {
|
|
1325
|
+
if (hosted) room.failServerHost('This game runs its rules on the server, and this build of the site does not hold them. Build and deploy it again.');
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
let restoring = false;
|
|
1329
|
+
let pauseWriteFailed = false;
|
|
1330
|
+
try {
|
|
1331
|
+
const build = hostedBuild(this.game);
|
|
1332
|
+
try { this.durable ??= roomStore(this.ctx.storage); } catch (error) { this.storageProblem(error); }
|
|
1333
|
+
let saved = this.cleanupPending ? null : this.readRoom();
|
|
1334
|
+
restoring = Boolean(saved);
|
|
1335
|
+
let boot = { epoch: 0, count: 0 };
|
|
1336
|
+
try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
|
|
1337
|
+
if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
|
|
1338
|
+
const error = new Error('saved boot counter is invalid');
|
|
1339
|
+
if (saved) throw error;
|
|
1340
|
+
this.storageProblem(error); this.clearRoom(); boot = { epoch: 0, count: 0 };
|
|
1341
|
+
}
|
|
1342
|
+
if (saved && saved.buildHash !== build.build) boot.count = 0;
|
|
1343
|
+
// A restore's provisional pause is not a recorded departure. A stored alarm,
|
|
1344
|
+
// even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
|
|
1345
|
+
const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
|
|
1346
|
+
const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
|
|
1347
|
+
const expired = Date.now() >= restoredEnd;
|
|
1348
|
+
if (saved && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
|
|
1349
|
+
const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
|
|
1350
|
+
const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
|
|
1351
|
+
this.finishRoom(why);
|
|
1352
|
+
room.forget(); room.ended = null;
|
|
1353
|
+
// Only a holder returning to the old match is rematched. A new visitor uses this name immediately.
|
|
1354
|
+
if (!expired) room.endedMatch = { why, ver: build.build, tokens, until: Date.now() + ROOM_PAUSE_MS };
|
|
1355
|
+
this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
|
|
1356
|
+
}
|
|
1357
|
+
this.roomSave = saved;
|
|
1358
|
+
if (saved) {
|
|
1359
|
+
// The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
|
|
1360
|
+
room.seats.clear(); room.state.clear(); room.stateBytes = 0;
|
|
1361
|
+
room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
|
|
1362
|
+
this.pausedAt = restoredEnd - ROOM_PAUSE_MS;
|
|
1363
|
+
this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
|
|
1364
|
+
saved.pausedAt = this.pausedAt;
|
|
1365
|
+
// A failed pause write must never be mistaken for an invalid host save.
|
|
1366
|
+
try { this.durable.write(saved); } catch (error) { pauseWriteFailed = true; this.storageProblem(error); }
|
|
1367
|
+
}
|
|
1368
|
+
const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
|
|
1369
|
+
if (saved) this.durable.boot({ epoch, count: boot.count });
|
|
1370
|
+
this.hostCreatedAt = Date.now();
|
|
1371
|
+
let ticks = 0;
|
|
1372
|
+
this.hostRt = startHost(this.game, {
|
|
1373
|
+
...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
|
|
1374
|
+
store: { save: (bytes) => this.saveRoom(bytes) },
|
|
1375
|
+
onTick: () => {
|
|
1376
|
+
if (!saved) return;
|
|
1377
|
+
ticks += 1;
|
|
1378
|
+
if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
|
|
1379
|
+
if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
|
|
1380
|
+
if (this.pendingBoot && Date.now() >= (this.bootRetryAt ?? 0)) { try { this.durable.boot(this.pendingBoot); this.pendingBoot = null; this.bootFailures = 0; } catch (error) { this.bootRetryAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(this.bootFailures ?? 0, 6)); this.bootFailures = (this.bootFailures ?? 0) + 1; this.storageProblem(error); } }
|
|
1381
|
+
},
|
|
1382
|
+
send: (m, text) => room.hostFrame(m, text),
|
|
1383
|
+
log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
|
|
1384
|
+
onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
|
|
1385
|
+
onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.retimeRound(); this.armRoom(Date.now() + ROOM_ALARM_MS); },
|
|
1386
|
+
onEnd: (why) => this.endHosted(why),
|
|
1387
|
+
});
|
|
1388
|
+
room.setServerHost(this.hostRt);
|
|
1389
|
+
room.gameVer = build.build;
|
|
1390
|
+
room.setCurrent(build.build);
|
|
1391
|
+
if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
|
|
1392
|
+
if (saved) {
|
|
1393
|
+
this.retimeRound();
|
|
1394
|
+
room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
|
|
1395
|
+
room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
|
|
1396
|
+
}
|
|
1397
|
+
this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
this.hostRt?.stop(); this.hostRt = null;
|
|
1400
|
+
if (restoring) {
|
|
1401
|
+
this.storageProblem(error);
|
|
1402
|
+
room.forget(); this.clearRoom();
|
|
1403
|
+
this.skipSave = true;
|
|
1404
|
+
this.ensureHost(room, hosted);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
room.failServerHost(`This game's rules could not start: ${String(error?.message ?? error).slice(0, 160)}`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
saveRoom(bytes) {
|
|
1412
|
+
const now = Date.now();
|
|
1413
|
+
if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
|
|
1414
|
+
try {
|
|
1415
|
+
this.durable ??= roomStore(this.ctx.storage);
|
|
1416
|
+
const build = hostedBuild(this.game);
|
|
1417
|
+
const host = new TextDecoder().decode(bytes);
|
|
1418
|
+
const saved = { v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net: this.room.saved() };
|
|
1419
|
+
const clearing = this.cleanupPending;
|
|
1420
|
+
if (clearing) this.durable.clear();
|
|
1421
|
+
this.durable.write(saved);
|
|
1422
|
+
if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
|
|
1423
|
+
this.roomSave = saved; this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
|
|
1424
|
+
this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
|
|
1425
|
+
clearTimeout(this.saveRetry); this.saveRetry = null;
|
|
1426
|
+
this.storageHealth = { ok: !this.pendingBoot };
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
this.saveError = error;
|
|
1429
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
|
|
1430
|
+
this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
|
|
1431
|
+
this.storageProblem(error);
|
|
1432
|
+
if (!this.saveRetry) {
|
|
1433
|
+
this.saveRetry = setTimeout(() => {
|
|
1434
|
+
this.saveRetry = null;
|
|
1435
|
+
if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
|
|
1436
|
+
}, delay);
|
|
1437
|
+
this.saveRetry?.unref?.();
|
|
1438
|
+
}
|
|
1439
|
+
throw error;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
retimeRound() {
|
|
1444
|
+
if (!this.hostRt) return;
|
|
1445
|
+
const r = this.hostRt.core.save().round;
|
|
1446
|
+
const period = 1000 / hostedBuild(this.game).settings.tickHz;
|
|
1447
|
+
this.room.hostFrame({ t: 'round', round: { ...this.room.lastRound, n: r[0], phase: r[1] ? 'live' : 'over', endsAt: Date.now() + (r[2] - this.hostRt.tick) * period, startedAt: Date.now() + (r[3] - this.hostRt.tick) * period } });
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
storageProblem(error) {
|
|
1451
|
+
this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
|
|
1452
|
+
if (Date.now() < (this.storageLogAt ?? 0)) return;
|
|
1453
|
+
this.storageLogAt = Date.now() + 1000;
|
|
1454
|
+
this.say({ ev: 'persist-failed', build: hostedBuild(this.game)?.build, error: this.storageHealth.message });
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
readRoom(validateHost = false) {
|
|
1458
|
+
if (this.skipSave) { this.skipSave = false; return null; }
|
|
1459
|
+
try {
|
|
1460
|
+
if (this.durable?.boots()?.retired === true) { this.clearRoom(); return null; }
|
|
1461
|
+
const saved = this.durable?.read();
|
|
1462
|
+
if (!saved) return null;
|
|
1463
|
+
if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
|
|
1464
|
+
this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
|
|
1465
|
+
!Number.isFinite(saved.at) || saved.at < 0 || saved.at > Date.now() + 5000 || saved.pausedAt !== null && (!Number.isFinite(saved.pausedAt) || saved.pausedAt < 0 || saved.pausedAt > Date.now() + 5000) ||
|
|
1466
|
+
saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
|
|
1467
|
+
saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
|
|
1468
|
+
saved.at = Math.min(saved.at, Date.now());
|
|
1469
|
+
if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
|
|
1470
|
+
if (hostedGame(saved.game) && saved.stateHash !== hostedBuild(saved.game).stateHash) return saved;
|
|
1471
|
+
this.game ??= saved.game; this.code ??= saved.room;
|
|
1472
|
+
const seats = saved.net.seats;
|
|
1473
|
+
const cap = hostedBuild(saved.game)?.seats ?? 32;
|
|
1474
|
+
if (saved.net.v !== 1 || saved.net.room !== saved.room || !Array.isArray(seats) || seats.length > cap ||
|
|
1475
|
+
new Set(seats.map((s) => s?.[0])).size !== seats.length || new Set(seats.map((s) => s?.[1])).size !== seats.length ||
|
|
1476
|
+
seats.some((s) => !Array.isArray(s) || !Number.isInteger(s[0]) || s[0] < 0 || s[0] >= cap || typeof s[1] !== 'string' || s[1].length > 128 || typeof s[2] !== 'string' || s[2].length > 40 || s[4] !== null && (!Number.isSafeInteger(s[4]) || s[4] < 0) || s[5] !== null && !Number.isFinite(s[5]))) throw new Error('saved seats do not fit this room');
|
|
1477
|
+
if (validateHost && hostedGame(saved.game) && saved.stateHash === hostedBuild(saved.game).stateHash) {
|
|
1478
|
+
const probe = startHost(saved.game, { restore: new TextEncoder().encode(saved.hostText ?? JSON.stringify(saved.host)), send() {} });
|
|
1479
|
+
probe.stop();
|
|
1480
|
+
} else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
|
|
1481
|
+
return saved;
|
|
1482
|
+
} catch (error) { this.storageProblem(error); this.clearRoom(); return null; }
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
clearRoom() {
|
|
1486
|
+
this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
|
|
1487
|
+
this.roomSave = null; this.saved = null; this.pendingBoot = null;
|
|
1488
|
+
try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
|
|
1489
|
+
catch (error) { this.retryCleanup(error); }
|
|
1490
|
+
this.queueNetCleanup(this.cleanupPending);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
queueNetCleanup(retired) {
|
|
1494
|
+
// The key-value record can preserve retirement when SQL writes, not just deletes, are unavailable.
|
|
1495
|
+
// Serialize it with a later successful save so an older failure cannot retire the new match.
|
|
1496
|
+
const generation = this.cleanupGeneration;
|
|
1497
|
+
this.netCleanup = (this.netCleanup ?? Promise.resolve()).then(() => {
|
|
1498
|
+
if (generation !== this.cleanupGeneration) return;
|
|
1499
|
+
return retired ? this.ctx.storage.put('net', { retired: true }) : this.ctx.storage.delete('net');
|
|
1500
|
+
}).then(() => {
|
|
1501
|
+
if (generation !== this.cleanupGeneration) return;
|
|
1502
|
+
this.netCleanupFailed = false;
|
|
1503
|
+
clearTimeout(this.netCleanupRetry); this.netCleanupRetry = null;
|
|
1504
|
+
if (!this.cleanupPending) this.cleanupFailures = 0;
|
|
1505
|
+
}).catch((error) => {
|
|
1506
|
+
if (generation !== this.cleanupGeneration) return;
|
|
1507
|
+
if (retired) { this.retryCleanup(error); return; }
|
|
1508
|
+
// Removing an old key marker is not permission to delete a newer SQL match.
|
|
1509
|
+
this.netCleanupFailed = true; this.storageProblem(error);
|
|
1510
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
|
|
1511
|
+
this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
|
|
1512
|
+
this.armRoom(Date.now() + delay);
|
|
1513
|
+
if (!this.netCleanupRetry) {
|
|
1514
|
+
this.netCleanupRetry = setTimeout(() => { this.netCleanupRetry = null; if (generation === this.cleanupGeneration) this.queueNetCleanup(false); }, delay);
|
|
1515
|
+
this.netCleanupRetry?.unref?.();
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
this.ctx.waitUntil(this.netCleanup);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
retryCleanup(error) {
|
|
1522
|
+
this.cleanupPending = true; this.storageProblem(error);
|
|
1523
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
|
|
1524
|
+
this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
|
|
1525
|
+
this.armRoom(Date.now() + delay);
|
|
1526
|
+
// An alarm write can fail as well. Keep the end pending for this object's lifetime.
|
|
1527
|
+
if (!this.cleanupRetry) {
|
|
1528
|
+
this.cleanupRetry = setTimeout(() => { this.cleanupRetry = null; if (this.cleanupPending) this.clearRoom(); }, delay);
|
|
1529
|
+
this.cleanupRetry?.unref?.();
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
finishRoom(why) {
|
|
1534
|
+
this.hostRt?.stop(); this.hostRt = null;
|
|
1535
|
+
clearInterval(this.timer); this.timer = null;
|
|
1536
|
+
clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
|
|
1537
|
+
this.clearRoom();
|
|
1538
|
+
const room = this.room;
|
|
1539
|
+
if (room) {
|
|
1540
|
+
this.officeSaved = room.officeSaved();
|
|
1541
|
+
room.ended = { why, ver: hostedBuild(this.game)?.build ?? null };
|
|
1542
|
+
for (const w of room.watchers) { try { w.close(1000, 'room-over'); } catch { /* gone */ } }
|
|
1543
|
+
room.watchers.clear();
|
|
1544
|
+
}
|
|
1545
|
+
this.tellLobby({ ended: true });
|
|
1546
|
+
this.say({ ev: 'host-over', why, build: hostedBuild(this.game)?.build, ...(why === 'restore loop' ? { message: 'This room shares its isolate and may not be the cause.' } : {}) });
|
|
1547
|
+
this.room = null; this.endedRoom = null; this.pausedAt = null;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
|
|
1551
|
+
armRoom(at) {
|
|
1552
|
+
this.roomAlarmAt = at;
|
|
1553
|
+
this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
|
|
1554
|
+
const cur = await this.ctx.storage.getAlarm();
|
|
1555
|
+
if (cur === null || cur > at) await this.ctx.storage.setAlarm(at);
|
|
1556
|
+
this.alarmFailures = 0;
|
|
1557
|
+
}).catch((error) => {
|
|
1558
|
+
this.storageProblem(error);
|
|
1559
|
+
if (this.alarmRetry) return;
|
|
1560
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.alarmFailures ?? 0, 6));
|
|
1561
|
+
this.alarmFailures = (this.alarmFailures ?? 0) + 1;
|
|
1562
|
+
this.alarmRetry = setTimeout(() => { this.alarmRetry = null; this.armRoom(this.roomAlarmAt); }, delay);
|
|
1563
|
+
this.alarmRetry?.unref?.();
|
|
1564
|
+
});
|
|
1565
|
+
this.ctx.waitUntil(this.alarmWrite);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* The rules failed again and again (rules/host.ts: the budget on every tick for two seconds, or slow ticks for
|
|
1570
|
+
* five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
|
|
1571
|
+
*/
|
|
1572
|
+
endHosted(why) {
|
|
1573
|
+
const room = this.room;
|
|
1574
|
+
if (!room) return;
|
|
1575
|
+
this.finishRoom(why);
|
|
1576
|
+
this.hostRt = null;
|
|
1577
|
+
for (const c of [...room.clients.values()]) {
|
|
1578
|
+
if (c.helloed) room.error(c, 'room-over', 'This room ended. Joining a fresh room.', { rematch: true, ver: room.ended.ver });
|
|
1579
|
+
room.clients.delete(c.id);
|
|
1580
|
+
try { c.conn.close(1011, 'room-over'); } catch { /* gone */ }
|
|
1581
|
+
}
|
|
1582
|
+
room.emptySince = Date.now();
|
|
1583
|
+
room.forget();
|
|
1584
|
+
this.report();
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* The room's part of the alarm (section 4.5 of the design). A room nobody came back to within a minute of its
|
|
1589
|
+
* pause ends: the relay forgets it and its stored seats go (`office` and `recorded` stay). A room in play keeps an
|
|
1590
|
+
* alarm ahead of it, so one that Cloudflare restarted and nobody returned to is still ended. After a restart there
|
|
1591
|
+
* is no room in memory: stored seats of a server-hosted room that nobody reconnected to are deleted.
|
|
1592
|
+
*/
|
|
1593
|
+
async roomAlarm() {
|
|
1594
|
+
try { await this.runRoomAlarm(); this.roomAlarmFailures = 0; }
|
|
1595
|
+
catch (error) {
|
|
1596
|
+
this.storageProblem(error);
|
|
1597
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.roomAlarmFailures ?? 0, 6));
|
|
1598
|
+
this.roomAlarmFailures = (this.roomAlarmFailures ?? 0) + 1;
|
|
1599
|
+
this.armRoom(Date.now() + delay);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async runRoomAlarm() {
|
|
1604
|
+
if (this.netCleanupFailed) this.queueNetCleanup(false);
|
|
1605
|
+
if (this.cleanupPending) this.clearRoom();
|
|
1606
|
+
const now = Date.now();
|
|
1607
|
+
const room = this.room;
|
|
1608
|
+
if (!room || !this.hostRt) {
|
|
1609
|
+
if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
|
|
1610
|
+
this.durable ??= roomStore(this.ctx.storage);
|
|
1611
|
+
const saved = this.cleanupPending ? null : this.readRoom(true);
|
|
1612
|
+
if (saved) {
|
|
1613
|
+
this.game = saved.game; this.code = saved.room;
|
|
1614
|
+
if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
|
|
1615
|
+
// No saved pause means people were present. The first cold wake starts the absence period.
|
|
1616
|
+
if (saved.pausedAt === null) {
|
|
1617
|
+
this.pausedAt ??= now; saved.pausedAt = this.pausedAt; this.durable.write(saved);
|
|
1618
|
+
}
|
|
1619
|
+
const endAt = roomEndAt({ people: 0, pausedAt: saved.pausedAt, createdAt: now });
|
|
1620
|
+
if (now < endAt) { this.armRoom(endAt); return; }
|
|
1621
|
+
this.finishRoom('nobody-returned');
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (this.saved?.hosted === 'server') { const was = this.saved.room ?? null; this.saved = null; await this.ctx.storage.delete('net').catch(() => {}); this.say({ ev: 'host-over', why: 'nobody-returned', room: was }); }
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
if (!this.hostRt) return;
|
|
1628
|
+
const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
|
|
1629
|
+
if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
|
|
1630
|
+
if (now < endAt) { this.armRoom(endAt); return; }
|
|
1631
|
+
// Nobody returned: neither watchers nor AI seats keep the paused room.
|
|
1632
|
+
this.finishRoom('nobody-returned');
|
|
1633
|
+
for (const c of [...room.clients.values()]) { if (c.helloed) room.error(c, 'room-over', 'Everyone left, so this room ended.', { rematch: true }); room.clients.delete(c.id); try { c.conn.close(1000, 'room-over'); } catch { /* gone */ } }
|
|
1634
|
+
room.forget();
|
|
1635
|
+
this.report();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1260
1638
|
/**
|
|
1261
1639
|
* One log line of this room: `{ at, ev, game, room, … }`. Never a seat token, a ticket, a browser key or an address;
|
|
1262
1640
|
* a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
|
|
@@ -1420,7 +1798,8 @@ export class Table {
|
|
|
1420
1798
|
if (!talks(room.policy) || !room.vocab || !(room.policy.guides > 0)) return;
|
|
1421
1799
|
this.house = new HouseAgents({
|
|
1422
1800
|
room, env: this.env,
|
|
1423
|
-
|
|
1801
|
+
// The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
|
|
1802
|
+
setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(() => {}); },
|
|
1424
1803
|
budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
|
|
1425
1804
|
log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
|
|
1426
1805
|
});
|
|
@@ -1485,6 +1864,7 @@ export class Table {
|
|
|
1485
1864
|
// A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
|
|
1486
1865
|
try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
|
|
1487
1866
|
try { await this.flushBrain(); } catch { /* counted next time */ }
|
|
1867
|
+
try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
|
|
1488
1868
|
}
|
|
1489
1869
|
|
|
1490
1870
|
async fetch(request) {
|
|
@@ -1492,10 +1872,16 @@ export class Table {
|
|
|
1492
1872
|
const game = url.searchParams.get('game');
|
|
1493
1873
|
const code = url.searchParams.get('room');
|
|
1494
1874
|
const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
|
|
1875
|
+
if (url.pathname === '/__restart') {
|
|
1876
|
+
if (this.env.HOMIE_PREVIEW !== '1' || request.method !== 'POST') return new Response('not found', { status: 404 });
|
|
1877
|
+
// No last-chance save: this measures exactly what a real abrupt restart can lose.
|
|
1878
|
+
setTimeout(() => this.ctx.abort('Preview forced restart'), 25);
|
|
1879
|
+
return json({ ok: true });
|
|
1880
|
+
}
|
|
1495
1881
|
const room = this.roomFor(game, code, max);
|
|
1496
1882
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1497
1883
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1498
|
-
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
1884
|
+
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
1499
1885
|
// 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.
|
|
1500
1886
|
if (url.pathname === '/__chat') {
|
|
1501
1887
|
const id = url.searchParams.get('id');
|
|
@@ -1521,6 +1907,8 @@ export class Table {
|
|
|
1521
1907
|
// Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
|
|
1522
1908
|
const said = url.searchParams.has('cur');
|
|
1523
1909
|
if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
|
|
1910
|
+
// Restore before applying the Worker's current policy: a save must not replace a newer policy.
|
|
1911
|
+
this.ensureHost(room, url.searchParams.get('host') === 'server');
|
|
1524
1912
|
// The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
|
|
1525
1913
|
const pol = decodeFacts(url.searchParams.get('pol'));
|
|
1526
1914
|
// The room's chat rules ride beside it (they may be longer than a policy): section 19.
|
|
@@ -1633,6 +2021,8 @@ export class Table {
|
|
|
1633
2021
|
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
|
|
1634
2022
|
// Room chat's counts, once a minute (section 19).
|
|
1635
2023
|
if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
|
|
2024
|
+
// A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
|
|
2025
|
+
if (n % 1200 === 0 && this.hostRt?.running) this.armRoom(Date.now() + ROOM_ALARM_MS);
|
|
1636
2026
|
if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
|
|
1637
2027
|
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); this.ctx.waitUntil(this.flushChat().catch(() => {})); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
|
|
1638
2028
|
}, 250);
|
|
@@ -1665,7 +2055,7 @@ export class Table {
|
|
|
1665
2055
|
/** Tell the Lobby how many players this room has, when it changes. */
|
|
1666
2056
|
report() {
|
|
1667
2057
|
const room = this.room;
|
|
1668
|
-
if (!room || !this.game) return;
|
|
2058
|
+
if (!room || !this.game || this.endedRoom) return;
|
|
1669
2059
|
const counts = room.facts().counts;
|
|
1670
2060
|
// Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
|
|
1671
2061
|
const players = counts.players;
|
|
@@ -1868,6 +2258,7 @@ export class Lobby {
|
|
|
1868
2258
|
if (url.pathname === '/report' && request.method === 'POST') {
|
|
1869
2259
|
const body = await request.json().catch(() => ({}));
|
|
1870
2260
|
if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
|
|
2261
|
+
if (body.ended === true) { this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
|
|
1871
2262
|
// The owner closed this room: matched with nobody until then (and forgotten as a public room).
|
|
1872
2263
|
if (Number.isFinite(body.closed)) {
|
|
1873
2264
|
if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
|