@homie-rocks/studio 0.14.4 → 0.16.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/worker/site.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  import { qrSvg } from './qr.mjs';
13
13
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
14
  import { licenseLabel, licenseOf, remixRow } from './license.mjs';
15
+ import { POLICY_WORDS, policyWords } from './servers.mjs';
15
16
 
16
17
  export const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
17
18
  const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
@@ -51,6 +52,7 @@ const I = {
51
52
  arrow: '<path d="M5 12h13M13 6l6 6-6 6"/>',
52
53
  spark: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>',
53
54
  home: '<path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M10 20v-5h4v5"/>',
55
+ eye: '<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"/>',
54
56
  };
55
57
  const icon = (name, cls = 'ico') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${I[name] ?? ''}</svg>`;
56
58
  const PLAY_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><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>';
@@ -236,6 +238,11 @@ img,video{display:block;max-width:100%}
236
238
  .pips i{width:8px;height:8px;border-radius:50%;background:color-mix(in srgb,var(--fg) 18%,transparent)}
237
239
  .pips i.on{background:#43ff9e;box-shadow:0 0 8px rgba(67,255,158,.55)}
238
240
  .join{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 18px;border-radius:12px;background:var(--hot);color:var(--hot-ink);font:800 15px/1 var(--display);text-decoration:none}
241
+ .room-acts{display:flex;gap:8px;align-items:center}
242
+ .watchb{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:44px;padding:0 14px;border-radius:12px;border:1px solid color-mix(in srgb,var(--fg) 24%,transparent);color:var(--fg);font:700 15px/1 var(--display);text-decoration:none}
243
+ .watchb:hover{border-color:var(--fg);background:color-mix(in srgb,var(--fg) 6%,transparent)}
244
+ .watchb svg{width:18px;height:18px;flex:none}
245
+ @media (max-width:379px){.watchb span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.watchb{padding:0 12px}}
239
246
  .quiet{margin:22px 0 0;padding:18px 20px;border-radius:var(--r);border:1px dashed color-mix(in srgb,var(--fg) 22%,transparent);color:var(--soft)}
240
247
  .stat{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}
241
248
  .stat span{padding:8px 12px;border-radius:999px;background:var(--panel);border:1px solid var(--line);font:600 14px/1.2 var(--text);color:var(--soft)}
@@ -382,6 +389,27 @@ img,video{display:block;max-width:100%}
382
389
  .hero-media.drift img,.hero-media.drift::after,.hero-media.bare::after{animation:none}
383
390
  .js .reveal{opacity:1;transform:none;transition:none}
384
391
  }
392
+
393
+ /* servers (0.16.0): a game's named room pools, their policy badges, AI marks, and their doors */
394
+ .pol{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--fg) 26%,transparent);font:700 11px/1.4 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--soft);white-space:nowrap}
395
+ .pol.hybrid{border-color:color-mix(in srgb,var(--hot) 60%,transparent);color:var(--fg)}
396
+ .pol.beginner{border-color:color-mix(in srgb,#7dd3fc 70%,transparent);color:#bfe9ff}
397
+ .pol.humans-only{border-color:color-mix(in srgb,#43ff9e 60%,transparent);color:#c6ffe0}
398
+ .ai{display:inline-flex;align-items:center;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--hot) 22%,transparent);color:var(--fg);font:800 11px/1.4 var(--mono);letter-spacing:.06em;vertical-align:1px}
399
+ .scards{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:16px;margin-top:24px}
400
+ .scard{display:flex;flex-direction:column;gap:10px;padding:18px 20px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line)}
401
+ .scard .top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
402
+ .scard h3{margin:0;font:800 22px/1.1 var(--display);letter-spacing:-.02em}
403
+ .scard h3 a{text-decoration:none}
404
+ .scard p{margin:0;color:var(--soft);font-size:15px}
405
+ .scard .nums{color:var(--dim);font-size:14px}
406
+ .scard .acts{display:flex;gap:10px;align-items:center;margin-top:auto;flex-wrap:wrap}
407
+ .scard .acts .btn{min-height:44px;font-size:16px;padding:0 20px;flex:none}
408
+ .homeb{display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 14px;border-radius:12px;border:1px solid color-mix(in srgb,var(--fg) 24%,transparent);background:transparent;color:var(--fg);font:700 14px/1 var(--display);cursor:pointer;text-decoration:none}
409
+ .homeb[aria-pressed="true"]{border-color:var(--hot);color:var(--hot)}
410
+ .quickplay{display:inline-flex;gap:8px;align-items:center;margin-top:18px;color:var(--soft);font-weight:700;text-decoration:none}
411
+ .door-why{margin-top:14px;color:var(--soft);max-width:60ch}
412
+ .room .srv{color:var(--soft);font-weight:700}
385
413
  `;
