@homie-rocks/studio 0.14.3 → 0.15.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
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { qrSvg } from './qr.mjs';
13
13
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
+ import { licenseLabel, licenseOf, remixRow } from './license.mjs';
14
15
 
15
16
  export const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
16
17
  const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
@@ -50,6 +51,7 @@ const I = {
50
51
  arrow: '<path d="M5 12h13M13 6l6 6-6 6"/>',
51
52
  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"/>',
52
53
  home: '<path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M10 20v-5h4v5"/>',
54
+ 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"/>',
53
55
  };
54
56
  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>`;
55
57
  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>';
@@ -122,6 +124,8 @@ img,video{display:block;max-width:100%}
122
124
  text-shadow:0 0 38px color-mix(in srgb,var(--hot) 50%,transparent),0 2px 0 color-mix(in srgb,var(--bg) 60%,transparent)}
123
125
  .title.t-long{font-size:clamp(44px,12vw,64px)}
124
126
  .title.t-xlong{font-size:clamp(38px,10vw,54px)}
127
+ .remix-of{margin:12px 0 0;font:600 14px/1.4 var(--text);color:var(--soft)}
128
+ .remix-of a{color:inherit;text-decoration:underline;text-underline-offset:3px}
125
129
  .line{margin:14px 0 22px;max-width:36ch;font:500 17px/1.45 var(--text);color:var(--soft);text-wrap:pretty}
126
130
  .play{display:flex;width:100%;align-items:center;justify-content:center;gap:12px;min-height:62px;padding:0 30px;border-radius:18px;
127
131
  background:var(--hot);color:var(--hot-ink);text-decoration:none;font:800 20px/1 var(--display);letter-spacing:-.01em;
@@ -233,6 +237,11 @@ img,video{display:block;max-width:100%}
233
237
  .pips i{width:8px;height:8px;border-radius:50%;background:color-mix(in srgb,var(--fg) 18%,transparent)}
234
238
  .pips i.on{background:#43ff9e;box-shadow:0 0 8px rgba(67,255,158,.55)}
235
239
  .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}
240
+ .room-acts{display:flex;gap:8px;align-items:center}
241
+ .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}
242
+ .watchb:hover{border-color:var(--fg);background:color-mix(in srgb,var(--fg) 6%,transparent)}
243
+ .watchb svg{width:18px;height:18px;flex:none}
244
+ @media (max-width:379px){.watchb span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.watchb{padding:0 12px}}
236
245
  .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)}
237
246
  .stat{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}
238
247
  .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)}
@@ -420,10 +429,13 @@ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.t
420
429
  if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(done,function(){})});
421
430
  // Live: the line under Play and the rooms list follow the site's own room counts.
422
431
  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
+ 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>';
423
433
  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);
424
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'));
425
435
  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);
426
- var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');li.appendChild(a);return li};
436
+ var acts=el('div','room-acts');
437
+ 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)}
438
+ var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');acts.appendChild(a);li.appendChild(acts);return li};
427
439
  var paintRooms=function(list,rooms){if(!list)return;var quiet=d.querySelector('[data-rooms-quiet]');list.textContent='';
428
440
  rooms.forEach(function(r){list.appendChild(roomRow(r))});list.hidden=!rooms.length;if(quiet)quiet.hidden=!!rooms.length;hand()};
429
441
  var live=d.querySelector('[data-live]'),list=d.querySelector('[data-rooms]');
@@ -636,14 +648,30 @@ function gameCard(g, playing = 0) {
636
648
  </article>`;
637
649
  }
638
650
 
651
+ /**
652
+ * What a game lets its watchers see (game.json "watch", NETPLAY.md section 16): `follow` (any player's view; the
653
+ * default), `overview` (the whole room only: a game with hidden hands or roles), or `off` (no watch door at all).
654
+ */
655
+ export function watchOf(g) {
656
+ const w = g?.watch;
657
+ if (w === false || w === 'off') return 'off';
658
+ if (w === 'overview') return 'overview';
659
+ return 'follow';
660
+ }
661
+
639
662
  /** A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). */
