@homie-rocks/studio 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -2
  3. package/app-starters/welcome/app.json +106 -0
  4. package/app-starters/welcome/index.html +4 -0
  5. package/app-starters/welcome/src/main.ts +107 -0
  6. package/apps/APPS.md +61 -0
  7. package/apps/apps.ts +28 -0
  8. package/bin/homie-studio.mjs +19 -4
  9. package/lib/art-cli.mjs +2 -2
  10. package/lib/art-tools.mjs +6 -6
  11. package/lib/asset-check.mjs +3 -2
  12. package/lib/asset-import.mjs +3 -2
  13. package/lib/asset-manifest.mjs +8 -8
  14. package/lib/build.mjs +6 -3
  15. package/lib/characters.mjs +7 -7
  16. package/lib/check.mjs +49 -0
  17. package/lib/cloudflare.mjs +13 -4
  18. package/lib/codex.mjs +2 -2
  19. package/lib/collision.mjs +1 -1
  20. package/lib/decisions.mjs +7 -7
  21. package/lib/dev.mjs +26 -9
  22. package/lib/directory.mjs +1 -1
  23. package/lib/doctor.mjs +8 -8
  24. package/lib/domain.mjs +43 -0
  25. package/lib/handoff.mjs +1 -1
  26. package/lib/jobs.mjs +17 -7
  27. package/lib/lab-check.mjs +1 -1
  28. package/lib/lab.mjs +8 -8
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +71 -21
  31. package/lib/mcp.mjs +6 -11
  32. package/lib/parts-build.mjs +4 -3
  33. package/lib/parts-cli.mjs +3 -3
  34. package/lib/parts-store.mjs +8 -7
  35. package/lib/parts-tools.mjs +7 -7
  36. package/lib/parts.mjs +19 -14
  37. package/lib/perf.mjs +2 -1
  38. package/lib/player-image.mjs +2 -2
  39. package/lib/prepare.mjs +47 -0
  40. package/lib/scaffold.mjs +31 -2
  41. package/lib/shop.mjs +1 -1
  42. package/lib/standalone-files.mjs +2 -2
  43. package/lib/standalone.mjs +7 -2
  44. package/lib/stats.mjs +2 -1
  45. package/lib/studio.mjs +32 -6
  46. package/lib/style-board.mjs +10 -9
  47. package/lib/template-history.json +82 -0
  48. package/links/links.d.ts +3 -0
  49. package/links/links.mjs +16 -0
  50. package/package.json +14 -3
  51. package/parts/PARTS.md +10 -5
  52. package/shop/SHOP.md +13 -0
  53. package/site/SITE.md +1 -1
  54. package/worker/account-page.mjs +4 -3
  55. package/worker/app-format.mjs +42 -0
  56. package/worker/app-records.mjs +100 -0
  57. package/worker/app-shell.mjs +29 -0
  58. package/worker/embed.mjs +4 -2
  59. package/worker/index.mjs +33 -9
  60. package/worker/pages.mjs +33 -27
  61. package/worker/parts.mjs +11 -9
  62. package/worker/schema.mjs +6 -5
  63. package/worker/seats.mjs +2 -2
  64. package/worker/shop-page.mjs +4 -3
  65. package/worker/site.mjs +49 -40
  66. package/worker/standalone.mjs +2 -2
  67. package/worker/version.mjs +1 -1
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) {
@@ -577,7 +581,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
577
581
  if (typeof setInterval === 'function') setInterval(tellRects, 1000);
578
582
  if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
579
583
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
580
- 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);
581
585
  screenCard.append(h2, div); screenCard.hidden = false;
582
586
  }
583
587
  }
@@ -756,7 +760,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
756
760
  var w = document.createElement('p'); w.className = 'when';
757
761
  w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
758
762
  var acts = document.createElement('div'); acts.className = 'acts';
759
- 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';
760
764
  var back = document.createElement('a'); back.href = '/' + boot.game + '/'; back.textContent = 'Back to ' + boot.name;
761
765
  acts.append(other, back);
762
766
  inner.append(h, p, w, acts); box.appendChild(inner);
@@ -769,7 +773,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
769
773
  var n = f && f.counts ? f.counts.players : null;
770
774
  var bits = [];
771
775
  if (state.server) bits.push(state.server.name);