386
414
 
387
415
  /* ------------------------------------------------------------------ the script (served at /_homie/site.js) */
@@ -423,10 +451,13 @@ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.t
423
451
  if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(done,function(){})});
424
452
  // Live: the line under Play and the rooms list follow the site's own room counts.
425
453
  var el=function(tag,cls,text){var e=d.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e};
454
+ 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>';
426
455
  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);
427
- var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+r.label));mid.appendChild(el('span',null,r.players+' of '+r.max+' seats taken · bots hold the rest'));
456
+ 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')));
428
457
  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);
429
- var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');li.appendChild(a);return li};
458
+ var acts=el('div','room-acts');
459
+ 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)}
460
+ var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');acts.appendChild(a);li.appendChild(acts);return li};
430
461
  var paintRooms=function(list,rooms){if(!list)return;var quiet=d.querySelector('[data-rooms-quiet]');list.textContent='';
431
462
  rooms.forEach(function(r){list.appendChild(roomRow(r))});list.hidden=!rooms.length;if(quiet)quiet.hidden=!!rooms.length;hand()};
432
463
  var live=d.querySelector('[data-live]'),list=d.querySelector('[data-rooms]');
@@ -442,6 +473,11 @@ if(src){
442
473
  var go=function(){setInterval(ask,15000);d.addEventListener('visibilitychange',ask)};
443
474
  if(d.prerendering===true)d.addEventListener('prerenderingchange',function(){ask();go()},{once:true});else go();
444
475
  }
476
+ // A server's "Make this my home" and "Leave" (a signed-in player): POST /<game>/s/<id>/home, then the page again.
477
+ [].forEach.call(d.querySelectorAll('[data-server-home]'),function(b){b.addEventListener('click',function(){
478
+ var o={};try{o=JSON.parse(b.getAttribute('data-server-home')||'{}')}catch(e){}b.disabled=true;
479
+ fetch(o.at,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(o.body||{})}).then(function(r){return r.json()}).then(function(j){
480
+ if(j&&j.ok){location.reload();return}b.disabled=false;b.textContent=(j&&j.message)||'That did not work'}).catch(function(){b.disabled=false})})});
445
481
  })();`;
446
482
 
447
483
  /* ------------------------------------------------------------------ the shell */
@@ -639,14 +675,37 @@ function gameCard(g, playing = 0) {
639
675
  </article>`;
640
676
  }
641
677
 