640
663
  export function roomView(g, r, max) {
641
664
  const n = Number(String(r.name ?? r.room ?? '').replace(/^pub-/, '')) || null;
642
- 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)}` };
665
+ const room = r.name ?? r.room;
666
+ return {
667
+ 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
+ // Watch this room from any player's view (section 16); a game that cannot be watched has no link.
669
+ watch: watchOf(g) === 'off' ? null : `/${g.id}/watch?room=${encodeURIComponent(room)}`,
670
+ };
643
671
  }
644
672
 
645
673
  function roomRows(rows, { names = true } = {}) {
646
- 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('');
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('');
647
675
  }
648
676
 
649
677
  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' }) : ''; };
@@ -908,9 +936,15 @@ export function jsonFeed(cat, posts, origin) {
908
936
  * public room, phone / computer / TV (with the join code), live rooms, how to play, credits, and "Make a game like
909
937
  * this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
910
938
  */
911
- export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null } = {}) {
939
+ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, remix = true } = {}) {
912
940
  const name = studioName(cat);
913
941
  const L = g.landing ?? {};
942
+ // A remix says what it is a remix of, linked, under its name and in its credits (game.json `remixOf`).
943
+ const lineage = remixRow(g.remixOf);
944
+ const ofWhat = lineage ? `${lineage.page ? `<a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.name)}</a>` : `<strong>${esc(lineage.name)}</strong>`}${lineage.studio ? ` by ${esc(lineage.studio)}` : ''}` : '';
945
+ // Open to remix: the source is in the build, the owner's switch is on, and the licence they picked allows it.
946
+ const license = licenseOf(g.license);
947
+ const remixable = Boolean(L.source) && remix !== false && license.kind !== 'no-remix';
914
948
  const h = L.hero ?? {};
915
949
  const host = origin.replace(/^https?:\/\//, '');
916
950
  const playUrl = `${origin}/${g.id}/play`;
@@ -929,12 +963,13 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
929
963
  <div class="hero-copy">
930
964
  <p class="kicker">${kickerHtml}</p>
931
965
  <h1 class="title ${titleClass(g.name)}" id="game-title">${esc(g.name)}</h1>
966
+ ${lineage ? `<p class="remix-of">Remix of ${ofWhat}</p>` : ''}
932
967
  <p class="line">${esc(L.pitch ?? g.blurb)}</p>
933
968
  <div class="row">
934
969
  <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
935
970
  ${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
936
971
  </div>
937
- ${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>` : ''}
972
+ ${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>` : ''}
938
973
  </div>
939
974
  </section>`;
940
975
 
@@ -979,27 +1014,28 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
979
1014
  const o = C.original;
980
1015
  const creditCards = [
981
1016
  `<div class="credit"><h3>Made by</h3><p><strong><a href="/">${esc(name)}</a></strong>${cat.studio?.tagline ? ` · ${esc(cat.studio.tagline)}` : ''}</p>${(C.people ?? []).length ? `<ul>${C.people.map((c) => `<li>${c.role ? `${esc(c.role)}: ` : ''}${c.url ? `<a href="${esc(c.url)}" rel="noopener">${esc(c.name)}</a>` : `<strong>${esc(c.name)}</strong>`}</li>`).join('')}</ul>` : ''}</div>`,
1017
+ lineage ? `<div class="credit"><h3>Remix of</h3><p>${ofWhat}.</p>${lineage.page ? `<p><a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.page.replace(/^https:\/\//, ''))}</a></p>` : ''}</div>` : '',
982
1018
  o && !o.house ? `<div class="credit"><h3>Based on</h3><p><strong>${esc(o.title)}</strong>${o.author ? ` by <strong>${esc(o.author)}</strong>` : ''}${o.year ? ` (${esc(o.year)})` : ''}${o.licence ? `, ${esc(o.licence)} licence` : ''}.</p>${o.url ? `<p><a href="${esc(o.url)}" rel="noopener">${esc(o.url.replace(/^https:\/\//, ''))}</a></p>` : ''}<p>Made multiplayer with Homie: public rooms, bots, join-in-progress, a new host when one leaves, touch controls and the big screen.</p></div>` : '',
983
1019
  (C.parts ?? []).length ? `<div class="credit"><h3>Also inside</h3><ul>${C.parts.map((p) => `<li>${esc(p.what)}${p.author ? `: ${esc(p.author)}` : ''}${p.url ? ` (<a href="${esc(p.url)}" rel="noopener">source</a>)` : ''}${p.licence ? `, ${p.licenceUrl ? `<a href="${esc(p.licenceUrl)}" rel="noopener">${esc(p.licence)}</a>` : esc(p.licence)}` : ''}</li>`).join('')}</ul></div>` : '',
984
- `<div class="credit"><h3>Built with</h3><p>Homie’s open engine: <a href="https://github.com/homie-rocks/homie" rel="noopener">@homie-rocks/studio</a> (rooms, netplay, the big screen), Apache-2.0.</p>${C.texts ? `<p><a href="/${esc(g.id)}/credits">Licences and full credits</a></p>` : ''}</div>`,
1020
+ `<div class="credit"><h3>Built with</h3><p>Homie’s open engine: <a href="https://github.com/homie-rocks/homie" rel="noopener">@homie-rocks/studio</a> (rooms, netplay, the big screen), Apache-2.0.</p>${L.source ? `<p>Its source: ${esc(licenseLabel(license))}.</p>` : ''}${C.texts ? `<p><a href="/${esc(g.id)}/credits">Licences and full credits</a></p>` : ''}</div>`,
985
1021
  ].filter(Boolean).join('');
986
1022
  const creditsBand = `<section class="band tight" aria-labelledby="credits-title"><div class="band-in"><p class="kicker reveal">Credits</p><h2 class="small-h reveal" id="credits-title">Who made ${esc(g.name)}</h2><div class="credits reveal">${creditCards}</div></div></section>`;
987
1023
 
988
1024
  const source = `${origin}/games/${g.id}/source.json`;
989
- const say = L.source
1025
+ const say = remixable
990
1026
  ? `Remix ${g.name} from ${source} into a game of my own in my Homie studio`
991
1027
  : `Make a multiplayer game like ${g.name} in my Homie studio`;
992
- const makeHref = L.source ? `https://homie.rocks/studio/?remix=${encodeURIComponent(source)}` : 'https://homie.rocks/studio/';
1028
+ const makeHref = remixable ? `https://homie.rocks/studio/?remix=${encodeURIComponent(source)}` : 'https://homie.rocks/studio/';
993
1029
  const makeBand = `<section class="band" aria-labelledby="make-title"><div class="band-in"><div class="make reveal">
994
- <p class="kicker">${L.source ? 'Open to remix' : 'Your turn'}</p>
1030
+ <p class="kicker">${remixable ? 'Open to remix' : 'Your turn'}</p>
995
1031
  <h2 id="make-title">Make a game like this</h2>
996
- <p class="lead">${L.source ? `${esc(g.name)}’s source is shared. Homie is a plugin for Claude Code and Codex: your AI copies this game into a studio of your own, makes it yours, and puts it online with public rooms like these, on your own free Cloudflare account.` : `Homie is a plugin for Claude Code and Codex: your AI sets up a studio of your own and makes a multiplayer game like ${esc(g.name)}, with public rooms like these, on your own free Cloudflare account.`}</p>
1032
+ <p class="lead">${remixable ? `${esc(g.name)}’s source is shared. Homie is a plugin for Claude Code and Codex: your AI copies this game into a studio of your own, makes it yours, and puts it online with public rooms like these, on your own free Cloudflare account.` : `Homie is a plugin for Claude Code and Codex: your AI sets up a studio of your own and makes a multiplayer game like ${esc(g.name)}, with public rooms like these, on your own free Cloudflare account.`}</p>
997
1033
  <ol class="say">
998
1034
  <li><div><span class="label">1 · In Claude Code</span><code>/plugin marketplace add homie-rocks/homie</code></div><button class="copy" type="button" data-copy="/plugin marketplace add homie-rocks/homie">${icon('copy')}<span data-copy-word>Copy</span></button></li>
999
1035
  <li><div><span class="label">2</span><code>/plugin install homie@homie</code></div><button class="copy" type="button" data-copy="/plugin install homie@homie">${icon('copy')}<span data-copy-word>Copy</span></button></li>
1000
1036
  <li><div><span class="label">3 · Then say</span><code>${esc(say)}</code></div><button class="copy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></li>
1001
1037
  </ol>
1002
- <div class="keys"><a class="btn" href="${esc(makeHref)}">${icon('spark')}<span>Make a game like this</span></a>${L.source ? `<a class="ghost" href="/games/${esc(g.id)}/source.json">See the source</a>` : ''}</div>
1038
+ <div class="keys"><a class="btn" href="${esc(makeHref)}">${icon('spark')}<span>Make a game like this</span></a>${remixable ? `<a class="ghost" href="/games/${esc(g.id)}/source.json">See the source</a>` : ''}</div>
1003
1039
  </div></div></section>`;
1004
1040
 
1005
1041
  // A partial that is its own <section> stands as it is; anything else sits in one of the page's bands.
@@ -1018,10 +1054,12 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1018
1054
  /** /<game>/credits: the licence texts the game ships with (CREDITS.md and every licence file credits.json names). */
1019
1055
  export function creditsPage(cat, g, texts, { origin = '' } = {}) {
1020
1056
  const name = studioName(cat);
1057
+ const lineage = remixRow(g.remixOf);
1058
+ const of = lineage ? `<p class="sec">Remix of</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>` : '';
1021
1059
  return layout(cat, {
1022
1060
  title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
1023
1061
  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>`)}
1024
- <div class="wrap">${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
1062
+ <div class="wrap">${of}${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
1025
1063
  });
1026
1064
  }
1027
1065
 
package/worker/stats.mjs CHANGED
@@ -9,6 +9,7 @@
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
@@ -53,7 +54,7 @@ CREATE TABLE IF NOT EXISTS stats_keys (
53
54
  ) WITHOUT ROWID;
54
55
  `;
55
56
 
56
- export const METRICS = Object.freeze(['visit', 'play', 'screen', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video']);
57
+ export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video']);
57
58
  const MAX_METRICS = new Set(['peak', 'peak-room']);
58
59
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
59
60
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
@@ -288,7 +289,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
288
289
  const of = (m, extra = () => true) => sum((r) => r.metric === m && r.subject === g.id && extra(r));
289
290
  return {
290
291
  id: g.id, name: g.name, seats: g.players?.max ?? null,
291
- visits: of('visit'), plays: of('play'), screens: of('screen'), rooms: of('room'),
292
+ visits: of('visit'), plays: of('play'), screens: of('screen'), watches: of('watch'), rooms: of('room'),
292
293
  rounds: of('round'), roundsWithPeople: of('round', (r) => r.source === 'people'), peopleInRounds: of('humans'),
293
294
  peakPlayers: maxOf((r) => r.metric === 'peak' && r.subject === g.id), peakInOneRoom: maxOf((r) => r.metric === 'peak-room' && r.subject === g.id),
294
295
  playingNow: now[g.id]?.players ?? 0, roomsNow: now[g.id]?.rooms ?? 0,
@@ -316,7 +317,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
316
317
  return {
317
318
  v: 1, kind: 'homie-studio-stats', studio: cat.studio?.name ?? null, range, ...(only ? { only } : {}),
318
319
  totals: {
319
- visits: sum((r) => r.metric === 'visit'), plays: sum((r) => r.metric === 'play'), screens: sum((r) => r.metric === 'screen'),
320
+ 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
321
  rooms: sum((r) => r.metric === 'room'), rounds: sum((r) => r.metric === 'round'), roundsWithPeople: sum((r) => r.metric === 'round' && r.source === 'people'),
321
322
  peopleInRounds: sum((r) => r.metric === 'humans'), songPlays: sum((r) => r.metric === 'song'), videoViews: sum((r) => r.metric === 'video'),
322
323
  peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
@@ -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.3';
2
+ export const STUDIO_VERSION_TAG = '0.15.0';