772
- 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'));
773
777
  // Every AI is counted as AI (agents, the seats kept for them, and the game's bots), never as a player.
774
778
  var aiN = f && f.counts ? (Number(f.counts.ai) || 0) + (Number(f.counts.bots) || 0) : 0;
775
779
  if (aiN) bits.push(aiN + ' AI');
@@ -783,10 +787,10 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
783
787
  else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
784
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');
785
789
  // The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
786
- 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(' · '));
787
791
  say(bits.join(' · ') || 'joining…');
788
792
  var count = ui && ui.querySelector('[data-room-count]');
789
- 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' : '');
790
794
  serverUi();
791
795
  }
792
796
 
@@ -803,7 +807,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
803
807
  // server's words from the page load are the fallback.
804
808
  function policyText(pol) {
805
809
  var w = pol && boot.words ? boot.words[pol.kind] : null;
806
- 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.' };
807
811
  var n = pol.kind === 'hybrid' ? (Number(pol.aiSeats) || 0) : 0;
808
812
  return { badge: pol.kind === 'hybrid' ? w.badge + ' · ' + n : w.badge, line: w.line.replace('{n}', String(n)) + (pol.kids && boot.kidsLine ? ' ' + boot.kidsLine : '') };
809
813
  }
@@ -910,14 +914,14 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
910
914
  var a = e.target.closest && e.target.closest('a[href]');
911
915
  if (!a) return;
912
916
  var u = new URL(a.href, location.href);
913
- 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;
914
918
  e.preventDefault(); state.openStudio(a.href);
915
919
  }, true);
916
920
  if (self.origin === 'null') {
917
921
  A.lift(); open(true);
918
922
  var note = document.querySelector('[data-embed-note]');
919
- note.hidden = false; note.textContent = 'This host prevents the game from connecting. Open on the studio’s site to play.';
920
- 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');
921
925
  return;
922
926
  }
923
927
  }
@@ -1116,8 +1120,8 @@ const OWNER_JS = String.raw`(function () {
1116
1120
  export function watchPage(cat, g, { origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
1117
1121
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
1118
1122
  const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
1119
- const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
1120
- 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}' : ''}`;
1121
1125
  return layoutless(`Watch ${g.name}`, `
1122
1126
  <header class="wtop" data-top>
1123
1127
  <span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
@@ -1126,7 +1130,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
1126
1130
  <span class="clock" data-clock hidden></span>
1127
1131
  <span class="grow"></span>
1128
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>
1129
- <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>
1130
1134
  </header>
1131
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>
1132
1136
  <footer class="dock">
@@ -1137,7 +1141,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
1137
1141
  <script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
1138
1142
  <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
1139
1143
  <script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
1140
- <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) });
1141
1145
  }
1142
1146
 
1143
1147
  /*
@@ -1291,9 +1295,10 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
1291
1295
  state.room = room;
1292
1296
  syncUrl();
1293
1297
  q1('[data-room-label]').textContent = labelOf(room);
1294
- 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);
1295
1299
  document.title = 'Watch ' + boot.name + ' · ' + labelOf(room);
1296
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'; }
1297
1302
  q.set('follow', first === 'auto' ? 'auto' : first === null ? 'overview' : String(first));
1298
1303
  if (boot.t) q.set('t', boot.t);
1299
1304
  if (params.get('debug') === '1') q.set('debug', '1');
@@ -1385,10 +1390,11 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
1385
1390
  full: 'Every place to watch it from is taken. Try the busiest other room.',
1386
1391
  none: 'This page finds a room the moment somebody starts one. Or press Play, and people who come to watch will see you.',
1387
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.'; }
1388
1394
  box.appendChild(el('h1', '', heads[kind] || heads.closed));
1389
1395
  box.appendChild(el('p', '', lines[kind] || lines.closed));
1390
1396
  var acts = el('div', 'acts');
1391
- 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');
1392
1398
  other.href = kind === 'none' || kind === 'off' ? '/' + boot.game + '/play' : '/' + boot.game + '/watch?not=' + encodeURIComponent(state.room || '');
1393
1399
  acts.appendChild(other);
1394
1400
  if (kind !== 'none' && kind !== 'off') { var play = el('a', '', 'Play'); play.href = '/' + boot.game + '/play'; acts.appendChild(play); }
@@ -1536,5 +1542,5 @@ export function embedAncestors(cat, origin, preview = false) {
1536
1542
 
1537
1543
  export function embedPreview(cat, g, origin) {
1538
1544
  const frameOrigin = new URL(origin); frameOrigin.hostname = frameOrigin.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
1539
- 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}');
1540
1546
  }
package/worker/parts.mjs CHANGED
@@ -38,11 +38,12 @@ export async function partsIndex(env, origin) {
38
38
  * Only a PUBLIC game is named (a private or invite-only game's parts are still shared, without saying where from).
39
39
  */
40
40
  function entryOf(p, cat, url) {
41
- const g = p.from?.game ? (cat.games ?? []).find((x) => x.id === p.from.game) : null;
41
+ const g = (p.from?.game ?? p.from?.app) ? (cat.games ?? []).find((x) => x.id === (p.from.game ?? p.from.app)) : null;
42
42
  const { from, ...rest } = p;
43
43
  return {
44
44
  ...rest,
45
- ...(g ? { from: { game: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play` } } : {}),
45
+ ...(!g && from && (from.music || from.video) ? { from: { ...(from.music ? { music: from.music } : { video: from.video }), name: from.name ?? null, studio: from.studio ?? null } } : {}),
46
+ ...(g ? { from: { [g.kind === 'app' ? 'app' : 'game']: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, [g.kind === 'app' ? 'open' : 'play']: `${url.origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` } } : {}),
46
47
  add: `${url.host}/${p.id}`,