642
- /** A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). */
643
- export function roomView(g, r, max) {
644
- const n = Number(String(r.name ?? r.room ?? '').replace(/^pub-/, '')) || null;
645
- return { game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room: r.name ?? r.room, players: r.players, max, cover: coverOf(g), play: `/${g.id}/play?room=${encodeURIComponent(r.name ?? r.room)}` };
678
+ /**
679
+ * What a game lets its watchers see (game.json "watch", NETPLAY.md section 16): `follow` (any player's view; the
680
+ * default), `overview` (the whole room only: a game with hidden hands or roles), or `off` (no watch door at all).
681
+ */
682
+ export function watchOf(g) {
683
+ const w = g?.watch;
684
+ if (w === false || w === 'off') return 'off';
685
+ if (w === 'overview') return 'overview';
686
+ return 'follow';
687
+ }
688
+
689
+ /**
690
+ * A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). A server's room
691
+ * (0.16.0) names its server and policy, and how many AI are in it (always marked AI).
692
+ */
693
+ export function roomView(g, r, max, srv = null) {
694
+ const room = r.name ?? r.room;
695
+ const m = /^(?:pub|s-[a-z0-9-]+)-(\d+)$/.exec(String(room ?? ''));
696
+ const n = m ? Number(m[1]) : null;
697
+ return {
698
+ game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room, players: r.players, max, cover: coverOf(g), play: `/${g.id}/play?room=${encodeURIComponent(room)}`,
699
+ // Watch this room from any player's view (section 16); a game that cannot be watched has no link.
700
+ watch: watchOf(g) === 'off' ? null : `/${g.id}/watch?room=${encodeURIComponent(room)}`,
701
+ server: srv && srv.id !== 'public' ? { id: srv.id, name: srv.name, badge: policyWords(srv).badge } : null,
702
+ policy: srv?.policy ?? 'open',
703
+ ai: Number(r.ai ?? r.agents ?? 0) || 0,
704
+ };
646
705
  }
647
706
 
648
707
  function roomRows(rows, { names = true } = {}) {
649
- 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)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken · 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><a class="join" href="${esc(r.play)}" data-play>Join</a></li>`).join('');
708
+ 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('');
650
709
  }
651
710
 
652
711
  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' }) : ''; };
@@ -800,6 +859,103 @@ export function roomsPage(cat, { origin = '', rooms = [] } = {}) {
800
859
  });
801
860
  }
802
861
 
862
+ /* ------------------------------------------------------------------ servers (0.16.0) */
863
+
864
+ const SERVERS_LEAD = 'Rooms on a server only ever match players from that server.';
865
+
866
+ /** One server's card: its name, policy badge and line, who is on it now, and Play (and its page). */
867
+ function serverCard(g, sv) {
868
+ const words = policyWords(sv);
869
+ const nums = [`${sv.playing} playing`, sv.ai ? `${sv.ai} AI` : '', sv.members ? `${sv.members} ${sv.members === 1 ? 'member' : 'members'}` : ''].filter(Boolean).join(' · ');
870
+ const page = sv.id === 'public' ? `/${g.id}/` : `/${g.id}/s/${sv.id}/`;
871
+ const play = sv.id === 'public' ? `/${g.id}/play` : `/${g.id}/s/${sv.id}/play`;
872
+ return `<article class="scard reveal" data-server="${esc(sv.id)}"><div class="top"><h3><a href="${esc(page)}">${esc(sv.name)}</a></h3><span class="pol ${esc(sv.policy)}">${esc(words.badge)}</span></div>
873
+ <p>${esc(sv.blurb || words.line)}</p><p class="nums">${esc(nums)}</p>
874
+ <div class="acts"><a class="btn" href="${esc(play)}" data-play>${PLAY_ICON}Play</a>${sv.id === 'public' ? '' : `<a class="ghost" href="${esc(page)}">About</a>`}</div></article>`;
875
+ }
876
+
877
+ /** Servers worth showing to everyone: open, listed, and not the public one (Quick play is its own link). */
878
+ const shownServers = (list) => (list ?? []).filter((x) => x.id !== 'public' && x.listed && x.state === 'open');
879
+
880
+ /** The landing's Servers band: there when the game has a server besides Quick play. */
881
+ export function serversBand(g, list) {
882
+ const shown = shownServers(list);
883
+ if (!shown.length) return '';
884
+ const pub = (list ?? []).find((x) => x.id === 'public');
885
+ return `<section class="band tight" aria-labelledby="servers-title" data-servers-band><div class="band-in">
886
+ <div class="head-row reveal"><div><p class="kicker">Servers</p><h2 class="small-h" id="servers-title">${esc(SERVERS_LEAD)}</h2></div><a class="more" href="/${esc(g.id)}/servers/">Every server ${icon('arrow')}</a></div>
887
+ <div class="scards">${shown.map((sv) => serverCard(g, sv)).join('')}</div>
888
+ ${pub && pub.state === 'open' ? `<a class="quickplay" href="/${esc(g.id)}/play" data-play>Quick play ${icon('arrow')} <span class="dim">(the public server)</span></a>` : ''}
889
+ </div></section>`;
890
+ }
891
+
892
+ /** /<game>/servers/: every server anyone can join (and, with `pick`, the page Play shows when Quick play is off). */
893
+ export function serversPage(cat, g, { origin = '', servers = [], hidden = false, pick = false } = {}) {
894
+ const shown = shownServers(servers);
895
+ const pub = servers.find((x) => x.id === 'public');
896
+ const cards = [...(pub && !hidden ? [pub] : []), ...shown];
897
+ return layout(cat, {
898
+ title: `Servers · ${g.name}`, description: `${g.name}'s servers: ${SERVERS_LEAD}`, origin, path: `/${g.id}/servers/`, page: 'servers', active: 'games',
899
+ main: `${pageHead(g.name, pick ? 'Pick a server' : 'Servers', SERVERS_LEAD)}
900
+ <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>`}
901
+ <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>`,
902
+ });
903
+ }
904
+
905
+ /**
906
+ * /<game>/s/<id>/: a server's page. Its name, blurb and badge with its line; its live rooms (AI marked); how many
907
+ * belong to it and this player's membership ("Make this my home"); and a big Play (or what its door asks for).
908
+ */
909
+ export function serverPage(cat, g, sv, { origin = '', rooms = [], door = { ok: true }, member = null, signedIn = false } = {}) {
910
+ const words = policyWords(sv);
911
+ const play = `/${g.id}/s/${sv.id}/play`;
912
+ const at = `/${g.id}/s/${sv.id}/home`;
913
+ const homeAct = (body, label, pressed = false) => `<button class="homeb" type="button" aria-pressed="${pressed ? 'true' : 'false'}" data-server-home="${esc(JSON.stringify({ at, body }))}">${esc(label)}</button>`;
914
+ const membership = !signedIn ? `<a class="homeb" href="/account/?next=${encodeURIComponent(`/${g.id}/s/${sv.id}/`)}">Sign in to make it your home</a>`
915
+ : member ? `${member.home ? homeAct({ join: true, home: false }, '★ Your home', true) : homeAct({ join: true, home: true }, '☆ Make this my home')}${member.role === 'mentor' ? ' <span class="pol beginner">Mentor</span>' : member.role === 'mod' ? ' <span class="pol">Mod</span>' : ''}${member.role === 'member' ? ` ${homeAct({ join: false }, 'Leave')}` : ''}`
916
+ : homeAct({ join: true, home: true }, '☆ Make this my home');
917
+ const blocked = !door.ok ? `<p class="door-why">${esc(doorWords(g, sv, door).line)}</p>` : '';
918
+ return layout(cat, {
919
+ 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',
920
+ main: `<header class="head"><p class="kicker">${esc(g.name)} · server</p><h1>${esc(sv.name)}</h1>
921
+ <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>` : ''}
922
+ ${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>
923
+ <div class="wrap"><p class="sec">Live rooms · ${esc(sv.playing)} playing${sv.ai ? ` · ${esc(sv.ai)} AI` : ''} · ${esc(sv.members)} ${sv.members === 1 ? 'member' : 'members'}</p>
924
+ ${rooms.length ? `<ol class="rooms">${roomRows(rooms, { names: false })}</ol>` : `<p class="quiet">Nobody is on ${esc(sv.name)} this minute. Press Play and you open its first room.</p>`}
925
+ <p class="sec"><a href="/${esc(g.id)}/servers/">Every server of ${esc(g.name)}</a></p></div>`,
926
+ });
927
+ }
928
+
929
+ /** What a server's door says to someone it does not let in (DESIGN section 7.1). */
930
+ function doorWords(g, sv, { why, days = null } = {}) {
931
+ switch (why) {
932
+ case 'account': return { title: `Sign in to play on ${sv.name}`, line: `${sv.name} is for players with an account on this studio (a passkey: no password, nothing to remember).` };
933
+ case 'invite': return { title: `${sv.name} is invite-only`, line: `${sv.name} lets in players with an invite. Have a code? Enter it to play.` };
934
+ case 'veteran': return { title: `${sv.name} is for new players`, line: `${sv.name} is for new players.${days ? ` You've been playing ${g.name} for ${days} days:` : ''} try another server or Quick play.` };
935
+ default: return { title: `${sv.name} is closed`, line: `${sv.name} is not open right now.` };
936
+ }
937
+ }
938
+
939
+ /** A door that does not let this visitor in: said plainly, with the way on (sign in, an invite code, another server). */
940
+ export function doorPage(cat, g, sv, { why = 'closed', days = null, next = '', watch = false, code = '' } = {}) {
941
+ const shown = String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
942
+ const pretty = shown.length === 8 ? `${shown.slice(0, 4)}-${shown.slice(4)}` : shown;
943
+ const w = doorWords(g, sv, { why, days });
944
+ const quick = `<a class="ghost" href="/${esc(g.id)}/play" data-play>Quick play</a>`;
945
+ const others = `<a class="ghost" href="/${esc(g.id)}/servers/">Other servers</a>`;
946
+ const acts = why === 'account'
947
+ ? `<a class="btn" href="/account/?next=${encodeURIComponent(next || `/${g.id}/s/${sv.id}/play`)}">Sign in to play on ${esc(sv.name)}</a>${others}`
948
+ : why === 'invite'
949
+ ? `<form method="post" action="/${esc(g.id)}/invite" class="keys" style="flex-wrap:wrap;gap:10px;margin-top:0"><input name="code" value="${esc(pretty)}" placeholder="XXXX-XXXX" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" maxlength="12" required aria-label="Invite code" style="font:600 18px/1 ui-monospace,Menlo,monospace;letter-spacing:.08em;padding:12px 14px;border-radius:12px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;min-width:12ch"><button class="btn" type="submit">Join ${esc(sv.name)}</button></form>${others}`
950
+ : `${quick}${others}`;
951
+ return layout(cat, {
952
+ title: `${w.title} · ${g.name}`, page: 'door', status: 403, active: 'games', extraHeaders: { 'cache-control': 'no-store, private', 'x-robots-tag': 'noindex' },
953
+ main: `<header class="head" data-door="${esc(why)}"><p class="kicker">${esc(g.name)} · ${esc(sv.name)}${watch ? ' · watch' : ''}</p><h1>${esc(w.title)}</h1>
954
+ <p class="lead">${esc(w.line)}</p><p class="door-why"><span class="pol ${esc(sv.policy)}">${esc(policyWords(sv).badge)}</span> ${esc(policyWords(sv).line)}</p>
955
+ <div class="keys">${acts}</div></header>`,
956
+ });
957
+ }
958
+
803
959
  export function postsPage(cat, { origin = '' } = {}) {
804
960
  const posts = cat.posts ?? [];
805
961
  const name = studioName(cat);
@@ -911,7 +1067,7 @@ export function jsonFeed(cat, posts, origin) {
911
1067
  * public room, phone / computer / TV (with the join code), live rooms, how to play, credits, and "Make a game like
912
1068
  * this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
913
1069
  */
914
- export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, remix = true } = {}) {
1070
+ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, remix = true, servers = null } = {}) {
915
1071
  const name = studioName(cat);
916
1072
  const L = g.landing ?? {};
917
1073
  // A remix says what it is a remix of, linked, under its name and in its credits (game.json `remixOf`).
@@ -944,7 +1100,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
944
1100
  <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
945
1101
  ${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
946
1102
  </div>
947
- ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</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>` : ''}
1103
+ ${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>` : ''}
948
1104
  </div>
949
1105
  </section>`;
950
1106
 
@@ -1022,7 +1178,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1022
1178
  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(),
1023
1179
  origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
1024
1180
  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)">` : ''}` : ''}<script type="application/ld+json">${jsonScript(ld)}</script>`,
