@homie-rocks/studio 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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 +13 -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 +1 -1
- 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 +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +1 -1
- package/lib/lab.mjs +8 -8
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +22 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/scaffold.mjs +21 -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 +41 -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/site.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appWords, openPath } from './app-format.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* The studio site's pages, in the studio's own look (site/SITE.md): the same sections as homie.rocks
|
|
3
4
|
* (Home, Games, Music, Videos, Rooms, Posts, each shown only when the studio has something in it), an epic
|
|
@@ -473,7 +474,7 @@ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.t
|
|
|
473
474
|
var el=function(tag,cls,text){var e=d.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e};
|
|
474
475
|
var EYE='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" 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>';
|
|
475
476
|
var roomRow=function(r){var li=el('li','room');var th=el('span','thumb');if(r.cover){var im=el('img');im.src=r.cover;im.alt='';im.loading='lazy';th.appendChild(im)}li.appendChild(th);
|
|
476
|
-
var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+(r.server?r.server.name+' · ':'')+r.label));mid.appendChild(el('span',null,r.players+' of '+r.max+' seats taken'+(r.ai?' · '+r.ai+' AI':'')+(r.policy==='humans-only'?' · humans only':' · bots hold the rest')));
|
|
477
|
+
var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+(r.server?r.server.name+' · ':'')+r.label));mid.appendChild(el('span',null,(r.kind==='app'?r.players+' people here':r.players+' of '+r.max+' seats taken')+(r.ai?' · '+r.ai+' AI':'')+(r.kind==='app'?'':r.policy==='humans-only'?' · humans only':' · bots hold the rest')));
|
|
477
478
|
var pips=el('div','pips');for(var i=0;i<Math.min(r.max,32);i++)pips.appendChild(el('i',i<r.players?'on':''));mid.appendChild(pips);li.appendChild(mid);
|
|
478
479
|
var acts=el('div','room-acts');
|
|
479
480
|
if(r.watch){var w=el('a','watchb');w.href=r.watch;w.setAttribute('aria-label','Watch '+(r.name?r.name+' · ':'')+r.label);w.innerHTML=EYE;w.appendChild(el('span',null,'Watch'));acts.appendChild(w)}
|
|
@@ -488,7 +489,7 @@ if(src){
|
|
|
488
489
|
var idle=live&&(live.getAttribute('data-idle')||(words&&words.textContent));
|
|
489
490
|
var ask=function(){if(d.hidden)return;fetch(src,{credentials:'omit',cache:'no-store'}).then(function(r){return r.ok?r.json():null}).then(function(b){
|
|
490
491
|
if(!b||b.ok!==true)return;var n=Number(b.playing)||0;
|
|
491
|
-
if(live){live.setAttribute('data-n',String(n));if(words)words.textContent=n<=0?idle:(n===1?'1 '+one+' playing right now':n+' '+many+' playing right now')}
|
|
492
|
+
if(live){live.setAttribute('data-n',String(n));if(words)words.textContent=n<=0?idle:(n===1?'1 '+one+(live.hasAttribute('data-app')?' here right now':' playing right now'):n+' '+many+(live.hasAttribute('data-app')?' here right now':' playing right now'))}
|
|
492
493
|
if(list&&Array.isArray(b.rooms))paintRooms(list,b.rooms)}).catch(function(){})};
|
|
493
494
|
var go=function(){setInterval(ask,15000);d.addEventListener('visibilitychange',ask)};
|
|
494
495
|
if(d.prerendering===true)d.addEventListener('prerenderingchange',function(){ask();go()},{once:true});else go();
|
|
@@ -551,12 +552,14 @@ const studioName = (cat) => cat.studio?.name ?? 'Studio';
|
|
|
551
552
|
|
|
552
553
|
/** Which sections this studio has: a section with nothing in it has no tab (and its page answers 404). */
|
|
553
554
|
export function sectionsOf(cat) {
|
|
554
|
-
const games = (cat.games ?? []).
|
|
555
|
+
const games = (cat.games ?? []).some((g) => g.kind !== 'app');
|
|
556
|
+
const apps = (cat.games ?? []).some((g) => g.kind === 'app');
|
|
555
557
|
return [
|
|
558
|
+
apps && { key: 'apps', href: '/apps/', label: 'Apps' },
|
|
556
559
|
games && { key: 'games', href: '/games/', label: 'Games' },
|
|
557
560
|
(cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
|
|
558
561
|
(cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
|
|
559
|
-
games && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
|
|
562
|
+
(games || apps) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
|
|
560
563
|
(cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
|
|
561
564
|
// The Lounge (0.29.0): the studio's own community room, when studio.json turns it on.
|
|
562
565
|
cat.studio?.lounge && { key: 'lounge', href: '/lounge/', label: cat.studio.lounge.tab ?? 'Lounge' },
|
|
@@ -711,10 +714,10 @@ function heroMedia(h = {}, { alt = '', word = '' } = {}) {
|
|
|
711
714
|
|
|
712
715
|
function liveLine(g, playing, { idle } = {}) {
|
|
713
716
|
const w = g.landing?.words ?? {};
|
|
714
|
-
const one = w.one ??
|
|
715
|
-
const many = w.many ??
|
|
716
|
-
const words = playing > 0 ? (playing === 1 ? `1 ${one} playing right now` : `${playing} ${many} playing right now`) : idle;
|
|
717
|
-
return `<p class="live" data-live="/${esc(g.id)}/live" data-n="${playing}" data-one="${esc(one)}" data-many="${esc(many)}" data-idle="${esc(idle)}" aria-live="polite"><span class="live-dot" aria-hidden="true"></span><span data-live-words>${esc(words)}</span></p>`;
|
|
717
|
+
const one = w.one ?? appWords(g).one;
|
|
718
|
+
const many = w.many ?? appWords(g).many;
|
|
719
|
+
const words = playing > 0 ? (playing === 1 ? `1 ${one} ${g.kind === 'app' ? 'here' : 'playing'} right now` : `${playing} ${many} ${g.kind === 'app' ? 'here' : 'playing'} right now`) : idle;
|
|
720
|
+
return `<p class="live" data-live="/${esc(g.id)}/live"${g.kind === 'app' ? ' data-app="1"' : ''} data-n="${playing}" data-one="${esc(one)}" data-many="${esc(many)}" data-idle="${esc(idle)}" aria-live="polite"><span class="live-dot" aria-hidden="true"></span><span data-live-words>${esc(words)}</span></p>`;
|
|
718
721
|
}
|
|
719
722
|
|
|
720
723
|
function gameCard(g, playing = 0) {
|
|
@@ -724,8 +727,8 @@ function gameCard(g, playing = 0) {
|
|
|
724
727
|
<div class="body">
|
|
725
728
|
<h3><a href="/${esc(g.id)}/">${esc(g.name)}</a></h3>
|
|
726
729
|
<p>${esc(g.landing?.pitch ?? g.blurb)}</p>
|
|
727
|
-
<div class="meta"><span>${esc(playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
|
|
728
|
-
<div class="acts"><a class="btn" href="
|
|
730
|
+
<div class="meta"><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
|
|
731
|
+
<div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
|
|
729
732
|
</div>
|
|
730
733
|
</article>`;
|
|
731
734
|
}
|
|
@@ -750,7 +753,7 @@ export function roomView(g, r, max, srv = null, { chat = false } = {}) {
|
|
|
750
753
|
const m = /^(?:pub|s-[a-z0-9-]+)-(\d+)$/.exec(String(room ?? ''));
|
|
751
754
|
const n = m ? Number(m[1]) : null;
|
|
752
755
|
return {
|
|
753
|
-
game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room, players: r.players, max, cover: coverOf(g), play:
|
|
756
|
+
...(g.kind === 'app' ? { kind: 'app' } : {}), game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room, players: r.players, max, cover: coverOf(g), play: `${openPath(g)}?room=${encodeURIComponent(room)}`,
|
|
754
757
|
// Watch this room from any player's view (section 16); a game that cannot be watched has no link.
|
|
755
758
|
watch: watchOf(g) === 'off' ? null : `/${g.id}/watch?room=${encodeURIComponent(room)}`,
|
|
756
759
|
server: srv && srv.id !== 'public' ? { id: srv.id, name: srv.name, badge: policyWords(srv).badge } : null,
|
|
@@ -763,7 +766,7 @@ export function roomView(g, r, max, srv = null, { chat = false } = {}) {
|
|
|
763
766
|
}
|
|
764
767
|
|
|
765
768
|
function roomRows(rows, { names = true } = {}) {
|
|
766
|
-
return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${r.server ? `${esc(r.server.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken${r.ai ? ` · ${esc(r.ai)} AI` : ''}${r.policy === 'humans-only' ? ' · humans only' : ' · bots hold the rest'}</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><div class="room-acts">${r.watch ? `<a class="watchb" href="${esc(r.watch)}" aria-label="Watch ${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}">${icon('eye', 'ico')}<span>Watch</span></a>` : ''}<a class="join" href="${esc(r.play)}" data-play>Join</a></div></li>`).join('');
|
|
769
|
+
return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${r.server ? `${esc(r.server.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken${r.ai ? ` · ${esc(r.ai)} AI` : ''}${r.kind === 'app' ? ' · people' : r.policy === 'humans-only' ? ' · humans only' : ' · bots hold the rest'}</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><div class="room-acts">${r.watch ? `<a class="watchb" href="${esc(r.watch)}" aria-label="Watch ${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}">${icon('eye', 'ico')}<span>Watch</span></a>` : ''}<a class="join" href="${esc(r.play)}" data-play>Join</a></div></li>`).join('');
|
|
767
770
|
}
|
|
768
771
|
|
|
769
772
|
const fmtDay = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : ''; };
|
|
@@ -807,7 +810,7 @@ export function featuredOf(cat) {
|
|
|
807
810
|
export const homeLd = (cat, origin) => [studioNode(cat, origin, { full: true }), websiteNode(cat, origin)];
|
|
808
811
|
|
|
809
812
|
/** A game landing's structured data: its breadcrumbs and its VideoGame (worker/schema.mjs gameNode). */
|
|
810
|
-
export const landingLd = (cat, g, origin, opts = {}) => [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`]]), gameNode(cat, g, origin, opts)];
|
|
813
|
+
export const landingLd = (cat, g, origin, opts = {}) => [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`]]), gameNode(cat, g, origin, opts)];
|
|
811
814
|
|
|
812
815
|
/** The public games as a list of their landings (Games, Rooms). */
|
|
813
816
|
const gamesList = (cat, origin, id, name) => itemList(origin, id, name, (cat.games ?? []).map((g) => ({ name: g.name, path: `/${g.id}/` })));
|
|
@@ -821,23 +824,24 @@ const gamesList = (cat, origin, id, name) => itemList(origin, id, name, (cat.gam
|
|
|
821
824
|
*/
|
|
822
825
|
export function homeHero(cat, f, { playing = 0 } = {}) {
|
|
823
826
|
const name = studioName(cat);
|
|
824
|
-
const games = cat.games ?? [];
|
|
827
|
+
const games = (cat.games ?? []).filter((g) => (g.kind === 'app') === (f.kind === 'app'));
|
|
825
828
|
const tagline = cat.studio?.tagline ?? '';
|
|
826
829
|
return `<section class="hero home" aria-labelledby="hero-title">
|
|
827
830
|
${heroMedia(f.landing?.hero, { alt: f.landing?.hero?.alt })}
|
|
828
831
|
<div class="hero-copy">
|
|
829
|
-
<p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || 'featured game')}</span></p>
|
|
832
|
+
<p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || (f.kind === 'app' ? 'featured app' : 'featured game'))}</span></p>
|
|
830
833
|
<h1 class="title ${titleClass(f.name)}" id="hero-title">${esc(f.name)}</h1>
|
|
831
834
|
<p class="line">${esc(f.landing?.pitch ?? f.blurb)}</p>
|
|
832
|
-
<div class="row"><a class="play" href="
|
|
833
|
-
<a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="
|
|
835
|
+
<div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(appWords(f).open) : 'Play now — free'}</span></a>${liveLine(f, playing, { idle: f.kind === 'app' ? 'One screen, together' : 'Bots hold every empty seat · press Play and you are in a round' })}</div>
|
|
836
|
+
<a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/${f.kind === 'app' ? 'apps' : 'games'}/">${icon('games')}<span>All ${games.length} ${f.kind === 'app' ? 'apps' : 'games'}</span></a>` : ''}
|
|
834
837
|
</div>
|
|
835
838
|
</section>`;
|
|
836
839
|
}
|
|
837
840
|
|
|
838
841
|
export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
839
842
|
const name = studioName(cat);
|
|
840
|
-
const games = cat.games ?? [];
|
|
843
|
+
const games = (cat.games ?? []).filter((g) => g.kind !== 'app');
|
|
844
|
+
const apps = (cat.games ?? []).filter((g) => g.kind === 'app');
|
|
841
845
|
const posts = (cat.posts ?? []).slice(0, 3);
|
|
842
846
|
const songs = cat.songs ?? [];
|
|
843
847
|
const videos = cat.videos ?? [];
|
|
@@ -889,6 +893,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
889
893
|
<div class="head-row reveal"><div><p class="kicker">Games</p><h2 id="games-title">${esc(games.length === 1 ? `One game, live now` : `${games.length} games, live now`)}</h2></div>${games.length > 3 ? `<a class="more" href="/games/">All games ${icon('arrow')}</a>` : ''}</div>
|
|
890
894
|
<div class="cards">${games.slice(0, 6).map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div>
|
|
891
895
|
</div></section>` : '';
|
|
896
|
+
const appsBand = apps.length ? `<section class="band"><div class="band-in"><div class="head-row"><div><p class="kicker">Apps</p><h2>Spaces to do things together</h2></div><a href="/apps/">All apps</a></div><div class="cards">${apps.map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div></div></section>` : '';
|
|
892
897
|
const postsBand = posts.length ? `<section class="band hotband" aria-labelledby="posts-title"><div class="band-in">
|
|
893
898
|
<div class="head-row reveal"><div><p class="kicker">Posts</p><h2 id="posts-title">From the studio</h2></div><a class="more" href="/posts/">All posts ${icon('arrow')}</a></div>
|
|
894
899
|
<div class="cards posts">${posts.map(postCard).join('')}</div>
|
|
@@ -903,7 +908,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
903
908
|
description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : soon ? `${name}: first game coming soon.` : name),
|
|
904
909
|
origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true, ld: homeLd(cat, origin),
|
|
905
910
|
head: f?.landing?.hero?.wideImage ? `<link rel="preload" as="image" href="${esc(f.landing.hero.tallImage ?? f.landing.hero.wideImage)}">` : '',
|
|
906
|
-
main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
|
|
911
|
+
main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${appsBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
|
|
907
912
|
});
|
|
908
913
|
}
|
|
909
914
|
|
|
@@ -913,13 +918,15 @@ function pageHead(kicker, title, lead, extra = '') {
|
|
|
913
918
|
return `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
|
|
914
919
|
}
|
|
915
920
|
|
|
916
|
-
export function gamesPage(cat, { origin = '', live = {} } = {}) {
|
|
917
|
-
const games = cat.games ?? [];
|
|
921
|
+
export function gamesPage(cat, { origin = '', live = {}, kind = 'game' } = {}) {
|
|
922
|
+
const games = (cat.games ?? []).filter((g) => (g.kind === 'app' ? 'app' : 'game') === kind);
|
|
923
|
+
const apps = kind === 'app';
|
|
924
|
+
const label = apps ? 'Apps' : 'Games';
|
|
918
925
|
const name = studioName(cat);
|
|
919
926
|
return layout(cat, {
|
|
920
|
-
title:
|
|
921
|
-
ld: [breadcrumbs(origin, [['Home', '/'], [
|
|
922
|
-
main: `${pageHead(name, '
|
|
927
|
+
title: `${label} · ${name}`, description: apps ? `Apps from ${name}: one screen, live together.` : `Every game from ${name}: press Play and you are in a live room.`, origin, path: `/${kind}s/`, page: `${kind}s`, active: `${kind}s`,
|
|
928
|
+
ld: [breadcrumbs(origin, [['Home', '/'], [label, `/${kind}s/`]]), gamesList({ ...cat, games }, origin, `/${kind}s/#list`, `${label} from ${name}`)],
|
|
929
|
+
main: `${pageHead(name, label, apps ? 'Open an app on your phone, tablet, kiosk or wall screen.' : `${games.length === 1 ? 'One game' : `${games.length} games`}. Press Play on a phone, a computer or a TV browser and you are in a live public room with whoever is playing; bots hold the empty seats.`)}
|
|
923
930
|
<div class="wrap"><div class="cards">${games.map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div></div>`,
|
|
924
931
|
});
|
|
925
932
|
}
|
|
@@ -927,15 +934,16 @@ export function gamesPage(cat, { origin = '', live = {} } = {}) {
|
|
|
927
934
|
export function roomsPage(cat, { origin = '', rooms = [] } = {}) {
|
|
928
935
|
const name = studioName(cat);
|
|
929
936
|
const games = cat.games ?? [];
|
|
937
|
+
const hasApps = games.some((g) => g.kind === 'app');
|
|
930
938
|
const total = rooms.reduce((n, r) => n + r.players, 0);
|
|
931
939
|
return layout(cat, {
|
|
932
|
-
title: `Rooms · ${name}`, description: `Public rooms playing now across ${name}'s games. Join one.`, origin, path: '/rooms/', page: 'rooms', active: 'rooms',
|
|
940
|
+
title: `Rooms · ${name}`, description: hasApps ? `Live rooms across ${name}'s apps and games. Join one.` : `Public rooms playing now across ${name}'s games. Join one.`, origin, path: '/rooms/', page: 'rooms', active: 'rooms',
|
|
933
941
|
// The games a room can be opened in, never who is in one now: a cached copy of the page must not go stale.
|
|
934
942
|
ld: [breadcrumbs(origin, [['Home', '/'], ['Rooms', '/rooms/']]), gamesList(cat, origin, '/rooms/#games', `Games with public rooms at ${name}`)],
|
|
935
|
-
main: `${pageHead('Live now', 'Rooms', rooms.length ? `${total} ${total === 1 ? 'person' : 'people'} playing in ${rooms.length} public ${rooms.length === 1 ? 'room' : 'rooms'} right now. Join one, or press Play on any game.` : `Public rooms across ${name}'s games, as they happen.`)}
|
|
943
|
+
main: `${pageHead('Live now', 'Rooms', hasApps ? `${total} people here. Open a screen below to join its room.` : rooms.length ? `${total} ${total === 1 ? 'person' : 'people'} playing in ${rooms.length} public ${rooms.length === 1 ? 'room' : 'rooms'} right now. Join one, or press Play on any game.` : `Public rooms across ${name}'s games, as they happen.`)}
|
|
936
944
|
<div class="wrap">
|
|
937
945
|
<ol class="rooms" data-rooms data-src="/api/rooms"${rooms.length ? '' : ' hidden'}>${roomRows(rooms)}</ol>
|
|
938
|
-
<div class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}
|
|
946
|
+
<div class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>${hasApps ? 'Nobody is here right now. Open an app below, or play a game.' : 'Nobody is in a room right now. Press Play on any game below: you start at once with bots in the empty seats, and whoever presses Play next lands in your room.'}</div>
|
|
939
947
|
<p class="sec">Start one</p>
|
|
940
948
|
<div class="cards">${games.map((g) => gameCard(g, 0)).join('')}</div>
|
|
941
949
|
</div>`,
|
|
@@ -979,7 +987,7 @@ export function serversPage(cat, g, { origin = '', servers = [], hidden = false,
|
|
|
979
987
|
const cards = [...(pub && !hidden ? [pub] : []), ...shown];
|
|
980
988
|
return layout(cat, {
|
|
981
989
|
title: `Servers · ${g.name}`, description: `${g.name}'s servers: ${SERVERS_LEAD}`, origin, path: `/${g.id}/servers/`, page: 'servers', active: 'games',
|
|
982
|
-
ld: pick ? null : [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`]])],
|
|
990
|
+
ld: pick ? null : [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`]])],
|
|
983
991
|
main: `${pageHead(g.name, pick ? 'Pick a server' : 'Servers', SERVERS_LEAD)}
|
|
984
992
|
<div class="wrap">${cards.length ? `<div class="scards">${cards.map((sv) => serverCard(g, sv)).join('')}</div>` : `<p class="quiet">No server is open right now.</p>`}
|
|
985
993
|
<p class="sec">Every policy</p><ul class="howto">${Object.values(POLICY_WORDS).map((w) => `<li>${esc(w.line.replace('{n}', 'N'))}</li>`).join('')}</ul></div>`,
|
|
@@ -1001,7 +1009,7 @@ export function serverPage(cat, g, sv, { origin = '', rooms = [], door = { ok: t
|
|
|
1001
1009
|
const blocked = !door.ok ? `<p class="door-why">${esc(doorWords(g, sv, door).line)}</p>` : '';
|
|
1002
1010
|
return layout(cat, {
|
|
1003
1011
|
title: `${sv.name} · ${g.name}`, description: `${sv.name}, a ${words.badge.toLowerCase()} server of ${g.name}. ${words.line}`, origin, path: `/${g.id}/s/${sv.id}/`, page: 'server', active: 'games',
|
|
1004
|
-
ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`], [sv.name, `/${g.id}/s/${sv.id}/`]])],
|
|
1012
|
+
ld: [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`], [sv.name, `/${g.id}/s/${sv.id}/`]])],
|
|
1005
1013
|
main: `<header class="head"><p class="kicker">${esc(g.name)} · server</p><h1>${esc(sv.name)}</h1>
|
|
1006
1014
|
<p class="lead"><span class="pol ${esc(sv.policy)}">${esc(words.badge)}</span> ${esc(words.line)}</p>${sv.blurb ? `<p class="lead">${esc(sv.blurb)}</p>` : ''}
|
|
1007
1015
|
${blocked}<div class="keys">${door.ok ? `<a class="btn" href="${esc(play)}" data-play>${PLAY_ICON}<span>Play on ${esc(sv.name)}</span></a>` : ''}${membership}</div></header>
|
|
@@ -1165,14 +1173,15 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1165
1173
|
const license = licenseLabel(g.license);
|
|
1166
1174
|
const h = L.hero ?? {};
|
|
1167
1175
|
const host = origin.replace(/^https?:\/\//, '');
|
|
1168
|
-
const playUrl = `${origin}
|
|
1176
|
+
const playUrl = `${origin}${openPath(g)}`;
|
|
1177
|
+
const app = g.kind === 'app';
|
|
1169
1178
|
let qr = '';
|
|
1170
1179
|
// Local development: no code a phone cannot open; the landing says to deploy instead of naming this computer's address.
|
|
1171
1180
|
const local = isLocalOrigin(origin);
|
|
1172
1181
|
if (!local) try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
|
|
1173
1182
|
const tvOn = L.tv !== false;
|
|
1174
1183
|
const max = g.players?.max ?? 8;
|
|
1175
|
-
const words = L.words
|
|
1184
|
+
const words = { ...appWords(g), ...L.words };
|
|
1176
1185
|
const many = words.many ?? 'players';
|
|
1177
1186
|
const kicker = L.kicker ?? `Free in your browser · <span class="k-opt">phone, computer or TV · </span>no download`;
|
|
1178
1187
|
const kickerHtml = L.kicker ? esc(L.kicker) : kicker;
|
|
@@ -1186,8 +1195,8 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1186
1195
|
${lineage ? `<p class="based-on">Based on ${ofWhat}</p>` : ''}
|
|
1187
1196
|
<p class="line">${esc(L.pitch ?? g.blurb)}</p>
|
|
1188
1197
|
<div class="row">
|
|
1189
|
-
<a class="play" href="
|
|
1190
|
-
${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
|
|
1198
|
+
<a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(words.open) : 'Play now — free'}</span></a>
|
|
1199
|
+
${liveLine(g, playing, { idle: app ? 'One screen, live together' : `Bots hold every empty seat · one tap and you are in` })}
|
|
1191
1200
|
</div>
|
|
1192
1201
|
${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${playing > 0 && rooms.length && watchOf(g) !== 'off' ? `<a class="also" href="/${esc(g.id)}/watch">${icon('eye')}<span>Watch a live room</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
|
|
1193
1202
|
</div>
|
|
@@ -1211,7 +1220,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1211
1220
|
</div></section>`;
|
|
1212
1221
|
|
|
1213
1222
|
const liveBand = `<section class="band tight" aria-labelledby="rooms-title"><div class="band-in">
|
|
1214
|
-
<div class="head-row reveal"><div><p class="kicker">Live rooms</p><h2 class="small-h" id="rooms-title">${rooms.length ? `${esc(playing)} ${playing === 1 ? esc(words.one ?? 'player') : esc(many)} in ${rooms.length} ${rooms.length === 1 ? 'room' : 'rooms'} right now` :
|
|
1223
|
+
<div class="head-row reveal"><div><p class="kicker">Live rooms</p><h2 class="small-h" id="rooms-title">${rooms.length ? `${esc(playing)} ${playing === 1 ? esc(words.one ?? 'player') : esc(many)} in ${rooms.length} ${rooms.length === 1 ? 'room' : 'rooms'} right now` : `Rooms open the moment you press ${appWords(g).open}`}</h2></div><a class="more" href="/rooms/">Every room ${icon('arrow')}</a></div>
|
|
1215
1224
|
<ol class="rooms" data-rooms data-src="/${esc(g.id)}/live"${rooms.length ? '' : ' hidden'}>${roomRows(rooms, { names: false })}</ol>
|
|
1216
1225
|
<p class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>No one is in a room this minute. Press Play and you start at once, with bots in the empty seats; whoever presses Play next takes a bot’s place in your room.</p>
|
|
1217
1226
|
${week ? `<div class="stat"><span>Played this week: <b>${esc(week.plays)}</b> ${week.plays === 1 ? 'time' : 'times'}</span><span><b>${esc(week.rounds)}</b> ${week.rounds === 1 ? 'round' : 'rounds'} with people</span>${week.peak ? `<span>Most at once today: <b>${esc(week.peak)}</b></span>` : ''}</div>` : ''}
|
|
@@ -1219,7 +1228,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1219
1228
|
|
|
1220
1229
|
const genres = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).filter((x) => typeof x === 'string' && x.trim());
|
|
1221
1230
|
const facts = [...genres, 'free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
|
|
1222
|
-
const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
|
|
1231
|
+
const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
|
|
1223
1232
|
const howBand = `<section class="band hotband" aria-labelledby="how-title"><div class="band-in split">
|
|
1224
1233
|
<div class="reveal">
|
|
1225
1234
|
<p class="kicker">How to play</p>
|
|
@@ -1251,13 +1260,13 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1251
1260
|
const band = (html) => (!html ? '' : /^\s*<section\b/i.test(html) ? html : `<section class="band tight"><div class="band-in">${html}</div></section>`);
|
|
1252
1261
|
const vars = { 'game.name': g.name, 'game.id': g.id, 'game.play': `/${g.id}/play` };
|
|
1253
1262
|
return layout(cat, {
|
|
1254
|
-
title: `${g.name} — play free in your browser`,
|
|
1255
|
-
description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
|
|
1256
|
-
playerGame: listed ? g : null, origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
|
|
1263
|
+
title: app ? `${g.name} — ${words.open}` : `${g.name} — play free in your browser`,
|
|
1264
|
+
description: app ? `${g.name}: ${L.pitch ?? g.blurb ?? ''} One transforming screen, live across your devices.` : `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
|
|
1265
|
+
playerGame: listed ? g : null, origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: app ? 'apps' : 'games', over: landingTokens(cat, L),
|
|
1257
1266
|
head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
|
|
1258
1267
|
// A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
|
|
1259
1268
|
ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
|
|
1260
|
-
main: `${hero}${
|
|
1269
|
+
main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(words.open)}</a><a class="ghost" href="/${esc(g.id)}/tv">Wall screen</a></div>${qr ? `<div class="scan"><span class="qr">${qr}</span><p>${esc(words.join)}</p></div>` : ''}</div></section>` : ways + liveBand}${servers ? serversBand(g, servers) : ''}${app ? '' : howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
|
|
1261
1270
|
});
|
|
1262
1271
|
}
|
|
1263
1272
|
|
|
@@ -1268,7 +1277,7 @@ export function creditsPage(cat, g, texts, { origin = '' } = {}) {
|
|
|
1268
1277
|
const of = lineage ? `<p class="sec">Based on</p><p>${lineage.page ? `<a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.name)}</a>` : esc(lineage.name)}${lineage.studio ? ` by ${esc(lineage.studio)}` : ''}</p>` : '';
|
|
1269
1278
|
return layout(cat, {
|
|
1270
1279
|
title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
|
|
1271
|
-
ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Credits', `/${g.id}/credits`]])],
|
|
1280
|
+
ld: [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`], ['Credits', `/${g.id}/credits`]])],
|
|
1272
1281
|
main: `${pageHead(g.name, 'Credits and licences', null, `<div class="keys"><a class="ghost" href="/${esc(g.id)}/">${icon('arrow')}<span>Back to ${esc(g.name)}</span></a></div>`)}
|
|
1273
1282
|
<div class="wrap">${of}${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
|
|
1274
1283
|
});
|
package/worker/standalone.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Worker: `app://game` in the desktop app (Electron), `capacitor://localhost` on iOS and `https://localhost` on
|
|
5
5
|
* Android (Capacitor). Those three are fixed by the shells, never by a game.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* A native shell may use public records for an app kind (worker/app-records.mjs), and otherwise: ask the Lobby which public room to join (`POST /<game>/api/lobby`),
|
|
8
8
|
* and read the answer. The room's socket (`/<game>/__net`) never asked where a page came from, so it needs nothing.
|
|
9
|
-
*
|
|
9
|
+
* Privileged records and everything else a page of this site does stay this site's own: no credentials are allowed across (there is no
|
|
10
10
|
* `access-control-allow-credentials` anywhere here), the lobby is never opened to `*`, and the POSTs that check
|
|
11
11
|
* `sameOrigin` (players, saves, the shop, chat reports, the office) still refuse an app, as they refuse any other
|
|
12
12
|
* site. So an app has no account, no cloud saves and no shop, and says so.
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.37.0';
|