47
48
  };
48
49
  }
@@ -131,21 +132,21 @@ const CSS = `<style>
131
132
  </style>`;
132
133
  const head = (kicker, title, lead, extra = '') => `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
133
134
  const studioOf = (cat) => cat.studio?.name ?? 'Studio';
134
- const fromLine = (p) => (p.from?.game ? `From <a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a>` : '');
135
+ const fromLine = (p) => ((p.from?.game ?? p.from?.app) ? `From <a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a>` : '');
135
136
  /** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
136
137
  const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my game`;
137
138
  const sayRow = (label, say) => `<div class="psay"><div><span class="ptag">${esc(label)}</span><code>${esc(say)}</code></div><button class="pcopy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></div>`;
138
139
 
139
140
  function card(p) {
140
141
  const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
141
- return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${p.from?.game ? `<p>From ${esc(p.from.name ?? p.from.game)}</p>` : ''}</a></li>`;
142
+ return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
142
143
  }
143
144
 
144
145
  export function partsPage(cat, parts, url) {
145
146
  const name = studioOf(cat);
146
147
  // Grouped by the game each came out of: a mashup starts from "the creature in that game".
147
148
  const groups = new Map();
148
- for (const p of parts) { const k = p.from?.game ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
149
+ for (const p of parts) { const k = (p.from?.game ?? p.from?.app) ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
149
150
  const body = [...groups].sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))).map(([k, list]) => `<p class="sec"${k ? ` id="from-${esc(k)}"` : ''}>${k ? `From ${esc(list[0].from.name ?? k)}` : 'More parts'}</p><ul class="pgrid">${list.map(card).join('')}</ul>`).join('');
150
151
  return layout(cat, {
151
152
  title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS,
@@ -213,7 +214,8 @@ export function partPage(cat, p, url) {
213
214
  : p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
214
215
  const facts = [
215
216
  ['Kind', esc(kindOf(p))],
216
- p.from?.game ? ['From the game', `<a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a> · <a href="/${esc(p.from.game)}/play">Play it</a>`] : null,
217
+ p.uses?.length ? ['For', esc(p.uses.join(', '))] : null,
218
+ (p.from?.game ?? p.from?.app) ? [p.from.app ? 'From the app' : 'From the game', `<a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a> · <a href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${p.from.app ? 'Open it' : 'Play it'}</a>`] : null,
217
219
  ['Licence', `${esc(p.license ?? 'none named')}${p.license && !/^LicenseRef-/.test(p.license) ? ` · <a href="https://spdx.org/licenses/${esc(p.license)}.html" rel="noopener">what it allows</a>` : ''}`],
218
220
  p.attribution ? ['Credit', esc(p.attribution)] : null,
219
221
  ['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
@@ -228,7 +230,7 @@ export function partPage(cat, p, url) {
228
230
  return layout(cat, {
229
231
  title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS,
230
232
  image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
231
- main: `${head(`${kindOf(p)} · game part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${p.from?.game ? `<a class="ghost" href="/${esc(p.from.game)}/play">${icon('games')}<span>Play ${esc(p.from.name ?? p.from.game)}</span></a>` : ''}</div>`)}
233
+ main: `${head(`${kindOf(p)} · shared part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${(p.from?.game ?? p.from?.app) ? `<a class="ghost" href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${icon('games')}<span>${p.from.app ? 'Open' : 'Play'} ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</span></a>` : ''}</div>`)}
232
234
  <div class="wrap">
233
235
  ${preview ? `<p class="sec">Try it</p>${preview}` : ''}
234
236
  <p class="sec">Use it in your game</p>
@@ -248,7 +250,7 @@ ${sayRow('Say', sayAdd(p))}
248
250
  * build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
249
251
  */
250
252
  export function partsBand(g, parts, host) {
251
- const mine = parts.filter((p) => p.from?.game === g.id);
253
+ const mine = parts.filter((p) => (p.from?.game ?? p.from?.app) === g.id);
252
254
  if (!mine.length) return '';
253
255
  const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new game in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
254
256
  return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
@@ -266,7 +268,7 @@ export async function withPartsBand(res, env, url, g) {
266
268
  try {
267
269
  if (!res || res.status !== 200 || !/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
268
270
  const index = await partsIndex(env, url.origin);
269
- const band = partsBand(g, index.parts.map((p) => ({ ...p, from: p.from?.game === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
271
+ const band = partsBand(g, index.parts.map((p) => ({ ...p, from: (p.from?.game ?? p.from?.app) === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
270
272
  if (!band) return res;
271
273
  const html = await res.text();
272
274
  const at = html.lastIndexOf('</main>');
package/worker/schema.mjs CHANGED
@@ -164,7 +164,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
164
164
  const L = g.landing ?? {};
165
165
  const h = L.hero ?? {};
166
166
  const page = `${origin}/${g.id}/`;
167
- const play = `${origin}/${g.id}/play`;
167
+ const play = `${origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}`;
168
168
  const min = Number(g.players?.min) || 1;
169
169
  const max = Number(g.players?.max) || 8;
170
170
  const modes = [...(min <= 1 ? ['SinglePlayer'] : []), ...(max > 1 ? ['MultiPlayer'] : [])];
@@ -180,7 +180,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
180
180
  const addOn = listed ? shopOffers(shop, g, origin) : [];
181
181
  const genre = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).map((x) => text(x, 40)).filter(Boolean);
182
182
  const node = {
183
- '@type': ['VideoGame', 'WebApplication'],
183
+ '@type': g.kind === 'app' ? 'WebApplication' : ['VideoGame', 'WebApplication'],
184
184
  '@id': `${page}#game`,
185
185
  name: g.name,
186
186
  ...(text(g.blurb ?? L.pitch) ? { description: text(g.blurb || L.pitch) } : {}),
@@ -189,10 +189,11 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
189
189
  ...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
190
190
  ...(shots.length ? { screenshot: shots } : {}),
191
191
  ...(genre.length ? { genre: genre.length === 1 ? genre[0] : genre } : {}),
192
- playMode: modes.map(PLAY_MODE),
192
+ ...(g.kind === 'app' ? {} : { playMode: modes.map(PLAY_MODE),
193
193
  numberOfPlayers: { '@type': 'QuantitativeValue', minValue: min, maxValue: max },
194
194
  gamePlatform: ['Web browser', 'Phone', 'Computer', ...(L.tv !== false ? ['TV'] : [])],
195
- applicationCategory: 'GameApplication',
195
+ }),
196
+ applicationCategory: g.kind === 'app' ? 'BusinessApplication' : 'GameApplication',
196
197
  operatingSystem: 'Any',
197
198
  browserRequirements: 'A modern web browser with JavaScript',
198
199
  isAccessibleForFree: true,
@@ -203,7 +204,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
203
204
  ...(trailer ? { trailer: videoNode(cat, trailer, origin, { nested: true }) } : {}),
204
205
  ...(when(g.dates?.published) ? { datePublished: g.dates.published } : {}),
205
206
  ...(when(g.dates?.modified) ? { dateModified: g.dates.modified } : {}),
206
- potentialAction: { '@type': 'PlayAction', target: play },
207
+ potentialAction: { '@type': g.kind === 'app' ? 'ViewAction' : 'PlayAction', target: play },
207
208
  ...(listed ? { offers: { '@type': 'Offer', price: 0, priceCurrency: /^[A-Z]{3}$/.test(String(shop?.currency ?? '').toUpperCase()) ? String(shop.currency).toUpperCase() : 'USD', availability: 'https://schema.org/OnlineOnly', url: play, ...(addOn.length ? { addOn } : {}) } } : {}),
208
209
  };
