@homie-rocks/studio 0.35.1 → 0.37.0
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 +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { appAccess, appRecordsRoute } from './app-records.mjs';
|
|
2
|
+
import { appRole, openPath } from './app-format.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* @homie-rocks/studio/worker — a studio's own site, on the studio's own Cloudflare.
|
|
3
5
|
*
|
|
@@ -173,6 +175,10 @@ async function sellingShop(env, cat) {
|
|
|
173
175
|
try { return (await readiness(env, shop)).ready ? shop : null; } catch { return null; }
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
async function availableShellShop(env, cat, game, options) {
|
|
179
|
+
return await sellingShop(env, cat) ? shellShop(cat, game, options) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
176
182
|
async function postsOf(env, origin) {
|
|
177
183
|
try {
|
|
178
184
|
const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
|
|
@@ -485,6 +491,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
485
491
|
const ver = versionOf(meta?.netplay?.version);
|
|
486
492
|
// The play page's switches for the game (section 24): the allowed names only, checked again here.
|
|
487
493
|
const params = paramsFrom(url.searchParams, meta);
|
|
494
|
+
if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
|
|
488
495
|
const cfg = {
|
|
489
496
|
v: 1,
|
|
490
497
|
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
|
|
@@ -511,7 +518,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
511
518
|
...(meta?.saves && want === 'play' ? { saves: true } : {}),
|
|
512
519
|
// The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
|
|
513
520
|
// watcher has no shop; the shell follows the studio shop policy for kids servers and television checkout.
|
|
514
|
-
...(!agent && !watching &&
|
|
521
|
+
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
515
522
|
};
|
|
516
523
|
let html = await res.text();
|
|
517
524
|
const head = `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
@@ -601,6 +608,8 @@ export default {
|
|
|
601
608
|
const url = new URL(request.url);
|
|
602
609
|
// A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
|
|
603
610
|
// unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
|
|
611
|
+
const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
|
|
612
|
+
if (appRecords && request.method === 'OPTIONS') return appCors(request, new Response(null, { status: 204, headers: { 'access-control-allow-methods': 'GET, POST, PUT, DELETE', 'access-control-allow-headers': 'content-type' } }));
|
|
604
613
|
const appLobby = isAppOrigin(request) && /^\/[^/]+\/api\/lobby\/?$/.test(url.pathname);
|
|
605
614
|
let res;
|
|
606
615
|
try { res = finish(await route(request, env, ctx), url.pathname); } catch (error) {
|
|
@@ -608,7 +617,7 @@ export default {
|
|
|
608
617
|
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
|
|
609
618
|
res = json({ ok: false, error: 'failed' }, 500);
|
|
610
619
|
}
|
|
611
|
-
if (appLobby) return appCors(request, res);
|
|
620
|
+
if (appLobby || appRecords) return appCors(request, res);
|
|
612
621
|
// A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
|
|
613
622
|
// that pays referrals. The page is answered first; a cookie is added to it only then.
|
|
614
623
|
if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
|
|
@@ -691,7 +700,7 @@ async function route(request, env, ctx) {
|
|
|
691
700
|
// its mark is here). A Preview says it is one.
|
|
692
701
|
...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
|
|
693
702
|
// Only the public games (a private or invite-only game leaves the directory the next time it reads this).
|
|
694
|
-
games: await Promise.all((cat.games ?? []).map(async (g) => {
|
|
703
|
+
games: await Promise.all((cat.games ?? []).filter((g) => g.kind !== 'app').map(async (g) => {
|
|
695
704
|
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
696
705
|
const cover = gameCover(g);
|
|
697
706
|
// The licence the game names for itself (an SPDX id), when it names one (worker/license.mjs). Nothing here
|
|
@@ -709,6 +718,7 @@ async function route(request, env, ctx) {
|
|
|
709
718
|
...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
|
|
710
719
|
};
|
|
711
720
|
})),
|
|
721
|
+
...((cat.games ?? []).some((g) => g.kind === 'app') ? { apps: cat.games.filter((g) => g.kind === 'app').map((g) => ({ id: g.id, kind: 'app', name: g.name, blurb: g.blurb, page: `${url.origin}/${g.id}/`, open: `${url.origin}/${g.id}/open`, wall: `${url.origin}/${g.id}/tv`, surfaces: Object.keys(g.surfaces ?? {}), ...(g.built ? { build: g.built } : {}) })) } : {}),
|
|
712
722
|
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
|
|
713
723
|
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
|
|
714
724
|
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
@@ -829,7 +839,7 @@ async function route(request, env, ctx) {
|
|
|
829
839
|
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
830
840
|
return homePage(cat, { origin: url.origin, rooms, live });
|
|
831
841
|
}
|
|
832
|
-
if (['games', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
|
|
842
|
+
if (['games', 'apps', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
|
|
833
843
|
const cat = await getCat();
|
|
834
844
|
const has = sectionsOf(cat).some((s) => s.key === parts[0]);
|
|
835
845
|
if (!has) return notFoundPage(parts[0] === 'posts' ? 'This studio has no posts yet.' : 'This studio has no games yet.', cat);
|
|
@@ -837,7 +847,7 @@ async function route(request, env, ctx) {
|
|
|
837
847
|
await countVisit(request, env, ctx, parts[0]);
|
|
838
848
|
if (parts[0] === 'posts') return postsPage(cat, { origin: url.origin });
|
|
839
849
|
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
840
|
-
return parts[0]
|
|
850
|
+
return ['games', 'apps'].includes(parts[0]) ? gamesPage(cat, { origin: url.origin, live, kind: parts[0] === 'apps' ? 'app' : 'game' }) : roomsPage(cat, { origin: url.origin, rooms });
|
|
841
851
|
}
|
|
842
852
|
if (parts[0] === 'posts' && parts.length === 2 && POST_SLUG.test(parts[1])) {
|
|
843
853
|
const cat = await getCat();
|
|
@@ -857,6 +867,7 @@ async function route(request, env, ctx) {
|
|
|
857
867
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
858
868
|
const cat = await getCat();
|
|
859
869
|
let sub = parts.slice(1).join('/');
|
|
870
|
+
if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
|
|
860
871
|
if (sub === '__restart') {
|
|
861
872
|
if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
|
|
862
873
|
const room = url.searchParams.get('room') ?? '';
|
|
@@ -869,6 +880,19 @@ async function route(request, env, ctx) {
|
|
|
869
880
|
// with access, and the owner may have made its rooms smaller than the game's own seats.
|
|
870
881
|
const launch = launchOf(meta, settings, env);
|
|
871
882
|
const max = seatsFor(meta, settings);
|
|
883
|
+
if (meta.kind === 'app') {
|
|
884
|
+
if (launch !== 'public' && !(await accessOf(request, env, game, launch)).ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
885
|
+
const api = await appRecordsRoute(request, env, meta, url, sub);
|
|
886
|
+
if (api) return api;
|
|
887
|
+
if (['play', 'tv', 'watch'].includes(sub)) {
|
|
888
|
+
let a;
|
|
889
|
+
try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
|
|
890
|
+
if (!a.ok) {
|
|
891
|
+
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
892
|
+
return json({ ok: false, error: a.error }, a.status);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
872
896
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
873
897
|
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
874
898
|
|
|
@@ -963,7 +987,7 @@ async function route(request, env, ctx) {
|
|
|
963
987
|
if (sub === 'play/preview' && env.HOMIE_EMBED_PREVIEW === '1' && isLocalOrigin(url.origin)) return embedPreview(cat, meta, url.origin);
|
|
964
988
|
if (sub === 'tv' || sub === 'play' || sub === 'play/embed') {
|
|
965
989
|
const embed = sub === 'play/embed';
|
|
966
|
-
if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('This game does not play inside a post. Open its studio page to play.', cat);
|
|
990
|
+
if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage(meta.kind === 'app' ? 'This app does not open inside a post. Open its studio page.' : 'This game does not play inside a post. Open its studio page to play.', cat);
|
|
967
991
|
const screen = !embed && (sub === 'tv' || url.searchParams.get('screen') === '1');
|
|
968
992
|
const asked = embed ? null : askedRoom;
|
|
969
993
|
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
|
|
@@ -996,20 +1020,20 @@ async function route(request, env, ctx) {
|
|
|
996
1020
|
if (!room) {
|
|
997
1021
|
try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
998
1022
|
}
|
|
999
|
-
const joinUrl = `${url.origin}
|
|
1023
|
+
const joinUrl = `${url.origin}${openPath(meta)}${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
1000
1024
|
// Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
|
|
1001
1025
|
const local = isLocalOrigin(url.origin);
|
|
1002
1026
|
let qr = null;
|
|
1003
1027
|
if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
1004
1028
|
// Television checkout and kids-server visibility follow the studio shop policy.
|
|
1005
|
-
const sh =
|
|
1029
|
+
const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
|
|
1006
1030
|
let shopQr = null;
|
|
1007
1031
|
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
1008
1032
|
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 });
|
|
1009
1033
|
}
|
|
1010
1034
|
await countVisit(request, env, ctx, game, 'play');
|
|
1011
1035
|
shareDaily(cat, url, ctx);
|
|
1012
|
-
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:
|
|
1036
|
+
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: await availableShellShop(env, cat, game, { kids: pol.kids }) });
|
|
1013
1037
|
}
|
|
1014
1038
|
if (sub === 'watch') {
|
|
1015
1039
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
@@ -1148,7 +1172,7 @@ async function route(request, env, ctx) {
|
|
|
1148
1172
|
const cur = versionOf(meta?.netplay?.version) ?? '';
|
|
1149
1173
|
const gv = versionOf(url.searchParams.get('gv')) ?? '';
|
|
1150
1174
|
const stall = Number(meta?.netplay?.stallMs) || 0;
|
|
1151
|
-
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' : ''}`;
|
|
1175
|
+
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?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
1152
1176
|
// The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
|
|
1153
1177
|
// tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
|
|
1154
1178
|
// uncaught error with nothing on it.
|
|
@@ -1388,7 +1412,7 @@ export class Table {
|
|
|
1388
1412
|
send: (m, text) => room.hostFrame(m, text),
|
|
1389
1413
|
log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
|
|
1390
1414
|
onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
|
|
1391
|
-
onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.
|
|
1415
|
+
onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.armRoom(Date.now() + ROOM_ALARM_MS); },
|
|
1392
1416
|
onEnd: (why) => this.endHosted(why),
|
|
1393
1417
|
});
|
|
1394
1418
|
room.setServerHost(this.hostRt);
|
|
@@ -1396,7 +1420,6 @@ export class Table {
|
|
|
1396
1420
|
room.setCurrent(build.build);
|
|
1397
1421
|
if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
|
|
1398
1422
|
if (saved) {
|
|
1399
|
-
this.retimeRound();
|
|
1400
1423
|
room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
|
|
1401
1424
|
room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
|
|
1402
1425
|
}
|
|
@@ -1446,13 +1469,6 @@ export class Table {
|
|
|
1446
1469
|
}
|
|
1447
1470
|
}
|
|
1448
1471
|
|
|
1449
|
-
retimeRound() {
|
|
1450
|
-
if (!this.hostRt) return;
|
|
1451
|
-
const r = this.hostRt.core.save().round;
|
|
1452
|
-
const period = 1000 / hostedBuild(this.game).settings.tickHz;
|
|
1453
|
-
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 } });
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
1472
|
storageProblem(error) {
|
|
1457
1473
|
this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
|
|
1458
1474
|
if (Date.now() < (this.storageLogAt ?? 0)) return;
|
|
@@ -1908,8 +1924,12 @@ export class Table {
|
|
|
1908
1924
|
this.report();
|
|
1909
1925
|
return json(res);
|
|
1910
1926
|
}
|
|
1927
|
+
room.rules = url.searchParams.get('rules') === '1';
|
|
1928
|
+
// Any tick rate a game may declare (1 to 60): the relay's allowances are the page's, from the same table.
|
|
1929
|
+
const hz = Number(url.searchParams.get('hz'));
|
|
1930
|
+
room.tickHz = Number.isInteger(hz) && hz >= 1 && hz <= 60 ? hz : 20;
|
|
1911
1931
|
// The owner's room size: a room already open takes it from the next visitor on.
|
|
1912
|
-
if (room.seatCap !== max) room.setSeats(max, perAddress(max));
|
|
1932
|
+
if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max));
|
|
1913
1933
|
// Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
|
|
1914
1934
|
const said = url.searchParams.has('cur');
|
|
1915
1935
|
if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Size caps for up to 16 seats: UTF-8 bytes in rules rooms, JSON string length in legacy rooms. */
|
|
2
|
+
export const LIMITS = Object.freeze({
|
|
3
|
+
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
4
|
+
// Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
|
|
5
|
+
say: 1536, react: 256,
|
|
6
|
+
// A game's own decision (section 20): a few typed questions about its state.
|
|
7
|
+
decide: 6144,
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
|
|
11
|
+
* for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
|
|
12
|
+
* a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
|
|
13
|
+
*/
|
|
14
|
+
export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
|
|
15
|
+
export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
|
|
16
|
+
|
|
17
|
+
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
|
|
18
|
+
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
19
|
+
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
20
|
+
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
21
|
+
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
22
|
+
/** Runtime output only. Player allowances above remain unchanged. */
|
|
23
|
+
export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end']);
|
|
24
|
+
export const rulesRates = (tickHz = 20, seats = 8) => ({
|
|
25
|
+
// A snapshot a tick, as the server sends, and a quarter over for the ticks a late timer runs together.
|
|
26
|
+
snap: Math.max(30, Math.ceil(tickHz * 1.25)), ckpt: 4, state: tickHz + 64,
|
|
27
|
+
ev: Math.ceil((tickHz + seats * EV_RATES.replica + 32) / 0.8), round: 8,
|
|
28
|
+
// A decision a tick is what one declared ask can make (the next waits for the answer), as on a server host.
|
|
29
|
+
roster: seats * 2 + 8, decide: Math.ceil((tickHz + seats * 5 + 8) / 0.8), caps: 4, 'rules-end': 4,
|
|
30
|
+
});
|
|
31
|
+
export const rulesCaps = seats => Object.fromEntries(RULES_FRAMES.map(t => [t, capOf(t, seats)]));
|
|
32
|
+
/** Two seconds of headroom absorb timer stalls and transport batching. */
|
|
33
|
+
export function takeRulesToken(buckets, kind, rate, now, burst = rate * 2) {
|
|
34
|
+
let b = buckets.get(kind);
|
|
35
|
+
if (!b) { b = { tokens: burst, at: now }; buckets.set(kind, b); }
|
|
36
|
+
b.tokens = Math.min(burst, b.tokens + Math.max(0, now - b.at) * rate / 1000);
|
|
37
|
+
b.at = now;
|
|
38
|
+
if (b.tokens + 1e-9 < 1) return Math.ceil((1 - b.tokens) * 1000 / rate);
|
|
39
|
+
b.tokens = Math.max(0, b.tokens - 1);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
package/worker/pages.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { appWords, openPath } from './app-format.mjs';
|
|
2
|
+
import { APP_SHELL_JS } from './app-shell.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* The play shell: the page around a game's sandboxed frame at /<game>/play and /<game>/tv. Every other page is
|
|
3
5
|
* worker/site.mjs. Every name a player typed is escaped.
|
|
@@ -46,7 +48,7 @@ export function noWatchPage(cat, g) {
|
|
|
46
48
|
title: `${g.name} is not shown to watchers`, page: 'no-watch', status: 404,
|
|
47
49
|
main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>${esc(g.name)} is played, not watched</h1>
|
|
48
50
|
<p class="lead">Its rooms are not shown to watchers: what each player sees stays theirs. Press Play and you are in a room, with bots in the empty seats.</p>
|
|
49
|
-
<div class="keys"><a class="btn" href="
|
|
51
|
+
<div class="keys"><a class="btn" href="${esc(openPath(g))}" data-play>Play ${esc(g.name)}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -90,6 +92,7 @@ export function sharePlaces(value) {
|
|
|
90
92
|
* covers the middle of the screen or a thumb.
|
|
91
93
|
*/
|
|
92
94
|
export function playPage(cat, g, { embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
95
|
+
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
93
96
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
94
97
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
95
98
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -168,6 +171,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
168
171
|
[hidden] { display: none !important; }
|
|
169
172
|
${arrive.css}
|
|
170
173
|
${embed ? `html,body{touch-action:auto}.room.at-top-center{transform:none;left:auto;right:max(8px,env(safe-area-inset-right))}.embed-open{display:flex;align-items:center;min-height:44px;color:inherit}.embed-note{font:12px/1.4 system-ui;overflow-wrap:anywhere}.sheet{position:fixed;inset:calc(max(8px,env(safe-area-inset-top)) + 48px) max(8px,env(safe-area-inset-right)) auto auto;max-width:calc(100vw - max(8px,env(safe-area-inset-left)) - max(8px,env(safe-area-inset-right)));max-height:calc(100dvh - max(8px,env(safe-area-inset-top)) - 48px - max(8px,env(safe-area-inset-bottom)));overflow:auto;overscroll-behavior:contain;touch-action:pan-y}.sheet.more::after{content:'▾ more below';position:sticky;bottom:-12px;display:block;margin:0 -12px -12px;padding:16px 0 5px;text-align:center;font:600 11px/1 system-ui;color:#cfd6e6;background:linear-gradient(transparent,rgba(8,12,22,.98) 60%);pointer-events:none}.sheet .embed-help{margin:8px 2px;font:500 12px/1.4 system-ui;color:#aab3c7}html:not(.player-frame) .frame-only{display:none}.sheet a,.sheet button{min-height:44px}.sheet .foot a,.sheet .foot button{display:inline-flex;align-items:center}.embed-open .frame-only{margin-left:4px}.player-top iframe.game{inset:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);width:calc(100% - env(safe-area-inset-left) - env(safe-area-inset-right));height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom))}.arrive-in{padding:12px}.arrive-title{font-size:clamp(24px,7vw,52px)}` : ''}
|
|
174
|
+
${shopControl ? `.shop-control{color:#eef1f8;font:600 13px/1.2 system-ui;position:fixed;top:env(safe-area-inset-top);left:max(8px,env(safe-area-inset-left));height:52px;display:flex;align-items:center;z-index:6}.shop-control button{min-height:44px;padding:0 18px}html body .room.room{flex-direction:column;align-items:flex-end;top:max(4px,env(safe-area-inset-top));right:max(8px,env(safe-area-inset-right));left:auto;bottom:auto;transform:none}html body iframe.game.game{top:calc(env(safe-area-inset-top) + 52px);height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 52px)}` : ''}
|
|
171
175
|
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
172
176
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
173
177
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
@@ -176,13 +180,13 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
176
180
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
177
181
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
178
182
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
179
|
-
const boot = { 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 } : {}) };
|
|
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 } : {}) };
|
|
180
184
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
181
185
|
const joinCorner = corner('join', 'bottom-right');
|
|
182
186
|
const first = places.desk;
|
|
183
187
|
// Local development: no join card at all on the big screen (no phone can open this computer's address); the room
|
|
184
188
|
// button's sheet says to deploy to share, for whoever looks for a link.
|
|
185
|
-
const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b
|
|
189
|
+
const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>${esc(appWords(g).join)}</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
|
|
186
190
|
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
187
191
|
<div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="${embed ? 'Room, invite and the studio’s site' : 'Room, invite and big screen'}"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
188
192
|
<button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
|
|
@@ -194,7 +198,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
194
198
|
</div>
|
|
195
199
|
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
196
200
|
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div>${embed ? `
|
|
197
|
-
<a class="embed-open" data-studio-open href="
|
|
201
|
+
<a class="embed-open" data-studio-open href="${esc(openPath(g))}" target="_blank" rel="noopener noreferrer">Open on the studio's site<span class="frame-only"> ↗</span></a><div class="embed-note" data-embed-note role="status" hidden></div>` : ''}
|
|
198
202
|
<div class="acts">
|
|
199
203
|
<button type="button" class="primary" data-invite><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M7.5 7.5 12 3l4.5 4.5M5 12v7a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-7"/></svg><span data-invite-word>Invite</span></button>
|
|
200
204
|
<a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
|
|
@@ -206,19 +210,19 @@ ${embed ? ` <p class="embed-help">Sound starts after a press. If pointer lock
|
|
|
206
210
|
</div>
|
|
207
211
|
</div>
|
|
208
212
|
<div class="toast" data-toast role="status" hidden></div>`;
|
|
209
|
-
return layoutless(`${g.name} ·
|
|
213
|
+
return layoutless(`${g.name} · ${appWords(g).open}`, `
|
|
210
214
|
<iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
|
|
211
215
|
${arrive.html}
|
|
212
216
|
<div class="chip${arrive.html ? ' held' : ''}" data-chip><span data-status>finding a room…</span></div>
|
|
213
217
|
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
214
218
|
<div class="card" data-results hidden></div>
|
|
215
219
|
<div class="card" data-screen hidden></div>
|
|
216
|
-
${joinCard}${share}
|
|
220
|
+
${joinCard}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
|
|
217
221
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
218
222
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
219
223
|
<script>${CHAT_JS}</script>
|
|
220
224
|
<script>${ARRIVAL_JS}</script>
|
|
221
|
-
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
225
|
+
<script>${SHELL_JS}</script>${g.kind === 'app' ? `<script>${APP_SHELL_JS}</script>` : ''}${g.saves && !screen ? `
|
|
222
226
|
<script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
|
|
223
227
|
<script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, embed ? embedAncestors(cat, origin, preview) : frameAncestors(cat), { embed, origin, head: gameSocialTags(cat, g, origin, launch === 'public') });
|
|
224
228
|
}
|
|
@@ -352,7 +356,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
352
356
|
var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null, server: boot.server || null, vote: null, myVote: {}, quietAi: false, full: false };
|
|
353
357
|
window.__shell = state;
|
|
354
358
|
// Where signing in comes back to. From the player address that is Play in the same room: the player itself is guests only.
|
|
355
|
-
state.returnPath = function () { return boot.embed ? '/' + boot.game + '/play' + (state.room ? '?room=' + encodeURIComponent(state.room) : '') : location.pathname + location.search; };
|
|
359
|
+
state.returnPath = function () { return boot.embed ? '/' + boot.game + (boot.kind === 'app' ? '/open' : '/play') + (state.room ? '?room=' + encodeURIComponent(state.room) : '') : location.pathname + location.search; };
|
|
356
360
|
// The arrival card (worker/arrival.mjs): the game's look while the room connects and the game loads.
|
|
357
361
|
var A = window.__homieArrival || { done: true, room: function () {}, facts: function () {}, full: function () {}, message: function () {}, lift: function () {}, frameLoaded: function () {} };
|
|
358
362
|
state.arrival = A.state || null;
|
|
@@ -450,7 +454,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
450
454
|
}
|
|
451
455
|
function shareReady(room) {
|
|
452
456
|
if (!ui) return;
|
|
453
|
-
var link = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
457
|
+
var link = location.origin + '/' + boot.game + (boot.kind === 'app' ? '/open?room=' : '/play?room=') + encodeURIComponent(room);
|
|
454
458
|
state.link = link;
|
|
455
459
|
ui.querySelector('[data-room-code]').textContent = labelOf(room) + (boot.embed ? ' \u00b7 Site' : '');
|
|
456
460
|
ui.querySelector('[data-room-label]').textContent = labelOf(room);
|
|
@@ -458,7 +462,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
458
462
|
ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
|
|
459
463
|
toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
|
|
460
464
|
ui.querySelector('[data-invite]').addEventListener('click', function () {
|
|
461
|
-
var data = { title: boot.name, text: 'Play ' + boot.name + ' with me: join my room.', url: link };
|
|
465
|
+
var data = { title: boot.name, text: (boot.appWords ? boot.appWords.open : 'Play') + ' ' + boot.name + ' with me: join my room.', url: link };
|
|
462
466
|
try { if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () { copy(link, 'Invite link copied'); });
|
|
463
467
|
else copy(link, 'Invite link copied'); } catch (e) { copy(link, 'Invite link copied'); }
|
|
464
468
|
});
|
|
@@ -472,7 +476,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
472
476
|
state.room = room;
|
|
473
477
|
if (boot.embed && keeps) try { sessionStorage.setItem(EMBED_KEY, JSON.stringify({ room: room, name: guestName })); } catch (e) {}
|
|
474
478
|
var studioOpen = document.querySelector('[data-studio-open]');
|
|
475
|
-
if (studioOpen) studioOpen.href = '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
479
|
+
if (studioOpen) studioOpen.href = '/' + boot.game + (boot.kind === 'app' ? '/open?room=' : '/play?room=') + encodeURIComponent(room);
|
|
476
480
|
// The room goes into the address: a reload comes back to it, and a copied address brings a friend into it.
|
|
477
481
|
try {
|
|
478
482
|
if (params.get('room') !== room) {
|
|
@@ -565,7 +569,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
565
569
|
if (m.what === 'roster') state.roster = m.slots;
|
|
566
570
|
// Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
|
|
567
571
|
// with its own words, because "try another room" is the only thing a player can do about it.
|
|
568
|
-
if (m.what === '
|
|
572
|
+
if (m.what === 'line') state.gameLine = typeof m.text === 'string' ? m.text.slice(0, 200) : null;
|
|
573
|
+
if (m.what === 'closed') { state.closed = m.why; if (typeof m.message === 'string') state.gameLine = m.message.slice(0, 200); if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
|
|
569
574
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
570
575
|
if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
|
|
571
576
|
paint();
|
|
@@ -576,7 +581,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
576
581
|
if (typeof setInterval === 'function') setInterval(tellRects, 1000);
|
|
577
582
|
if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
|
|
578
583
|
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
579
|
-
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
584
|
+
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + (boot.kind === 'app' ? '/open?room=' : '/play?room=') + encodeURIComponent(room);
|
|
580
585
|
screenCard.append(h2, div); screenCard.hidden = false;
|
|
581
586
|
}
|
|
582
587
|
}
|
|
@@ -755,7 +760,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
755
760
|
var w = document.createElement('p'); w.className = 'when';
|
|
756
761
|
w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
|
|
757
762
|
var acts = document.createElement('div'); acts.className = 'acts';
|
|
758
|
-
var other = document.createElement('a'); other.className = 'primary'; other.href = '/' + boot.game + '/play?not=' + encodeURIComponent(state.room || ''); other.textContent = 'Play in another room';
|
|
763
|
+
var other = document.createElement('a'); other.className = 'primary'; other.href = '/' + boot.game + (boot.kind === 'app' ? '/open?not=' : '/play?not=') + encodeURIComponent(state.room || ''); other.textContent = boot.kind === 'app' ? 'Join another room' : 'Play in another room';
|
|
759
764
|
var back = document.createElement('a'); back.href = '/' + boot.game + '/'; back.textContent = 'Back to ' + boot.name;
|
|
760
765
|
acts.append(other, back);
|
|
761
766
|
inner.append(h, p, w, acts); box.appendChild(inner);
|
|
@@ -768,23 +773,24 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
768
773
|
var n = f && f.counts ? f.counts.players : null;
|
|
769
774
|
var bits = [];
|
|
770
775
|
if (state.server) bits.push(state.server.name);
|
|
771
|
-
if (n !== null) bits.push(n + ' playing');
|
|
776
|
+
if (n !== null) bits.push(n + (boot.kind === 'app' ? ' ' + (n === 1 ? boot.appWords.one : boot.appWords.many) + ' here' : ' playing'));
|
|
772
777
|
// Every AI is counted as AI (agents, the seats kept for them, and the game's bots), never as a player.
|
|
773
778
|
var aiN = f && f.counts ? (Number(f.counts.ai) || 0) + (Number(f.counts.bots) || 0) : 0;
|
|
774
779
|
if (aiN) bits.push(aiN + ' AI');
|
|
775
780
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
776
781
|
if (state.full) bits.push('waiting for a seat');
|
|
777
782
|
// Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
|
|
778
|
-
if (state.
|
|
783
|
+
if (state.gameLine) bits.push(state.gameLine);
|
|
784
|
+
else if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
|
|
779
785
|
// The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
|
|
780
786
|
// playing by itself because the room never answered, or did not come back in time.
|
|
781
787
|
else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
|
|
782
788
|
else if (state.link && state.link.state === 'alone') bits.push(state.link.why === 'reconnect-timeout' ? 'playing on your own: the room dropped' : 'playing on your own: looking for the room');
|
|
783
789
|
// The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
|
|
784
|
-
if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
|
|
790
|
+
if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + (boot.kind === 'app' ? ' ' + (n === 1 ? boot.appWords.one : boot.appWords.many) + ' here' : ' playing') : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
|
|
785
791
|
say(bits.join(' · ') || 'joining…');
|
|
786
792
|
var count = ui && ui.querySelector('[data-room-count]');
|
|
787
|
-
if (count && n !== null) count.textContent = n + (n === 1 ?
|
|
793
|
+
if (count && n !== null) count.textContent = n + ' ' + (boot.appWords ? (n === 1 ? boot.appWords.one : boot.appWords.many) : (n === 1 ? 'player' : 'players')) + ' here' + (aiN ? ' · ' + aiN + ' AI' : '');
|
|
788
794
|
serverUi();
|
|
789
795
|
}
|
|
790
796
|
|
|
@@ -801,7 +807,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
801
807
|
// server's words from the page load are the fallback.
|
|
802
808
|
function policyText(pol) {
|
|
803
809
|
var w = pol && boot.words ? boot.words[pol.kind] : null;
|
|
804
|
-
if (!w) return state.server ? { badge: state.server.badge, line: state.server.line } : { badge: 'Open', line: 'Open: anyone can play. AI players are always marked AI.' };
|
|
810
|
+
if (!w) return state.server ? { badge: state.server.badge, line: state.server.line } : { badge: 'Open', line: boot.kind === 'app' ? 'Open: join this screen together.' : 'Open: anyone can play. AI players are always marked AI.' };
|
|
805
811
|
var n = pol.kind === 'hybrid' ? (Number(pol.aiSeats) || 0) : 0;
|
|
806
812
|
return { badge: pol.kind === 'hybrid' ? w.badge + ' · ' + n : w.badge, line: w.line.replace('{n}', String(n)) + (pol.kids && boot.kidsLine ? ' ' + boot.kidsLine : '') };
|
|
807
813
|
}
|
|
@@ -908,14 +914,14 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
908
914
|
var a = e.target.closest && e.target.closest('a[href]');
|
|
909
915
|
if (!a) return;
|
|
910
916
|
var u = new URL(a.href, location.href);
|
|
911
|
-
if (u.origin === location.origin && u.pathname === '/' + boot.game + '/play/embed') return;
|
|
917
|
+
if (u.origin === location.origin && u.pathname === '/' + boot.game + (boot.kind === 'app' ? '/open/embed' : '/play/embed')) return;
|
|
912
918
|
e.preventDefault(); state.openStudio(a.href);
|
|
913
919
|
}, true);
|
|
914
920
|
if (self.origin === 'null') {
|
|
915
921
|
A.lift(); open(true);
|
|
916
922
|
var note = document.querySelector('[data-embed-note]');
|
|
917
|
-
note.hidden = false; note.textContent = 'This host prevents
|
|
918
|
-
say('Open on the studio’s site
|
|
923
|
+
note.hidden = false; note.textContent = 'This host prevents connecting. Open on the studio’s site.';
|
|
924
|
+
say('Open on the studio’s site');
|
|
919
925
|
return;
|
|
920
926
|
}
|
|
921
927
|
}
|
|
@@ -1114,8 +1120,8 @@ const OWNER_JS = String.raw`(function () {
|
|
|
1114
1120
|
export function watchPage(cat, g, { origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
1115
1121
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
1116
1122
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
1117
|
-
const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
1118
|
-
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}`;
|
|
1123
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
1124
|
+
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}${g.kind === 'app' ? '.dock{display:none}' : ''}`;
|
|
1119
1125
|
return layoutless(`Watch ${g.name}`, `
|
|
1120
1126
|
<header class="wtop" data-top>
|
|
1121
1127
|
<span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
|
|
@@ -1124,7 +1130,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
|
|
|
1124
1130
|
<span class="clock" data-clock hidden></span>
|
|
1125
1131
|
<span class="grow"></span>
|
|
1126
1132
|
<span class="eyes" data-eyes hidden title="Watching this room"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span data-eyes-n></span><span class="eyes-w"> watching</span></span>
|
|
1127
|
-
<a class="playb" data-playb href="
|
|
1133
|
+
<a class="playb" data-playb href="${esc(openPath(g))}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg><span>${esc(appWords(g).open)}</span></a>
|
|
1128
1134
|
</header>
|
|
1129
1135
|
<main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe></main>
|
|
1130
1136
|
<footer class="dock">
|
|
@@ -1135,7 +1141,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
|
|
|
1135
1141
|
<script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
1136
1142
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
1137
1143
|
<script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
|
|
1138
|
-
<script>${WATCH_JS}</script
|
|
1144
|
+
<script>${WATCH_JS}</script>${g.kind === 'app' ? `<script>${APP_SHELL_JS}</script>` : ''}`, css, frameAncestors(cat), { head: gameSocialTags(cat, g, origin) });
|
|
1139
1145
|
}
|
|
1140
1146
|
|
|
1141
1147
|
/*
|
|
@@ -1289,9 +1295,10 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1289
1295
|
state.room = room;
|
|
1290
1296
|
syncUrl();
|
|
1291
1297
|
q1('[data-room-label]').textContent = labelOf(room);
|
|
1292
|
-
q1('[data-playb]').href = '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
1298
|
+
q1('[data-playb]').href = '/' + boot.game + (boot.kind === 'app' ? '/open?room=' : '/play?room=') + encodeURIComponent(room);
|
|
1293
1299
|
document.title = 'Watch ' + boot.name + ' · ' + labelOf(room);
|
|
1294
1300
|
var q = new URLSearchParams({ room: room, watch: '1', device: device, b: roomKey });
|
|
1301
|
+
if (boot.kind === 'app') { q.set('role', params.get('role') || boot.role); q.set('surface', 'wall'); q1('[data-playb] span').textContent = 'Open'; }
|
|
1295
1302
|
q.set('follow', first === 'auto' ? 'auto' : first === null ? 'overview' : String(first));
|
|
1296
1303
|
if (boot.t) q.set('t', boot.t);
|
|
1297
1304
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
@@ -1383,10 +1390,11 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1383
1390
|
full: 'Every place to watch it from is taken. Try the busiest other room.',
|
|
1384
1391
|
none: 'This page finds a room the moment somebody starts one. Or press Play, and people who come to watch will see you.',
|
|
1385
1392
|
};
|
|
1393
|
+
if (boot.kind === 'app') { heads.none = 'Nobody is here right now'; heads.off = 'Watching is unavailable'; lines.empty = lines.none = lines.off = 'Open the app to join a screen, or watch another room.'; }
|
|
1386
1394
|
box.appendChild(el('h1', '', heads[kind] || heads.closed));
|
|
1387
1395
|
box.appendChild(el('p', '', lines[kind] || lines.closed));
|
|
1388
1396
|
var acts = el('div', 'acts');
|
|
1389
|
-
var other = el('a', 'primary', kind === 'none' || kind === 'off' ? 'Play ' + boot.name : 'Watch another room');
|
|
1397
|
+
var other = el('a', 'primary', kind === 'none' || kind === 'off' ? (boot.kind === 'app' ? 'Open ' : 'Play ') + boot.name : 'Watch another room');
|
|
1390
1398
|
other.href = kind === 'none' || kind === 'off' ? '/' + boot.game + '/play' : '/' + boot.game + '/watch?not=' + encodeURIComponent(state.room || '');
|
|
1391
1399
|
acts.appendChild(other);
|
|
1392
1400
|
if (kind !== 'none' && kind !== 'off') { var play = el('a', '', 'Play'); play.href = '/' + boot.game + '/play'; acts.appendChild(play); }
|
|
@@ -1534,5 +1542,5 @@ export function embedAncestors(cat, origin, preview = false) {
|
|
|
1534
1542
|
|
|
1535
1543
|
export function embedPreview(cat, g, origin) {
|
|
1536
1544
|
const frameOrigin = new URL(origin); frameOrigin.hostname = frameOrigin.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
|
|
1537
|
-
return layoutless(`Preview ${g.name} in a post`, `<h1>Player card preview</h1><p>A cross-origin mock post using the sandbox observed in X’s web client. X’s phone apps open the player full screen; its website opens third-party games in a new tab.</p><iframe title="${esc(g.name)}" src="${esc(frameOrigin.origin)}
|
|
1545
|
+
return layoutless(`Preview ${g.name} in a post`, `<h1>Player card preview</h1><p>A cross-origin mock post using the sandbox observed in X’s web client. X’s phone apps open the player full screen; its website opens third-party games in a new tab.</p><iframe title="${esc(g.name)}" src="${esc(frameOrigin.origin)}${esc(openPath(g))}/embed" sandbox="${PLAYER_SANDBOX}" allow="autoplay; fullscreen; web-share" allowfullscreen scrolling="no" style="width:100%;aspect-ratio:${PLAYER_SIZE.width}/${PLAYER_SIZE.height};border:0"></iframe>`, 'body{max-width:480px;margin:24px auto;background:#101622;color:white;font:16px system-ui}');
|
|
1538
1546
|
}
|