1025
- main: `${hero}${ways}${liveBand}${howBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
1181
+ main: `${hero}${ways}${liveBand}${servers ? serversBand(g, servers) : ''}${howBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
1026
1182
  });
1027
1183
  }
1028
1184
 
package/worker/stats.mjs CHANGED
@@ -9,11 +9,15 @@
9
9
  * visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
10
10
  * play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
11
11
  * screen a big screen opened (/<game>/tv)
12
+ * watch a watch page opened (/<game>/watch: a live room from any player's view; 0.15.0)
12
13
  * room a room opened: the first seat taken in an empty room (public, or a named one)
13
14
  * round a round finished (source: `people` when a person was in it, `bots` when only bots were)
14
15
  * humans the people in finished rounds, summed
15
16
  * peak the most people playing one game at once that day (all its rooms), and peak-room in one room
16
17
  * song a song's player started; video: a video's player started
18
+ * agent-minutes minutes an AI sat in a seat (an agent pass, NETPLAY.md section 17; 0.16.0), counted as it leaves
19
+ * brain-calls, brain-neurons reserved for AI guides' brains (a later version): calls made and Workers AI used
20
+ * A round's rows count agents as AI, never as people: `humans` is people only.
17
21
  * Each visit, play and screen row carries WHERE FROM: '' (typed, bookmarked, or this site's own link), the
18
22
  * referring site's host (homie.rocks, another studio, anywhere on the web), or `via:<tag>` from a ?via= link.
19
23
  * Concurrent players now come from the Lobby at read time; nothing stores them.
@@ -53,7 +57,7 @@ CREATE TABLE IF NOT EXISTS stats_keys (
53
57
  ) WITHOUT ROWID;
54
58
  `;
55
59
 
56
- export const METRICS = Object.freeze(['visit', 'play', 'screen', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video']);
60
+ export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons']);
57
61
  const MAX_METRICS = new Set(['peak', 'peak-room']);
58
62
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
59
63
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
@@ -288,9 +292,10 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
288
292
  const of = (m, extra = () => true) => sum((r) => r.metric === m && r.subject === g.id && extra(r));
289
293
  return {
290
294
  id: g.id, name: g.name, seats: g.players?.max ?? null,
291
- visits: of('visit'), plays: of('play'), screens: of('screen'), rooms: of('room'),
295
+ visits: of('visit'), plays: of('play'), screens: of('screen'), watches: of('watch'), rooms: of('room'),
292
296
  rounds: of('round'), roundsWithPeople: of('round', (r) => r.source === 'people'), peopleInRounds: of('humans'),
293
297
  peakPlayers: maxOf((r) => r.metric === 'peak' && r.subject === g.id), peakInOneRoom: maxOf((r) => r.metric === 'peak-room' && r.subject === g.id),
298
+ agentMinutes: of('agent-minutes'),
294
299
  playingNow: now[g.id]?.players ?? 0, roomsNow: now[g.id]?.rooms ?? 0,
295
300
  };
296
301
  });
@@ -316,9 +321,10 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
316
321
  return {
317
322
  v: 1, kind: 'homie-studio-stats', studio: cat.studio?.name ?? null, range, ...(only ? { only } : {}),
318
323
  totals: {
319
- visits: sum((r) => r.metric === 'visit'), plays: sum((r) => r.metric === 'play'), screens: sum((r) => r.metric === 'screen'),
324
+ visits: sum((r) => r.metric === 'visit'), plays: sum((r) => r.metric === 'play'), screens: sum((r) => r.metric === 'screen'), watches: sum((r) => r.metric === 'watch'),
320
325
  rooms: sum((r) => r.metric === 'room'), rounds: sum((r) => r.metric === 'round'), roundsWithPeople: sum((r) => r.metric === 'round' && r.source === 'people'),
321
326
  peopleInRounds: sum((r) => r.metric === 'humans'), songPlays: sum((r) => r.metric === 'song'), videoViews: sum((r) => r.metric === 'video'),
327
+ agentMinutes: sum((r) => r.metric === 'agent-minutes'),
322
328
  peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
323
329
  playingNow: perGame.reduce((n, g) => n + g.playingNow, 0),
324
330
  },
@@ -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.14.4';
2
+ export const STUDIO_VERSION_TAG = '0.16.0';