209
210
  return withExtras(node, g.schema);
package/worker/seats.mjs CHANGED
@@ -34,7 +34,7 @@ export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
34
34
  * the seat's token, the ticket, the browser key…): a game can never ask to be handed those.
35
35
  */
36
36
  export const PLAY_PARAMS = Object.freeze(['debug', 'q', 'touchdebug', 'cam', 'view']);
37
- const RESERVED_PARAMS = new Set(['room', 'device', 'want', 'k', 'b', 't', 'name', 'hush', 'chat', 'bub', 'watch', 'follow', 'hand', 'screen', 'arrive', 'not', 'via', 'invite', 'w', 'gv', 'pf', 'server', 'token', 'key', 'code']);
37
+ const RESERVED_PARAMS = new Set(['room', 'device', 'want', 'k', 'b', 't', 'name', 'hush', 'chat', 'bub', 'watch', 'follow', 'hand', 'screen', 'arrive', 'not', 'via', 'invite', 'w', 'gv', 'pf', 'server', 'token', 'key', 'code', 'access']);
38
38
  const PARAM_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
39
39
  /** A switch's value as it is passed in: up to 48 of A-Z a-z 0-9 _ . ~ - (empty for a bare `?debug`). */
40
40
  export const PARAM_VALUE = '^[A-Za-z0-9_.~-]{0,48}$';
