@homie-rocks/studio 0.15.0 → 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');
@@ -388,6 +389,27 @@ img,video{display:block;max-width:100%}
388
389
  .hero-media.drift img,.hero-media.drift::after,.hero-media.bare::after{animation:none}
389
390
  .js .reveal{opacity:1;transform:none;transition:none}
390
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}
391
413
  `;
392
414
 
393
415
  /* ------------------------------------------------------------------ the script (served at /_homie/site.js) */
@@ -431,7 +453,7 @@ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.t
431
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};
432
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>';
433
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);
434
- 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')));
435
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);
436
458
  var acts=el('div','room-acts');
437
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)}
@@ -451,6 +473,11 @@ if(src){
451
473
  var go=function(){setInterval(ask,15000);d.addEventListener('visibilitychange',ask)};
452
474
  if(d.prerendering===true)d.addEventListener('prerenderingchange',function(){ask();go()},{once:true});else go();
453
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})})});
454
481
  })();`;
455
482
 
456
483
  /* ------------------------------------------------------------------ the shell */
@@ -659,19 +686,26 @@ export function watchOf(g) {
659
686
  return 'follow';
660
687
  }
661
688
 
662
- /** A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). */
663
- export function roomView(g, r, max) {
664
- const n = Number(String(r.name ?? r.room ?? '').replace(/^pub-/, '')) || null;
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) {
665
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;
666
697
  return {
667
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)}`,
668
699
  // Watch this room from any player's view (section 16); a game that cannot be watched has no link.
669
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,
670
704
  };
671
705
  }
672
706
 
673
707
  function roomRows(rows, { names = true } = {}) {
674
- 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><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('');
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('');
675
709
  }
676
710
 
677
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' }) : ''; };
@@ -825,6 +859,103 @@ export function roomsPage(cat, { origin = '', rooms = [] } = {}) {
825
859
  });
826
860
  }
827
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
+
828
959
  export function postsPage(cat, { origin = '' } = {}) {
829
960
  const posts = cat.posts ?? [];
830
961
  const name = studioName(cat);
@@ -936,7 +1067,7 @@ export function jsonFeed(cat, posts, origin) {
936
1067
  * public room, phone / computer / TV (with the join code), live rooms, how to play, credits, and "Make a game like
937
1068
  * this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
938
1069
  */
939
- 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 } = {}) {
940
1071
  const name = studioName(cat);
941
1072
  const L = g.landing ?? {};
942
1073
  // A remix says what it is a remix of, linked, under its name and in its credits (game.json `remixOf`).
@@ -1047,7 +1178,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1047
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(),
1048
1179
  origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
1049
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>`,
1050
- 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}`,
1051
1182
  });
1052
1183
  }
1053
1184
 
package/worker/stats.mjs CHANGED
@@ -15,6 +15,9 @@
15
15
  * humans the people in finished rounds, summed
16
16
  * peak the most people playing one game at once that day (all its rooms), and peak-room in one room
17
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.
18
21
  * Each visit, play and screen row carries WHERE FROM: '' (typed, bookmarked, or this site's own link), the
19
22
  * referring site's host (homie.rocks, another studio, anywhere on the web), or `via:<tag>` from a ?via= link.
20
23
  * Concurrent players now come from the Lobby at read time; nothing stores them.
@@ -54,7 +57,7 @@ CREATE TABLE IF NOT EXISTS stats_keys (
54
57
  ) WITHOUT ROWID;
55
58
  `;
56
59
 
57
- export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', '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']);
58
61
  const MAX_METRICS = new Set(['peak', 'peak-room']);
59
62
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
60
63
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
@@ -292,6 +295,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
292
295
  visits: of('visit'), plays: of('play'), screens: of('screen'), watches: of('watch'), rooms: of('room'),
293
296
  rounds: of('round'), roundsWithPeople: of('round', (r) => r.source === 'people'), peopleInRounds: of('humans'),
294
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'),
295
299
  playingNow: now[g.id]?.players ?? 0, roomsNow: now[g.id]?.rooms ?? 0,
296
300
  };
297
301
  });
@@ -320,6 +324,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
320
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'),
321
325
  rooms: sum((r) => r.metric === 'room'), rounds: sum((r) => r.metric === 'round'), roundsWithPeople: sum((r) => r.metric === 'round' && r.source === 'people'),
322
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'),
323
328
  peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
324
329
  playingNow: perGame.reduce((n, g) => n + g.playingNow, 0),
325
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.15.0';
2
+ export const STUDIO_VERSION_TAG = '0.16.0';