@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/pages.mjs CHANGED
@@ -3,6 +3,9 @@
3
3
  * worker/site.mjs. Every name a player typed is escaped.
4
4
  */
5
5
  import { esc, layout } from './site.mjs';
6
+ import { NET_PALETTE } from './room.mjs';
7
+ import { SKILLS } from './agents.mjs';
8
+ import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
6
9
  import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
7
10
 
8
11
  export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
@@ -21,14 +24,24 @@ export function frameAncestors(cat) {
21
24
  return ["'self'", ...extra].join(' ');
22
25
  }
23
26
 
24
- /** What a play or big-screen page answers to a room code it cannot use: said on the page, never a silent public room. */
25
- export function badRoomPage(cat, g, raw, { screen = false } = {}) {
27
+ /** What a play, big-screen or watch page answers to a room code it cannot use: said on the page, never a silent public room. */
28
+ export function badRoomPage(cat, g, raw, { screen = false, watch = false } = {}) {
26
29
  const shown = String(raw ?? '').slice(0, 64);
27
30
  return layout(cat, {
28
31
  title: `That room link does not work · ${g.name}`, page: 'bad-room', status: 400,
29
32
  main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>That room link does not work</h1>
30
- <p class="lead">The room code in this link${shown ? ` (<span class="addr">${esc(shown)}${String(raw).length > 64 ? '…' : ''}</span>)` : ''} is not one a room can have: a code is 1 to 32 letters, digits, hyphens or underscores. Ask whoever sent it for the link again, or play in a public room.</p>
31
- <div class="keys"><a class="btn" href="/${esc(g.id)}/${screen ? 'tv' : 'play'}" data-play>Join a public room</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
33
+ <p class="lead">The room code in this link${shown ? ` (<span class="addr">${esc(shown)}${String(raw).length > 64 ? '…' : ''}</span>)` : ''} is not one a room can have: a code is 1 to 32 letters, digits, hyphens or underscores. Ask whoever sent it for the link again, or ${watch ? 'watch' : 'play in'} a public room.</p>
34
+ <div class="keys"><a class="btn" href="/${esc(g.id)}/${watch ? 'watch' : screen ? 'tv' : 'play'}"${watch ? '' : ' data-play'}>${watch ? 'Watch a public room' : 'Join a public room'}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
35
+ });
36
+ }
37
+
38
+ /** A game whose game.json says `"watch": false` has no watch door: the page says so and offers Play. */
39
+ export function noWatchPage(cat, g) {
40
+ return layout(cat, {
41
+ title: `${g.name} is not shown to watchers`, page: 'no-watch', status: 404,
42
+ main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>${esc(g.name)} is played, not watched</h1>
43
+ <p class="lead">Its rooms are not shown to watchers: what each player sees stays theirs. Press Play and you are in a room, with bots in the empty seats.</p>
44
+ <div class="keys"><a class="btn" href="/${esc(g.id)}/play" data-play>Play ${esc(g.name)}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
32
45
  });
33
46
  }
34
47
 
@@ -71,7 +84,7 @@ export function sharePlaces(value) {
71
84
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
72
85
  * covers the middle of the screen or a thumb.
73
86
  */
74
- export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public' } = {}) {
87
+ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
75
88
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
76
89
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
77
90
  const places = sharePlaces(g.screen?.share);
@@ -137,16 +150,28 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
137
150
  .notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
138
151
  .notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
139
152
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
140
- [hidden] { display: none !important; }${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
153
+ [hidden] { display: none !important; }
154
+ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
141
155
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
142
156
  // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
143
- const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}) };
157
+ // The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
158
+ const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
159
+ // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
160
+ const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
161
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE };
144
162
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
145
163
  const joinCorner = corner('join', 'bottom-right');
146
164
  const first = places.desk;
147
165
  const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
148
166
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
149
167
  <button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
168
+ <button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button>
169
+ <div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
170
+ <div class="code"><b data-server-name></b><span data-server-badge></span></div>
171
+ <p class="sline" data-server-line></p>
172
+ <div class="srow" data-level-row hidden><span>AI level: <b data-level-name></b></span><button type="button" data-level-vote>Change ▾</button></div>
173
+ <label class="srow quiet-ai"><span>Quiet AI <small>hides AI chat on this screen</small></span><input type="checkbox" data-quiet-ai></label>
174
+ </div>
150
175
  <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
151
176
  <div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
152
177
  <div class="acts">