@@ -42,7 +42,7 @@ export const PARAM_VALUE = '^[A-Za-z0-9_.~-]{0,48}$';
42
42
  /** The names a game may be handed: the defaults, then up to 12 of its own (lowercase, never one the page uses). */
43
43
  export function playParams(meta) {
44
44
  const own = Array.isArray(meta?.netplay?.params) ? meta.netplay.params : [];
45
- const out = [...PLAY_PARAMS];
45
+ const out = [...PLAY_PARAMS, ...(meta?.kind === 'app' ? ['role', 'surface', 'ticket'] : [])];
46
46
  for (const raw of own) {
47
47
  const k = String(raw ?? '');
48
48
  if (PARAM_NAME.test(k) && !RESERVED_PARAMS.has(k) && !out.includes(k)) out.push(k);
@@ -409,7 +409,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
409
409
  try { inFrame = Boolean(window.top) && window.top !== window; } catch (e) { inFrame = true; }
410
410
  if (boot.embed || inFrame) {
411
411
  box.appendChild(el('p', { class: 'sub' }, inFrame ? 'Purchases open on the studio’s site, outside this post.' : 'Purchases are made in the studio’s shop.'));
412
- box.appendChild(el('a', { class: 'buy', href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener noreferrer' }, inFrame ? 'Buy on the studio’s site ↗' : 'Go to the shop'));
412
+ box.appendChild(el('a', { class: 'buy', href: '/shop/?game=' + encodeURIComponent(GAME) + (want ? '&item=' + encodeURIComponent(want) : ''), target: '_blank', rel: 'noopener noreferrer' }, inFrame ? 'Buy on the studio’s site ↗' : 'Go to the shop'));
413
413
  return;
414
414
  }
415
415
  if (screen) {
@@ -463,8 +463,9 @@ export const SHOP_SHELL_JS = String.raw`(function () {
463
463
  }
464
464
  loadItems('');
465
465
  }
466
- var opener = document.querySelector('[data-shop-open]');
467
- if (opener) opener.addEventListener('click', function () { open(null); });
466
+ document.querySelectorAll('[data-shop-open]').forEach(function (opener) {
467
+ opener.addEventListener('click', function () { open(null); });
468
+ });
468
469
  var state = window.__shell || (window.__shell = {});
469
470
  state.shop = { open: open, close: close, refresh: refresh, get owns() { return owns.slice(); } };
470
471