@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.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -2
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +19 -4
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +6 -3
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +13 -4
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +26 -9
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +8 -8
- package/lib/domain.mjs +43 -0
- package/lib/handoff.mjs +1 -1
- package/lib/jobs.mjs +17 -7
- package/lib/lab-check.mjs +1 -1
- package/lib/lab.mjs +8 -8
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +71 -21
- package/lib/mcp.mjs +6 -11
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/prepare.mjs +47 -0
- package/lib/scaffold.mjs +31 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +7 -2
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +82 -0
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/package.json +14 -3
- package/parts/PARTS.md +10 -5
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/embed.mjs +4 -2
- package/worker/index.mjs +33 -9
- package/worker/pages.mjs +33 -27
- package/worker/parts.mjs +11 -9
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/worker/pages.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { appWords, openPath } from './app-format.mjs';
|
|
2
|
+
import { APP_SHELL_JS } from './app-shell.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* The play shell: the page around a game's sandboxed frame at /<game>/play and /<game>/tv. Every other page is
|
|
3
5
|
* worker/site.mjs. Every name a player typed is escaped.
|
|
@@ -46,7 +48,7 @@ export function noWatchPage(cat, g) {
|
|
|
46
48
|
title: `${g.name} is not shown to watchers`, page: 'no-watch', status: 404,
|
|
47
49
|
main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>${esc(g.name)} is played, not watched</h1>
|
|
48
50
|
<p class="lead">Its rooms are not shown to watchers: what each player sees stays theirs. Press Play and you are in a room, with bots in the empty seats.</p>
|
|
49
|
-
<div class="keys"><a class="btn" href="
|
|
51
|
+
<div class="keys"><a class="btn" href="${esc(openPath(g))}" data-play>Play ${esc(g.name)}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -90,6 +92,7 @@ export function sharePlaces(value) {
|
|
|
90
92
|
* covers the middle of the screen or a thumb.
|
|
91
93
|
*/
|
|
92
94
|
export function playPage(cat, g, { embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
95
|
+
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
93
96
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
94
97
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
95
98
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -168,6 +171,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
168
171
|
[hidden] { display: none !important; }
|
|
169
172
|
${arrive.css}
|
|
170
173
|
${embed ? `html,body{touch-action:auto}.room.at-top-center{transform:none;left:auto;right:max(8px,env(safe-area-inset-right))}.embed-open{display:flex;align-items:center;min-height:44px;color:inherit}.embed-note{font:12px/1.4 system-ui;overflow-wrap:anywhere}.sheet{position:fixed;inset:calc(max(8px,env(safe-area-inset-top)) + 48px) max(8px,env(safe-area-inset-right)) auto auto;max-width:calc(100vw - max(8px,env(safe-area-inset-left)) - max(8px,env(safe-area-inset-right)));max-height:calc(100dvh - max(8px,env(safe-area-inset-top)) - 48px - max(8px,env(safe-area-inset-bottom)));overflow:auto;overscroll-behavior:contain;touch-action:pan-y}.sheet.more::after{content:'▾ more below';position:sticky;bottom:-12px;display:block;margin:0 -12px -12px;padding:16px 0 5px;text-align:center;font:600 11px/1 system-ui;color:#cfd6e6;background:linear-gradient(transparent,rgba(8,12,22,.98) 60%);pointer-events:none}.sheet .embed-help{margin:8px 2px;font:500 12px/1.4 system-ui;color:#aab3c7}html:not(.player-frame) .frame-only{display:none}.sheet a,.sheet button{min-height:44px}.sheet .foot a,.sheet .foot button{display:inline-flex;align-items:center}.embed-open .frame-only{margin-left:4px}.player-top iframe.game{inset:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);width:calc(100% - env(safe-area-inset-left) - env(safe-area-inset-right));height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom))}.arrive-in{padding:12px}.arrive-title{font-size:clamp(24px,7vw,52px)}` : ''}
|
|
174
|
+
${shopControl ? `.shop-control{color:#eef1f8;font:600 13px/1.2 system-ui;position:fixed;top:env(safe-area-inset-top);left:max(8px,env(safe-area-inset-left));height:52px;display:flex;align-items:center;z-index:6}.shop-control button{min-height:44px;padding:0 18px}html body .room.room{flex-direction:column;align-items:flex-end;top:max(4px,env(safe-area-inset-top));right:max(8px,env(safe-area-inset-right));left:auto;bottom:auto;transform:none}html body iframe.game.game{top:calc(env(safe-area-inset-top) + 52px);height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 52px)}` : ''}
|
|
171
175
|
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
172
176
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
173
177
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
@@ -176,13 +180,13 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
176
180
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
177
181
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
178
182
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
179
|
-
const boot = { embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
183
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
180
184
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
181
185
|
const joinCorner = corner('join', 'bottom-right');
|
|
182
186
|
const first = places.desk;
|
|
183
187
|
// Local development: no join card at all on the big screen (no phone can open this computer's address); the room
|
|
184
188
|
// button's sheet says to deploy to share, for whoever looks for a link.
|
|
185
|
-
const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b
|
|
189
|
+
const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>${esc(appWords(g).join)}</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
|
|
186
190
|
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
187
191
|
<div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="${embed ? 'Room, invite and the studio’s site' : 'Room, invite and big screen'}"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
188
192
|
<button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
|
|
@@ -194,7 +198,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
194
198
|
</div>
|
|
195
199
|
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
196
200
|
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div>${embed ? `
|
|
197
|
-
<a class="embed-open" data-studio-open href="
|
|
201
|
+
<a class="embed-open" data-studio-open href="${esc(openPath(g))}" target="_blank" rel="noopener noreferrer">Open on the studio's site<span class="frame-only"> ↗</span></a><div class="embed-note" data-embed-note role="status" hidden></div>` : ''}
|
|
198
202
|
<div class="acts">
|
|
199
203
|
<button type="button" class="primary" data-invite><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M7.5 7.5 12 3l4.5 4.5M5 12v7a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-7"/></svg><span data-invite-word>Invite</span></button>
|
|
200
204
|
<a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
|
|
@@ -206,19 +210,19 @@ ${embed ? ` <p class="embed-help">Sound starts after a press. If pointer lock
|
|
|
206
210
|
</div>
|
|
207
211
|
</div>
|
|
208
212
|
<div class="toast" data-toast role="status" hidden></div>`;
|
|
209
|
-
return layoutless(`${g.name} ·
|
|
213
|
+
return layoutless(`${g.name} · ${appWords(g).open}`, `
|
|
210
214
|
<iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
|
|
211
215
|
${arrive.html}
|
|
212
216
|
<div class="chip${arrive.html ? ' held' : ''}" data-chip><span data-status>finding a room…</span></div>
|
|
213
217
|
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
214
218
|
<div class="card" data-results hidden></div>
|
|
215
219
|
<div class="card" data-screen hidden></div>
|
|
216
|
-
${joinCard}${share}
|
|
220
|
+
${joinCard}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
|
|
217
221
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
218
222
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
219
223
|
<script>${CHAT_JS}</script>
|
|
220
224
|
<script>${ARRIVAL_JS}</script>
|
|
221
|
-
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
225
|
+
<script>${SHELL_JS}</script>${g.kind === 'app' ? `<script>${APP_SHELL_JS}</script>` : ''}${g.saves && !screen ? `
|
|
222
226
|
<script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
|
|
223
227
|
<script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, embed ? embedAncestors(cat, origin, preview) : frameAncestors(cat), { embed, origin, head: gameSocialTags(cat, g, origin, launch === 'public') });
|
|
224
228
|
}
|
|
@@ -352,7 +356,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
352
356
|
var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null, server: boot.server || null, vote: null, myVote: {}, quietAi: false, full: false };
|
|
353
357
|
window.__shell = state;
|
|
354
358
|
// Where signing in comes back to. From the player address that is Play in the same room: the player itself is guests only.
|
|
355
|
-
state.returnPath = function () { return boot.embed ? '/' + boot.game + '/play' + (state.room ? '?room=' + encodeURIComponent(state.room) : '') : location.pathname + location.search; };
|
|
359
|
+
state.returnPath = function () { return boot.embed ? '/' + boot.game + (boot.kind === 'app' ? '/open' : '/play') + (state.room ? '?room=' + encodeURIComponent(state.room) : '') : location.pathname + location.search; };
|
|
356
360
|
// The arrival card (worker/arrival.mjs): the game's look while the room connects and the game loads.
|
|
357
361
|
var A = window.__homieArrival || { done: true, room: function () {}, facts: function () {}, full: function () {}, message: function () {}, lift: function () {}, frameLoaded: function () {} };
|
|
358
362
|
state.arrival = A.state || null;
|
|
@@ -450,7 +454,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
450
454
|
}
|
|
451
455
|
function shareReady(room) {
|
|
452
456
|
if (!ui) return;
|
|
453
|
-
var link = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
457
|
+
var link = location.origin + '/' + boot.game + (boot.kind === 'app' ? '/open?room=' : '/play?room=') + encodeURIComponent(room);
|
|
454
458
|
state.link = link;
|
|
455
459
|
ui.querySelector('[data-room-code]').textContent = labelOf(room) + (boot.embed ? ' \u00b7 Site' : '');
|
|
456
460
|
ui.querySelector('[data-room-label]').textContent = labelOf(room);
|
|
@@ -458,7 +462,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
458
462
|
ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
|
|
459
463
|
toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
|
|
460
464
|
ui.querySelector('[data-invite]').addEventListener('click', function () {
|
|
461
|
-
var data = { title: boot.name, text: 'Play ' + boot.name + ' with me: join my room.', url: link };
|
|
465
|
+
var data = { title: boot.name, text: (boot.appWords ? boot.appWords.open : 'Play') + ' ' + boot.name + ' with me: join my room.', url: link };
|
|
462
466
|
try { if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () { copy(link, 'Invite link copied'); });
|
|
463
467
|
else copy(link, 'Invite link copied'); } catch (e) { copy(link, 'Invite link copied'); }
|
|
464
468
|
});
|
|
@@ -472,7 +476,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
472
476
|
state.room = room;
|
|
473
477
|
if (boot.embed && keeps) try { sessionStorage.setItem(EMBED_KEY, JSON.stringify({ room: room, name: guestName })); } catch (e) {}
|
|
474
478
|
var studioOpen = document.querySelector('[data-studio-open]');
|
|
475
|
-
if (studioOpen) studioOpen.href = '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
479
|
+
if (studioOpen) studioOpen.href = '/' + boot.game + (boot.kind === 'app' ? '/open?room=' : '/play?room=') + encodeURIComponent(room);
|
|
476
480
|
// The room goes into the address: a reload comes back to it, and a copied address brings a friend into it.
|
|
477
481
|
try {
|
|
478
482
|
if (params.get('room') !== room) {
|
|
@@ -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 ?
|
|
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
|
|
920
|
-
say('Open on the studio’s site
|
|
923
|
+
note.hidden = false; note.textContent = 'This host prevents connecting. Open on the studio’s site.';
|
|
924
|
+
say('Open on the studio’s site');
|
|
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="
|
|
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
|
|
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)}
|
|
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: {
|
|
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.
|
|
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)} ·
|
|
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}
|
|
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
|
-
|
|
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);
|
package/worker/shop-page.mjs
CHANGED
|
@@ -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
|
-
|
|
467
|
-
|
|
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
|
|