@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. 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 && shellShop(cat, game) ? { shop: true } : {}),
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] === 'games' ? gamesPage(cat, { origin: url.origin, live }) : roomsPage(cat, { origin: url.origin, rooms });
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}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
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 = shellShop(cat, game, { kids: pol.kids });
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: shellShop(cat, game, { kids: pol.kids }) });
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.retimeRound(); this.armRoom(Date.now() + ROOM_ALARM_MS); },
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="/${esc(g.id)}/play" data-play>Play ${esc(g.name)}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
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>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
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="/${esc(g.id)}/play" 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>` : ''}
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} · play`, `
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 === 'closed') { state.closed = m.why; 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); }
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.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');
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 ? ' player here' : ' players here') + (aiN ? ' · ' + aiN + ' AI' : '');
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 the game from connecting. Open on the studio’s site to play.';
918
- say('Open on the studio’s site to play');
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="/${esc(g.id)}/play"><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>Play</span></a>
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>`, css, frameAncestors(cat), { head: gameSocialTags(cat, g, origin) });
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)}/${esc(g.id)}/play/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}');
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
  }