@@ -162,6 +187,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
162
187
  return layoutless(`${g.name} · play`, `
163
188
  <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
164
189
  <div class="chip" data-chip><span data-status>finding a room…</span></div>
190
+ <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
165
191
  <div class="card" data-results hidden></div>
166
192
  <div class="card" data-screen hidden></div>
167
193
  ${joinCard}${share}
@@ -170,6 +196,42 @@ ${joinCard}${share}
170
196
  <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
171
197
  }
172
198
 
199
+ /*
200
+ * SERVERS AND THE AI DIAL (0.16.0, NETPLAY.md section 17): the server pill beside the room button (its name and
201
+ * badge; its sheet has the policy's line, the AI's level with "Change", and Quiet AI), and the party's vote card on
202
+ * how strong the AI should be (a bottom sheet on a phone; keys 1-5 on a computer). Nothing covers the middle of the
203
+ * screen except the card, for a few seconds, when a vote is open.
204
+ */
205
+ const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--hot); flex: none; }
206
+ .spill.dim .sdot { display: block; }
207
+ .ssheet .sline { margin: 0 2px 10px; color: #c3cad9; font-weight: 500; line-height: 1.4; }
208
+ .ssheet .srow { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 40px; margin: 0 2px; border-top: 1px solid rgba(255,255,255,.08); font-weight: 600; }
209
+ .ssheet .srow small { display: block; color: #8b93a7; font-weight: 500; font-size: 11px; }
210
+ .ssheet .srow button { min-height: 34px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.18); background: transparent; color: inherit; font: inherit; cursor: pointer; }
211
+ .ssheet .srow input { width: 20px; height: 20px; accent-color: var(--hot); }
212
+ .aipill { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: rgba(255,207,90,.2); color: #ffe7a8; font: 800 10px/1.6 ui-monospace, Menlo, monospace; letter-spacing: .06em; vertical-align: 1px; }
213
+ .vote { box-sizing: border-box; position: fixed; left: 50%; top: 50%; z-index: 12; transform: translate(-50%, -50%); width: min(560px, calc(100vw - 24px)); padding: 16px 16px 14px; border-radius: 18px; background: rgba(8,12,22,.95); border: 1px solid var(--hot); color: #eef1f8; font: 600 14px/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 24px 70px rgba(0,0,0,.55); touch-action: manipulation; -webkit-user-select: none; user-select: none; animation: rise .25s ease-out; }
214
+ @keyframes rise { from { opacity: 0; transform: translate(-50%, -46%); } }
215
+ .vote .vh { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 2px 12px; }
216
+ .vote .vh b { font-size: 18px; letter-spacing: -.01em; }
217
+ .vote .vh span { color: #aab3c7; font-weight: 600; font-size: 12px; white-space: nowrap; }
218
+ .vote .opts { display: grid; grid-template-columns: repeat(var(--n, 5), 1fr); gap: 8px; }
219
+ .vote .opts button { min-height: 58px; padding: 6px 4px; border-radius: 13px; border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.04); color: inherit; font: inherit; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
220
+ .vote .opts button b { font-size: 16px; }
221
+ .vote .opts button small { color: #9aa3b7; font-size: 11px; font-weight: 600; }
222
+ .vote .opts button[aria-pressed="true"] { background: var(--hot); color: #0b0b10; border-color: transparent; }
223
+ .vote .opts button[aria-pressed="true"] small { color: #3b3420; }
224
+ .vote .legend { display: flex; justify-content: space-between; gap: 10px; margin: 10px 4px 0; color: #8b93a7; font-size: 11px; font-weight: 600; }
225
+ .vote .legend i { flex: 1; border-bottom: 2px dotted rgba(255,255,255,.18); margin-bottom: 4px; }
226
+ .vote .foot { margin: 10px 2px 0; text-align: center; color: #c3cad9; font-weight: 600; }
227
+ @media (max-width: 540px) {
228
+ .vote { top: auto; bottom: 0; left: 0; right: 0; width: auto; transform: none; border-radius: 20px 20px 0 0; padding-bottom: max(16px, env(safe-area-inset-bottom)); animation: up .25s ease-out; }
229
+ @keyframes up { from { opacity: 0; transform: translateY(20px); } }
230
+ .vote .opts { grid-template-columns: repeat(3, 1fr); }
231
+ .vote .opts button { min-height: 64px; }
232
+ .vote .legend { display: none; }
233
+ }`;
234
+
173
235
  /** The play page's own document (no site chrome: the game owns the whole screen). */
174
236
  function layoutless(title, body, css, ancestors = "'self'") {
175
237
  return new Response(`<!doctype html>
@@ -201,7 +263,7 @@ const SHELL_JS = String.raw`(function () {
201
263
  var statusEl = document.querySelector('[data-status]');
202
264
  var results = document.querySelector('[data-results]');
203
265
  var screenCard = document.querySelector('[data-screen]');
204
- var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null };
266
+ var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null, server: boot.server || null, vote: null, myVote: {}, quietAi: false, full: false };
205
267
  window.__shell = state;
206
268
  // This browser's room key: random, kept in this site's own storage, and sent only to this site's rooms. It is what
207
269
  // an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
@@ -248,7 +310,18 @@ const SHELL_JS = String.raw`(function () {
248
310
  addEventListener('resize', place);
249
311
  function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
250
312
  function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
251
- function wake() { if (!toggle) return; toggle.classList.remove('dim'); clearTimeout(dimTimer); dimTimer = setTimeout(function () { if (sheet.hidden) toggle.classList.add('dim'); }, 6000); }
313
+ // The room pill and the server pill fade to dots after 6 s, so neither sits on the game's own HUD.
314
+ function wake() {
315
+ if (!toggle) return;
316
+ var sp = document.querySelector('[data-server-toggle]');
317
+ toggle.classList.remove('dim'); if (sp) sp.classList.remove('dim');
318
+ clearTimeout(dimTimer);
319
+ dimTimer = setTimeout(function () {
320
+ if (sheet.hidden) toggle.classList.add('dim');
321
+ var ss = document.querySelector('[data-server-sheet]');
322
+ if (sp && (!ss || ss.hidden)) sp.classList.add('dim');
323
+ }, 6000);
324
+ }
252
325
  function open(on) { if (!sheet) return; sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on) wake(); else { wake(); try { frame.focus(); } catch (e) {} } }
253
326
  function copy(text, done) {
254
327
  var ok = function () { flash(done || 'Link copied'); };
@@ -298,6 +371,8 @@ const SHELL_JS = String.raw`(function () {
298
371
  try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
299
372
  }
300
373
  if (params.get('debug') === '1') q.set('debug', '1');
374
+ // Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
375
+ if (state.quietAi) q.set('hush', '1');
301
376
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
302
377
  ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
303
378
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
@@ -312,7 +387,7 @@ const SHELL_JS = String.raw`(function () {
312
387
  if (m.what === 'stats') state.stats = m.stats;
313
388
  if (m.what === 'round') onRound(m.round);
314
389
  if (m.what === 'roster') state.roster = m.slots;
315
- if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
390
+ if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
316
391
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
317
392
  if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
318
393
  paint();
@@ -338,7 +413,9 @@ const SHELL_JS = String.raw`(function () {
338
413
  var ol = document.createElement('ol');
339
414
  r.results.slice(0, 6).forEach(function (row) {
340
415
  var li = document.createElement('li');
341
- li.textContent = row.name + (row.bot ? ' (bot)' : '') + ' — ' + row.score;
416
+ li.textContent = String(row.name) + ' — ' + row.score;
417
+ // Every AI row (an agent, or a bot) carries the AI pill.
418
+ if (row.agent || row.bot) { var pill = document.createElement('span'); pill.className = 'aipill'; pill.textContent = 'AI'; li.append(pill); }
342
419
  ol.append(li);
343
420
  });
344
421
  results.append(h, ol); results.hidden = params.get('debug') !== '1'; // the game draws its own results; the card is for ?debug=1
@@ -359,10 +436,13 @@ const SHELL_JS = String.raw`(function () {
359
436
  if (m.t === 'closed') { notice('closed', m); return; }
360
437
  if (m.t === 'announce') { banner(m); return; }
361
438
  if (m.t === 'muted') { mutedNote(m); return; }
439
+ if (m.t === 'vote') { onVote(m); return; }
362
440
  if (m.t !== 'net') return;
363
441
  state.facts = m;
442
+ if (typeof m.st === 'number') state.offset = m.st - Date.now();
364
443
  if (m.announce && m.announce.text) banner(m.announce, true);
365
444
  if (state.facts && state.facts.round) onRound(state.facts.round);
445
+ if (m.vote) onVote(m.vote, true); else if (state.vote && state.vote.open) onVote({ open: false, id: state.vote.id }, true);
366
446
  paint();
367
447
  };
368
448
  ws.onclose = function () { if (!state.notice) setTimeout(function () { watch(room); }, 1500); };
@@ -427,23 +507,141 @@ const SHELL_JS = String.raw`(function () {
427
507
  var f = state.facts;
428
508
  var n = f && f.counts ? f.counts.players : null;
429
509
  var bits = [];
430
- if (n !== null) bits.push(n + (n === 1 ? ' player' : ' players') + ' here');
431
- if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
510
+ if (state.server) bits.push(state.server.name);
511
+ if (n !== null) bits.push(n + ' playing');
512
+ // Every AI is counted as AI (agents, the seats kept for them, and the game's bots), never as a player.
513
+ var aiN = f && f.counts ? (Number(f.counts.ai) || 0) + (Number(f.counts.bots) || 0) : 0;
514
+ if (aiN) bits.push(aiN + ' AI');
515
+ if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
516
+ if (state.full) bits.push('waiting for a seat');
432
517
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
433
518
  say(bits.join(' · ') || 'joining…');
434
519
  var count = ui && ui.querySelector('[data-room-count]');
435
- if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here');
520
+ if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here') + (aiN ? ' · ' + aiN + ' AI' : '');
521
+ serverUi();
522
+ }
523
+
524
+ /* ---------------------------------------------------------- the server pill, Quiet AI and the vote (section 17) */
525
+ var SKILL = boot.skills || [];
526
+ var sToggle = document.querySelector('[data-server-toggle]');
527
+ var sSheet = document.querySelector('[data-server-sheet]');
528
+ var voteCard = document.querySelector('[data-vote]');
529
+ try { state.quietAi = localStorage.getItem('homie-quiet-ai') === '1'; } catch (e) { state.quietAi = false; }
530
+ function skillName(n) { var s = SKILL[n - 1]; return s ? s.name : 'level ' + n; }
531
+ function hasAi() { var f = state.facts; return Boolean(f && f.counts && (f.counts.ai || f.counts.bots || f.counts.agents)); }
532
+ function readsDial() { var f = state.facts; return Boolean(f && Array.isArray(f.caps) && f.caps.indexOf('skill') >= 0); }
533
+ // The room's live policy says the badge and line (the owner's change reaches an open page at once); the
534
+ // server's words from the page load are the fallback.
535
+ function policyText(pol) {
536
+ var w = pol && boot.words ? boot.words[pol.kind] : null;
537
+ if (!w) return state.server ? { badge: state.server.badge, line: state.server.line } : { badge: 'Open', line: 'Open: anyone can play. AI players are always marked AI.' };
538
+ var n = pol.kind === 'hybrid' ? (Number(pol.aiSeats) || 0) : 0;
539
+ return { badge: pol.kind === 'hybrid' ? w.badge + ' · ' + n : w.badge, line: w.line.replace('{n}', String(n)) + (pol.kids && boot.kidsLine ? ' ' + boot.kidsLine : '') };
540
+ }
541
+ function serverUi() {
542
+ if (!sToggle || !sSheet) return;
543
+ var f = state.facts; var pol = f && f.policy ? f.policy : null;
544
+ var show = Boolean(state.server) || (hasAi() && readsDial());
545
+ sToggle.hidden = !show;
546
+ if (!show) return;
547
+ var name = state.server ? state.server.name : 'Quick play';
548
+ var words = policyText(pol);
549
+ var label = name + ' · ' + words.badge + (state.server && state.server.mentor ? ' · Mentor' : '');
550
+ var labelEl = sToggle.querySelector('[data-server-label]');
551
+ // A new policy (the owner's change) shows in full for a moment, then fades with the room pill.
552
+ if (labelEl.textContent !== label) { labelEl.textContent = label; wake(); }
553
+ sSheet.querySelector('[data-server-name]').textContent = name;
554
+ sSheet.querySelector('[data-server-badge]').textContent = words.badge;
555
+ sSheet.querySelector('[data-server-line]').textContent = words.line;
556
+ var row = sSheet.querySelector('[data-level-row]');
557
+ row.hidden = !(readsDial() && hasAi() && boot.vote !== false);
558
+ if (pol && pol.skill) sSheet.querySelector('[data-level-name]').textContent = pol.skill.name;
559
+ sSheet.querySelector('[data-quiet-ai]').checked = state.quietAi;
560
+ }
561
+ if (sToggle) {
562
+ sToggle.addEventListener('click', function (e) { e.stopPropagation(); var on = sSheet.hidden; sSheet.hidden = !on; sToggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on && sheet) open(false); wake(); });
563
+ document.addEventListener('pointerdown', function (e) { if (!sSheet.hidden && !sSheet.contains(e.target) && e.target !== sToggle && !sToggle.contains(e.target)) { sSheet.hidden = true; sToggle.setAttribute('aria-expanded', 'false'); } }, true);
564
+ sSheet.querySelector('[data-quiet-ai]').addEventListener('change', function (e) {
565
+ state.quietAi = e.target.checked;
566
+ try { localStorage.setItem('homie-quiet-ai', state.quietAi ? '1' : '0'); } catch (e2) {}
567
+ try { frame.contentWindow.postMessage({ t: 'homie-hush', on: state.quietAi }, '*'); } catch (e3) {}
568
+ flash(state.quietAi ? 'AI chat is hidden on this screen.' : 'AI chat shows again.');
569
+ });
570
+ sSheet.querySelector('[data-level-vote]').addEventListener('click', function () { sendVote({ open: true }); sSheet.hidden = true; sToggle.setAttribute('aria-expanded', 'false'); });
436
571
  }
572
+ function sendVote(m) {
573
+ var ws = state.watchSocket;
574
+ if (!ws || ws.readyState !== 1) { flash('Not connected yet; try again in a moment.'); return; }
575
+ try { ws.send(JSON.stringify(Object.assign({ t: 'vote', of: 'skill' }, m))); } catch (e) {}
576
+ }
577
+ function cast(n) { if (!state.vote || !state.vote.open) return; state.myVote[state.vote.id] = n; sendVote({ n: n }); renderVote(); }
578
+ var shownResults = {};
579
+ function onVote(v, quiet) {
580
+ if (!v || typeof v !== 'object') return;
581
+ if (v.t === 'error' || v.code) return;
582
+ var prev = state.vote;
583
+ state.vote = v.options ? v : (prev && prev.id === v.id ? Object.assign({}, prev, { open: false }) : prev);
584
+ if (v.result && !v.open && !shownResults[v.id] && !quiet) {
585
+ shownResults[v.id] = true;
586
+ flash('The party set the AI to ' + v.result.name + ' (' + v.result.votes + (v.result.votes === 1 ? ' vote' : ' votes') + ').');
587
+ } else if (v.result && !v.open) shownResults[v.id] = true;
588
+ renderVote();
589
+ }
590
+ var voteTimer = null;
591
+ function renderVote() {
592
+ var v = state.vote;
593
+ if (!voteCard) return;
594
+ // A game that draws its own card (game.json "agents": { "vote": "game" }), or no vote: the shell shows none.
595
+ if (!v || !v.open || boot.vote !== 'shell' || boot.screen || state.seat === null || state.seat === undefined) { voteCard.hidden = true; clearInterval(voteTimer); voteTimer = null; return; }
596
+ var left = Math.max(0, Math.ceil((v.until - (Date.now() + (state.offset || 0))) / 1000));
597
+ var mine = state.myVote[v.id];
598
+ voteCard.textContent = '';
599
+ var head = document.createElement('div'); head.className = 'vh';
600
+ var t = document.createElement('b'); t.textContent = 'How strong should the AI be?';
601
+ var sub = document.createElement('span'); sub.textContent = 'for this party · ' + left + ' s';
602
+ head.append(t, sub);
603
+ var opts = document.createElement('div'); opts.className = 'opts'; opts.style.setProperty('--n', String(v.options.length));
604
+ v.options.forEach(function (n) {
605
+ var b = document.createElement('button'); b.type = 'button'; b.setAttribute('data-vote-n', String(n));
606
+ b.setAttribute('aria-pressed', mine === n ? 'true' : 'false');
607
+ var big = document.createElement('b'); big.textContent = n + ' ' + skillName(n);
608
+ var small = document.createElement('small'); small.textContent = (v.counts && v.counts[n] ? v.counts[n] + (v.counts[n] === 1 ? ' vote' : ' votes') : '\u00a0');
609
+ b.append(big, small);
610
+ b.addEventListener('click', function () { cast(n); });
611
+ opts.appendChild(b);
612
+ });
613
+ var legend = document.createElement('div'); legend.className = 'legend';
614
+ var l1 = document.createElement('span'); l1.textContent = SKILL[0] ? SKILL[0].card.split(',')[0] : 'stays at the back';
615
+ var l2 = document.createElement('span'); var top = SKILL[v.options.length - 1]; l2.textContent = top ? top.card.split(',')[0] : '';
616
+ legend.append(l1, document.createElement('i'), l2);
617
+ var foot = document.createElement('div'); foot.className = 'foot';
618
+ foot.textContent = (v.voters || 0) + ' of ' + (v.of_total || 1) + ' voted · the middle vote wins';
619
+ voteCard.append(head, opts, legend, foot);
620
+ voteCard.hidden = false;
621
+ if (!voteTimer) voteTimer = setInterval(renderVote, 500);
622
+ }
623
+ document.addEventListener('keydown', function (e) {
624
+ if (!state.vote || !state.vote.open || voteCard.hidden || e.metaKey || e.ctrlKey || e.altKey) return;
625
+ var n = Number(e.key);
626
+ if (state.vote.options.indexOf(n) >= 0) { e.preventDefault(); cast(n); }
627
+ });
437
628
 
438
629
  // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
439
630
  // The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
440
631
  var askedRoom = params.get('room');
632
+ var lobbyQ = [];
633
+ if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
634
+ if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
441
635
  if (boot.room) start(boot.room);
442
636
  else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
443
637
  else if (askedRoom !== null) { say('that room link does not work'); }
444
- else fetch('/' + boot.game + '/api/lobby' + (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '') ? '?not=' + encodeURIComponent(params.get('not')) : ''), { method: 'POST' })
638
+ else fetch('/' + boot.game + '/api/lobby' + (lobbyQ.length ? '?' + lobbyQ.join('&') : ''), { method: 'POST' })
445
639
  .then(function (r) { return r.json(); })
446
- .then(function (j) { start(j.room || 'main'); })
640
+ .then(function (j) {
641
+ // Every room this server may have is full: the fullest one, watched until a seat frees (the relay seats it).
642
+ if (j.full) { state.full = true; flash((state.server ? state.server.name : 'This server') + ' is full. You\u2019re next for a seat.'); }
643
+ start(j.room || 'main');
644
+ })
447
645
  .catch(function () { start('main'); });
448
646
  }());`;
449
647
 
@@ -469,6 +667,7 @@ const OWNER_CSS = `.owner { position: fixed; z-index: 10; top: max(8px, env(safe
469
667
  .osheet .tag { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid rgba(255,255,255,.2); color: #aab3c7; margin-left: 6px; }
470
668
  .osheet .tag.you { color: #c9b8ff; border-color: #c9b8ff; }
471
669
  .osheet .tag.muted { color: #ff8a9a; border-color: rgba(255,107,125,.6); }
670
+ .osheet .tag.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
472
671
  .osheet .bot { color: #6c7489; font-weight: 500; padding: 2px 10px 8px; }
473
672
  .osheet .det { padding: 4px 2px; }
474
673
  .osheet .det b { font-size: 18px; display: block; margin-bottom: 4px; overflow-wrap: anywhere; }
@@ -541,7 +740,7 @@ const OWNER_JS = String.raw`(function () {
541
740
  var back = el('button', 'back', '← Everyone here'); back.type = 'button'; back.onclick = function () { view.pick = null; view.msg = ''; render(); };
542
741
  var det = el('div', 'det');
543
742
  det.appendChild(el('b', '', picked.name || 'Someone'));
544
- det.appendChild(el('p', '', (picked.seat !== null ? 'Seat ' + (picked.seat + 1) : 'Watching') + ' · ' + (picked.device === 'phone' ? 'on a phone' : picked.device === 'tv' ? 'a big screen' : 'on a computer') + (picked.role === 'host' ? ' · hosting the room' : '')));
743
+ det.appendChild(el('p', '', (picked.seat !== null ? 'Seat ' + (picked.seat + 1) : 'Watching') + ' · ' + (picked.agent ? 'an AI (' + (picked.agent.role === 'guide' ? 'a guide' : 'a companion') + '), always marked AI' : picked.device === 'phone' ? 'on a phone' : picked.device === 'tv' ? 'a big screen' : 'on a computer') + (picked.role === 'host' ? ' · hosting the room' : '')));
545
744
  if (me) det.appendChild(el('p', '', 'This is you.'));
546
745
  if (picked.muted) det.appendChild(el('p', '', 'Muted: their chat and emotes reach nobody.'));
547
746
  sheet.append(back, det);
@@ -567,12 +766,13 @@ const OWNER_JS = String.raw`(function () {
567
766
  var left = el('span'); left.appendChild(el('span', '', c.name || 'Someone'));
568
767
  if (c.seat !== null && c.seat === state.seat) left.appendChild(el('span', 'tag you', 'you'));
569
768
  if (c.muted) left.appendChild(el('span', 'tag muted', 'muted'));
769
+ if (c.agent) left.appendChild(el('span', 'tag ai', 'AI' + (c.agent.role === 'guide' ? ' guide' : c.agent.role === 'party' ? ' companion' : '')));
570
770
  if (c.role === 'host') left.appendChild(el('span', 'tag', 'host'));
571
771
  row.append(left, el('small', '', c.seat !== null ? 'seat ' + (c.seat + 1) + ' · ' + (c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'screen' : 'computer') : 'waiting'));
572
772
  row.onclick = function () { view.pick = c.id; view.msg = ''; render(); };
573
773
  sheet.appendChild(row);
574
774
  });
575
- var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'Bots: ' + b.map(function (s) { return s.name; }).join(', ')));
775
+ var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'AI bots: ' + b.map(function (s) { return s.name; }).join(', ')));
576
776
  }
577
777
  var form = el('form'); form.setAttribute('data-announce', '');
578
778
  var ta = el('textarea'); ta.maxLength = 280; ta.placeholder = 'Announce something to the players'; ta.setAttribute('aria-label', 'Announcement');
@@ -598,3 +798,407 @@ const OWNER_JS = String.raw`(function () {
598
798
  setInterval(render, 1500);
599
799
  window.__owner = { open: open, render: render };
600
800
  }());`;
801
+
802
+ /*
803
+ * WATCH ANY PLAYER (0.15.0, NETPLAY.md section 16). /<game>/watch?room=<room>[&follow=<seat>|auto|overview] is the
804
+ * game itself, rendered by this browser as a watcher: a screen that never takes a seat. A strip of the room's players
805
+ * switches whose view it renders (a tap, keys 1-9, A for Auto, O for the whole room); Auto follows the action. The
806
+ * game draws the followed player's camera and HUD (its netplay helper's `viewSeat`); a game that does not shows its
807
+ * overview, and the strip says so. A game with hidden hands or roles says `"watch": "overview"` in game.json: its
808
+ * watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
809
+ * is the play door's). Every name a player typed is set with textContent.
810
+ */
811
+ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
812
+ const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
813
+ const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
814
+ const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
815
+ const css = `:root{--hot:${hot}}${WATCH_CSS}`;
816
+ return layoutless(`Watch ${g.name}`, `
817
+ <header class="wtop" data-top>
818
+ <span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
819
+ <span class="what"><b>${esc(g.name)}</b><span data-room-label>finding a room…</span></span>
820
+ <span class="clock" data-clock hidden></span>
821
+ <span class="grow"></span>
822
+ <span class="eyes" data-eyes hidden title="Watching this room"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span data-eyes-n></span><span class="eyes-w"> watching</span></span>
823
+ <a class="playb" data-playb href="/${esc(g.id)}/play"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg><span>Play</span></a>
824
+ </header>
825
+ <main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe></main>
826
+ <footer class="dock">
827
+ <div class="caption" data-caption role="status" aria-live="polite"><b data-cap-head>Joining the room…</b><span data-cap-sub></span></div>
828
+ <nav class="strip" data-strip role="toolbar" aria-label="Whose view to watch"></nav>
829
+ </footer>
830
+ <div class="wnote" data-note hidden></div>
831
+ <script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
832
+ <script>${WATCH_JS}</script>`, css, frameAncestors(cat));
833
+ }
834
+
835
+ /*
836
+ * The watch page is a broadcast: a band above the game (live, the game and room, the clock, who is watching, Play),
837
+ * the game in between, and a dock below (whose view this is, and the players to switch between). Nothing covers the
838
+ * game's own picture or HUD: every game draws its clock and board where it likes.
839
+ */
840
+ const WATCH_CSS = `
841
+ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #05070d; color: #eef1f8; -webkit-tap-highlight-color: transparent; }
842
+ body { --u: clamp(12px, calc(0.8vmin + 7px), 22px); --band: #080b14; --line: rgba(255,255,255,.08); display: flex; flex-direction: column; font: 600 var(--u)/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
843
+ body.big { --u: clamp(16px, calc(1vmin + 6px), 32px); }
844
+ .stage { position: relative; flex: 1; min-height: 0; background: #04060c; }
845
+ iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; }
846
+ .wtop { flex: none; display: flex; align-items: center; gap: calc(var(--u) * .7); padding: max(calc(var(--u) * .5), env(safe-area-inset-top)) max(calc(var(--u) * .9), env(safe-area-inset-right)) calc(var(--u) * .5) max(calc(var(--u) * .9), env(safe-area-inset-left)); background: linear-gradient(#0b0f1a, var(--band)); border-bottom: 1px solid var(--line); min-width: 0; }
847
+ .grow { flex: 1; }
848
+ .live { flex: none; display: inline-flex; align-items: center; gap: .45em; padding: .3em .7em .3em .6em; border-radius: 999px; background: rgba(255,59,92,.16); border: 1px solid rgba(255,90,120,.5); font-weight: 800; font-size: .74em; letter-spacing: .14em; text-transform: uppercase; color: #ffd9e0; }
849
+ .live i { width: .62em; height: .62em; border-radius: 50%; background: #ff3b5c; box-shadow: 0 0 10px #ff3b5c; animation: pulse 1.6s ease-in-out infinite; }
850
+ .live.off { background: rgba(255,255,255,.06); border-color: rgba(255,255,255,.18); color: #aab3c7; }
851
+ .live.off i { background: #6c7489; box-shadow: none; animation: none; }
852
+ @keyframes pulse { 50% { opacity: .35; } }
853
+ @media (prefers-reduced-motion: reduce) { .live i { animation: none; } }
854
+ .what { display: flex; align-items: baseline; gap: .55em; min-width: 0; }
855
+ .what b { font-weight: 800; font-size: 1.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
856
+ .what span { color: #9aa3b7; font-size: .86em; font-weight: 600; white-space: nowrap; }
857
+ .clock { flex: none; padding: .26em .7em; border-radius: 9px; background: rgba(255,255,255,.05); border: 1px solid var(--line); font-variant-numeric: tabular-nums; font-weight: 800; white-space: nowrap; }
858
+ .eyes { flex: none; display: inline-flex; align-items: center; gap: .35em; color: #b9c1d3; font-variant-numeric: tabular-nums; font-size: .9em; }
859
+ .eyes svg { width: 1.2em; height: 1.2em; }
860
+ .playb { flex: none; display: inline-flex; align-items: center; gap: .45em; min-height: 2.4em; padding: 0 1em 0 .8em; border-radius: 11px; background: var(--hot); color: #0b0b10; font-weight: 800; text-decoration: none; }
861
+ .playb:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
862
+ .playb svg { width: 1em; height: 1em; }
863
+ .dock { flex: none; display: flex; align-items: center; gap: calc(var(--u) * .9); padding: calc(var(--u) * .55) max(calc(var(--u) * .9), env(safe-area-inset-right)) max(calc(var(--u) * .55), env(safe-area-inset-bottom)) max(calc(var(--u) * .9), env(safe-area-inset-left)); background: linear-gradient(var(--band), #0b0f1a); border-top: 1px solid var(--line); min-width: 0; }
864
+ .caption { flex: 0 1 auto; min-width: 0; max-width: 34%; display: flex; flex-direction: column; gap: .1em; }
865
+ .caption b { font-weight: 800; font-size: 1.08em; display: flex; align-items: center; gap: .5em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
866
+ .caption b .dot { width: .72em; height: .72em; border-radius: 50%; flex: none; }
867
+ .caption span { color: #9aa3b7; font-size: .82em; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
868
+ .strip { flex: 1; min-width: 0; display: flex; gap: calc(var(--u) * .45); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; padding: 3px 2px; }
869
+ .strip::-webkit-scrollbar { display: none; }
870
+ .strip > :first-child { margin-left: auto; }
871
+ .strip > :last-child { margin-right: auto; }
872
+ .chip { flex: none; display: inline-flex; align-items: center; gap: .5em; min-height: 2.6em; max-width: 15em; padding: 0 .9em 0 .5em; border-radius: 999px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.035); color: #e8ecf5; font: inherit; font-weight: 700; cursor: pointer; touch-action: manipulation; transition: border-color .2s, background .2s; }
873
+ .chip:hover { border-color: rgba(255,255,255,.4); }
874
+ .chip:focus-visible { outline: 2px solid var(--hot); outline-offset: 2px; }
875
+ .chip[aria-pressed="true"] { border-color: var(--hot); background: color-mix(in srgb, var(--hot) 16%, transparent); box-shadow: inset 0 0 0 1px var(--hot); }
876
+ .chip.auto-on { border-color: color-mix(in srgb, var(--hot) 55%, rgba(255,255,255,.2)); }
877
+ .chip[disabled] { cursor: default; padding-left: .8em; }
878
+ .chip[disabled]:hover { border-color: rgba(255,255,255,.14); }
879
+ .chip .k { display: inline-grid; place-items: center; min-width: 1.45em; height: 1.45em; padding: 0 .2em; border-radius: 6px; background: rgba(255,255,255,.08); color: #9aa3b7; font-size: .76em; font-weight: 800; box-sizing: border-box; }
880
+ .chip .dot { width: .8em; height: .8em; border-radius: 50%; flex: none; }
881
+ .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
882
+ .chip .sc { color: #9aa3b7; font-variant-numeric: tabular-nums; font-weight: 800; }
883
+ .chip .sc.lead { color: var(--hot); }
884
+ .chip svg { width: 1.1em; height: 1.1em; flex: none; }
885
+ .chip .tag { font-size: .68em; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--hot); }
886
+ .chip .tag.ai { padding: .1em .45em; border-radius: 999px; background: color-mix(in srgb, var(--hot) 20%, transparent); color: #fff; letter-spacing: .06em; }
887
+ .hint { flex: none; align-self: center; color: #7d8699; font-size: .76em; font-weight: 600; padding: 0 .2em; white-space: nowrap; }
888
+ @media (hover: none) { .chip .k, .hint { display: none; } }
889
+ /* A phone held upright: the caption over the strip, the strip scrolling sideways. */
890
+ @media (max-width: 640px) {
891
+ .dock { flex-direction: column; align-items: stretch; gap: calc(var(--u) * .45); }
892
+ .caption { max-width: none; align-items: center; text-align: center; }
893
+ .chip { max-width: 10.5em; min-height: 2.75em; }
894
+ .what span, .eyes-w { display: none; }
895
+ }
896
+ /* Not much height (a phone on its side): thinner bands. */
897
+ @media (max-height: 460px) {
898
+ .wtop, .dock { padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom)); }
899
+ .caption span { display: none; }
900
+ .chip { min-height: 2.3em; }
901
+ }
902
+ body.idle { cursor: none; }
903
+ .wnote { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgba(4,6,12,.8); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
904
+ .wnote .box { box-sizing: border-box; width: min(440px, 100%); padding: calc(var(--u) * 1.5); border-radius: 20px; background: #0d111c; border: 1px solid rgba(255,255,255,.14); box-shadow: 0 24px 70px rgba(0,0,0,.55); }
905
+ .wnote h1 { margin: 0 0 .4em; font-size: 1.5em; letter-spacing: -.01em; }
906
+ .wnote p { margin: 0 0 .7em; color: #c3cad9; font-weight: 500; line-height: 1.45; }
907
+ .wnote .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 1em; }
908
+ .wnote .acts a { display: inline-flex; align-items: center; min-height: 2.9em; padding: 0 1.1em; border-radius: 12px; text-decoration: none; font-weight: 800; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
909
+ .wnote .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
910
+ [hidden] { display: none !important; }`;
911
+
912
+ /* The watch page's script (the site's own origin; the game runs in the sandboxed frame as a watcher). */
913
+ const WATCH_JS = String.raw`(function () {
914
+ 'use strict';
915
+ var boot = window.__HOMIE_WATCH;
916
+ var params = new URLSearchParams(location.search);
917
+ var hand = params.get('hand');
918
+ var device = hand === 'phone' || hand === 'desk' || hand === 'tv' ? hand : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
919
+ var frame = document.querySelector('iframe.game');
920
+ var q1 = function (s) { return document.querySelector(s); };
921
+ var strip = q1('[data-strip]'), note = q1('[data-note]');
922
+ var capHead = q1('[data-cap-head]'), capSub = q1('[data-cap-sub]'), clockEl = q1('[data-clock]'), liveEl = q1('[data-live]');
923
+ var palette = Array.isArray(boot.palette) ? boot.palette : [];
924
+ var asked = params.get('follow');
925
+ var first = /^\d{1,2}$/.test(asked || '') ? Number(asked) : asked === 'overview' ? null : 'auto';
926
+ // What the page knows: the room's facts (its watch socket), and what the game in the frame says it renders.
927
+ var state = { game: boot.game, room: null, facts: null, offset: 0, round: null, view: null, follows: null, canFollow: boot.policy !== 'overview', why: boot.policy === 'overview' ? 'overview' : null, scores: null, asked: first, attached: false, notice: null, emptySince: 0 };
928
+ window.__watch = state;
929
+ function rnd() {
930
+ try { var a = new Uint8Array(16); crypto.getRandomValues(a); var s = ''; for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
931
+ catch (e) { var r = ''; while (r.length < 22) r += Math.random().toString(36).slice(2); return r.slice(0, 22); }
932
+ }
933
+ // The browser's room key, as the play page keeps it: a kick holds a browser out of watching too, and a browser
934
+ // that holds a seat in this room watches it in the overview only (section 16).
935
+ var roomKey = null;
936
+ try { roomKey = localStorage.getItem('homie-b'); if (!/^[A-Za-z0-9_-]{16,43}$/.test(roomKey || '')) { roomKey = rnd(); localStorage.setItem('homie-b', roomKey); } } catch (e) { roomKey = rnd(); }
937
+
938
+ function sizeUp() {
939
+ var big = hand === 'tv' || (Math.min(innerWidth, innerHeight) >= 900 && !(window.matchMedia && matchMedia('(pointer: coarse)').matches));
940
+ document.body.classList.toggle('big', big);
941
+ }
942
+ sizeUp();
943
+ addEventListener('resize', sizeUp);
944
+ function labelOf(room) { var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
945
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
946
+ function svg(path) { var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.innerHTML = path; return s; }
947
+ var AUTO_ICON = '<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"/>';
948
+ var ROOM_ICON = '<rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/>';
949
+
950
+ // The players, in seat order: the strip's order, and what keys 1-9 pick (the n-th player).
951
+ function players() {
952
+ var f = state.facts;
953
+ var list = f && Array.isArray(f.clients) ? f.clients.filter(function (c) { return c && typeof c.seat === 'number' && !c.watch; }) : [];
954
+ return list.sort(function (a, b) { return a.seat - b.seat; });
955
+ }
956
+ function nameOf(seat) { var p = players().filter(function (c) { return c.seat === seat; })[0]; return p ? String(p.name || 'A player') : null; }
957
+ function colourOf(p) { return palette[(Number(p.colour) || 0) % (palette.length || 1)] || '#e8ecf5'; }
958
+ // Choosing whose view: the game decides it can (its helper said so), and the room lets this browser.
959
+ function canChoose() { return state.follows === true && state.canFollow; }
960
+
961
+ function follow(target) {
962
+ if (!canChoose() || !frame.contentWindow) return;
963
+ state.asked = target;
964
+ try { frame.contentWindow.postMessage({ t: 'homie-watch', follow: target }, '*'); } catch (e) {}
965
+ paint();
966
+ }
967
+
968
+ // The address follows the view, so a copied link watches the same player.
969
+ function syncUrl() {
970
+ try {
971
+ var v = state.view;
972
+ var f = !v ? null : v.following === 'auto' ? 'auto' : v.following === null ? 'overview' : String(v.following);
973
+ if (params.get('room') !== state.room || (f && params.get('follow') !== f)) {
974
+ params.set('room', state.room);
975
+ if (f) params.set('follow', f); else params.delete('follow');
976
+ params.delete('not');
977
+ history.replaceState(history.state, '', location.pathname + '?' + params.toString() + location.hash);
978
+ }
979
+ } catch (e) {}
980
+ }
981
+
982
+ function start(room) {
983
+ state.room = room;
984
+ syncUrl();
985
+ q1('[data-room-label]').textContent = labelOf(room);
986
+ q1('[data-playb]').href = '/' + boot.game + '/play?room=' + encodeURIComponent(room);
987
+ document.title = 'Watch ' + boot.name + ' · ' + labelOf(room);
988
+ var q = new URLSearchParams({ room: room, watch: '1', device: device, b: roomKey });
989
+ q.set('follow', first === 'auto' ? 'auto' : first === null ? 'overview' : String(first));
990
+ if (boot.t) q.set('t', boot.t);
991
+ if (params.get('debug') === '1') q.set('debug', '1');
992
+ ['cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
993
+ frame.src = '/' + boot.game + '/__game/?' + q.toString();
994
+ addEventListener('message', function (ev) {
995
+ if (ev.source !== frame.contentWindow) return;
996
+ var m = ev.data;
997
+ if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
998
+ if (m.what === 'attached') {
999
+ state.attached = true;
1000
+ // A helper from before revision 5 never says what it renders: after a moment, the room's overview it is.
1001
+ setTimeout(function () { if (state.follows === null) { state.follows = false; paint(); } }, 4000);
1002
+ }
1003
+ if (m.what === 'view') {
1004
+ state.view = { seat: typeof m.seat === 'number' ? m.seat : null, following: m.following === 'auto' || typeof m.following === 'number' ? m.following : null, why: String(m.why || '') };
1005
+ state.follows = m.follows === true;
1006
+ state.canFollow = m.canFollow !== false;
1007
+ state.why = typeof m.whyNot === 'string' ? m.whyNot : null;
1008
+ syncUrl();
1009
+ }
1010
+ if (m.what === 'scores' && Array.isArray(m.scores)) {
1011
+ var sc = {}; m.scores.forEach(function (r) { if (r && typeof r.seat === 'number' && isFinite(r.score)) sc[r.seat] = Number(r.score); });
1012
+ state.scores = sc;
1013
+ }
1014
+ if (m.what === 'round' && m.round) state.round = m.round;
1015
+ if (m.what === 'closed') {
1016
+ if (m.why === 'kicked' || m.why === 'room-closed') shut(m.why === 'kicked' ? 'kicked' : 'closed', m);
1017
+ else if (m.why === 'watch-off') shut('off', m);
1018
+ else if (m.why === 'room-full') shut('full', m);
1019
+ }
1020
+ paint();
1021
+ });
1022
+ watch(room);
1023
+ }
1024
+
1025
+ function watch(room) {
1026
+ if (state.notice && state.notice.final) return;
1027
+ var ws;
1028
+ var extra = '&b=' + encodeURIComponent(roomKey) + (boot.t ? '&t=' + encodeURIComponent(boot.t) : '');
1029
+ try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
1030
+ catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
1031
+ state.socket = ws;
1032
+ ws.onmessage = function (ev) {
1033
+ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
1034
+ if (!m || typeof m !== 'object') return;
1035
+ if (m.t === 'kicked') { shut('kicked', m); return; }
1036
+ if (m.t === 'closed') { shut('closed', m); return; }
1037
+ if (m.t !== 'net') return;
1038
+ state.facts = m;
1039
+ if (typeof m.st === 'number') state.offset = m.st - Date.now();
1040
+ if (m.round) state.round = m.round;
1041
+ paint();
1042
+ };
1043
+ ws.onclose = function () { if (!(state.notice && state.notice.final)) setTimeout(function () { watch(room); }, 1500); };
1044
+ }
1045
+
1046
+ // A room that ended, a game that is not watched, a kick: the page says so plainly and offers the way on.
1047
+ function shut(kind, m) {
1048
+ if (state.notice && state.notice.kind === kind) return;
1049
+ var final = kind !== 'empty';
1050
+ state.notice = { kind: kind, final: final };
1051
+ if (final) { try { frame.src = 'about:blank'; } catch (e) {} if (state.socket) { try { state.socket.close(); } catch (e) {} } }
1052
+ note.textContent = '';
1053
+ var box = el('div', 'box');
1054
+ var label = labelOf(state.room);
1055
+ var heads = { empty: 'Everyone has left ' + label, kicked: 'You were removed from this room', closed: 'This room is closed', off: boot.name + ' is played, not watched', full: label + ' is full of watchers', none: 'Nobody is playing ' + boot.name + ' right now' };
1056
+ var lines = {
1057
+ empty: 'The players went on their way. Watch another room, or press Play: you start at once, with bots in the empty seats.',
1058
+ kicked: (m && m.message) || 'The studio removed you from this room.',
1059
+ closed: (m && m.message) || 'The studio closed this room.',
1060
+ off: 'Its rooms are not shown to watchers. Press Play and you are in one.',
1061
+ full: 'Every place to watch it from is taken. Try the busiest other room.',
1062
+ none: 'This page finds a room the moment somebody starts one. Or press Play, and people who come to watch will see you.',
1063
+ };
1064
+ box.appendChild(el('h1', '', heads[kind] || heads.closed));
1065
+ box.appendChild(el('p', '', lines[kind] || lines.closed));
1066
+ var acts = el('div', 'acts');
1067
+ var other = el('a', 'primary', kind === 'none' || kind === 'off' ? 'Play ' + boot.name : 'Watch another room');
1068
+ other.href = kind === 'none' || kind === 'off' ? '/' + boot.game + '/play' : '/' + boot.game + '/watch?not=' + encodeURIComponent(state.room || '');
1069
+ acts.appendChild(other);
1070
+ if (kind !== 'none' && kind !== 'off') { var play = el('a', '', 'Play'); play.href = '/' + boot.game + '/play'; acts.appendChild(play); }
1071
+ var back = el('a', '', 'Back to ' + boot.name); back.href = '/' + boot.game + '/'; acts.appendChild(back);
1072
+ box.appendChild(acts);
1073
+ note.appendChild(box);
1074
+ note.hidden = false;
1075
+ liveEl.classList.add('off');
1076
+ }
1077
+ function unshut() { if (state.notice && !state.notice.final) { state.notice = null; note.hidden = true; note.textContent = ''; liveEl.classList.remove('off'); } }
1078
+
1079
+ var drawn = '';
1080
+ function paintStrip() {
1081
+ var list = players();
1082
+ var choose = canChoose();
1083
+ var v = state.view || { seat: null, following: state.asked, why: '' };
1084
+ var scores = state.scores || {};
1085
+ var lead = null; var best = -Infinity;
1086
+ list.forEach(function (p) { var s = scores[p.seat]; if (typeof s === 'number' && s > best) { best = s; lead = p.seat; } });
1087
+ var sig = JSON.stringify([choose, v.seat, v.following, list.map(function (p) { return [p.seat, p.name, p.colour, scores[p.seat], Boolean(p.agent)]; }), lead, state.follows]);
1088
+ if (sig === drawn) return;
1089
+ drawn = sig;
1090
+ strip.textContent = '';
1091
+ function chip(target, key, build, pressed, extraCls) {
1092
+ var b = el('button', 'chip' + (extraCls ? ' ' + extraCls : ''));
1093
+ b.type = 'button';
1094
+ b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
1095
+ if (!choose) { b.disabled = true; b.setAttribute('aria-disabled', 'true'); }
1096
+ if (key) b.appendChild(el('span', 'k', key));
1097
+ build(b);
1098
+ b.addEventListener('click', function () { follow(target); });
1099
+ strip.appendChild(b);
1100
+ return b;
1101
+ }
1102
+ if (choose) chip('auto', 'A', function (b) { b.appendChild(svg(AUTO_ICON)); b.appendChild(el('span', 'nm', 'Auto')); }, v.following === 'auto');
1103
+ list.forEach(function (p, i) {
1104
+ var on = choose && v.following === p.seat;
1105
+ var auto = choose && v.following === 'auto' && v.seat === p.seat;
1106
+ chip(p.seat, i < 9 ? String(i + 1) : '', function (b) {
1107
+ var dot = el('i', 'dot'); dot.style.background = colourOf(p); b.appendChild(dot);
1108
+ b.appendChild(el('span', 'nm', p.name || 'A player'));
1109
+ // An AI in the room is marked AI here too (section 17).
1110
+ if (p.agent) b.appendChild(el('span', 'tag ai', 'AI'));
1111
+ if (auto) b.appendChild(el('span', 'tag', 'auto'));
1112
+ if (typeof scores[p.seat] === 'number') b.appendChild(el('span', 'sc' + (p.seat === lead && list.length > 1 ? ' lead' : ''), scores[p.seat]));
1113
+ b.setAttribute('aria-label', (on || auto ? 'Watching ' : 'Watch ') + (p.name || 'a player'));
1114
+ }, on, auto ? 'auto-on' : '');
1115
+ });
1116
+ if (choose) chip(null, 'O', function (b) { b.appendChild(svg(ROOM_ICON)); b.appendChild(el('span', 'nm', 'Whole room')); }, v.following === null);
1117
+ if (choose && device !== 'phone') strip.appendChild(el('span', 'hint', '1–9 · A · O'));
1118
+ }
1119
+
1120
+ function paint() {
1121
+ var f = state.facts;
1122
+ var list = players();
1123
+ // The caption: whose view this is, and why it is the whole room when it is.
1124
+ var v = state.view;
1125
+ var head = 'Watching the whole room';
1126
+ var sub = '';
1127
+ var dotColour = null;
1128
+ if (!state.attached || state.follows === null) { head = 'Joining ' + labelOf(state.room) + '…'; }
1129
+ else if (!state.follows) sub = boot.name + ' shows watchers the whole room';
1130
+ else if (!state.canFollow && state.why === 'seated-here') sub = 'You are playing in this room in another tab, so this one shows the whole room';
1131
+ else if (!state.canFollow) sub = boot.name + ' keeps each player’s view to themselves';
1132
+ else if (v && v.seat !== null) {
1133
+ var who = list.filter(function (p) { return p.seat === v.seat; })[0];
1134
+ head = 'Watching ' + (who ? who.name || 'a player' : 'a player');
1135
+ dotColour = who ? colourOf(who) : null;
1136
+ sub = v.following === 'auto' ? 'Auto · following the action' : '';
1137
+ } else if (v && v.following === 'auto') sub = list.length ? 'Auto · waiting for the action' : 'Auto · nobody is playing yet';
1138
+ capHead.textContent = '';
1139
+ if (dotColour) { var d = el('span', 'dot'); d.style.background = dotColour; capHead.appendChild(d); }
1140
+ capHead.appendChild(document.createTextNode(head));
1141
+ capSub.textContent = sub;
1142
+ capSub.hidden = !sub;
1143
+ // The round's clock, in the relay's time.
1144
+ var r = state.round;
1145
+ if (r && isFinite(r.endsAt)) {
1146
+ var left = Math.max(0, Math.ceil((r.endsAt - (Date.now() + state.offset)) / 1000));
1147
+ clockEl.textContent = r.phase === 'over' ? 'Next round in ' + left : 'Round ' + r.n + ' · ' + Math.floor(left / 60) + ':' + String(left % 60).padStart(2, '0');
1148
+ clockEl.hidden = false;
1149
+ } else clockEl.hidden = true;
1150
+ var eyes = f && f.counts ? Number(f.counts.watchers) || 0 : 0;
1151
+ q1('[data-eyes]').hidden = eyes < 1;
1152
+ q1('[data-eyes-n]').textContent = String(eyes);
1153
+ paintStrip();
1154
+ // Everyone left: say so after a moment (a reload is not a departure), and take it back if they return.
1155
+ var people = f && f.counts ? Number(f.counts.players) || 0 : null;
1156
+ if (people === 0 && state.attached) {
1157
+ if (!state.emptySince) state.emptySince = Date.now();
1158
+ if (Date.now() - state.emptySince > 6000 && !state.notice) shut('empty');
1159
+ } else { state.emptySince = 0; if (people) unshut(); }
1160
+ }
1161
+ setInterval(paint, 500);
1162
+
1163
+ // Keys: 1-9 the n-th player, A Auto, O (or 0) the whole room, arrows the next or previous player, F full screen.
1164
+ addEventListener('keydown', function (e) {
1165
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1166
+ var k = e.key;
1167
+ var list = players();
1168
+ wake();
1169
+ if (/^[1-9]$/.test(k)) { var p = list[Number(k) - 1]; if (p) follow(p.seat); e.preventDefault(); return; }
1170
+ if (k === 'a' || k === 'A') { follow('auto'); return; }
1171
+ if (k === 'o' || k === 'O' || k === '0') { follow(null); return; }
1172
+ if (k === 'ArrowRight' || k === 'ArrowLeft') {
1173
+ if (!list.length) return;
1174
+ var cur = state.view && typeof state.view.seat === 'number' ? state.view.seat : null;
1175
+ var i = list.map(function (p) { return p.seat; }).indexOf(cur);
1176
+ var n = k === 'ArrowRight' ? (i + 1) % list.length : (i <= 0 ? list.length - 1 : i - 1);
1177
+ follow(list[n].seat); e.preventDefault(); return;
1178
+ }
1179
+ if (k === 'f' || k === 'F') { try { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); } catch (e2) {} }
1180
+ });
1181
+
1182
+ // The bars get out of the way while nobody touches anything; any touch, move or key brings them back.
1183
+ var idleTimer = null;
1184
+ function wake() { document.body.classList.remove('idle'); clearTimeout(idleTimer); idleTimer = setTimeout(function () { document.body.classList.add('idle'); }, 5000); }
1185
+ ['pointermove', 'pointerdown', 'touchstart', 'wheel'].forEach(function (t) { addEventListener(t, wake, { passive: true }); });
1186
+ wake();
1187
+ // The frame never keeps the keys: a click in the game hands them back to the page.
1188
+ addEventListener('blur', function () { setTimeout(function () { if (document.activeElement === frame) { try { frame.blur(); window.focus(); } catch (e) {} } }, 0); });
1189
+
1190
+ // A named room watches that room; no room: the busiest public room now (nothing is reserved), or a wait for one.
1191
+ var askedRoom = params.get('room');
1192
+ if (boot.room) start(boot.room);
1193
+ else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
1194
+ else {
1195
+ var not = /^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '') ? params.get('not') : '';
1196
+ var look = function () {
1197
+ fetch('/' + boot.game + '/api/watch' + (not ? '?not=' + encodeURIComponent(not) : ''), { cache: 'no-store' })
1198
+ .then(function (r) { return r.json(); })
1199
+ .then(function (j) { if (j && j.room) { unshut(); state.notice = null; note.hidden = true; start(j.room); } else { if (!state.notice) shut('none'); state.notice.final = false; setTimeout(look, 10000); } })
1200
+ .catch(function () { setTimeout(look, 10000); });
1201
+ };
1202
+ look();
1203
+ }
1204
+ }());`;