@homie-rocks/studio 0.15.0 → 0.16.1

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
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { esc, layout } from './site.mjs';
6
6
  import { NET_PALETTE } from './room.mjs';
7
+ import { SKILLS } from './agents.mjs';
8
+ import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
7
9
  import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
8
10
 
9
11
  export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
@@ -82,7 +84,7 @@ export function sharePlaces(value) {
82
84
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
83
85
  * covers the middle of the screen or a thumb.
84
86
  */
85
- 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 } = {}) {
86
88
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
87
89
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
88
90
  const places = sharePlaces(g.screen?.share);
@@ -111,6 +113,10 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
111
113
  .room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
112
114
  .room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
113
115
  .chip.chip-right { left: auto; right: max(10px, env(safe-area-inset-right)); }
116
+ /* The room button and the server pill share one band at the room button's place, side by side (the server pill on
117
+ the inner side), so the shell never covers more of the game than the place the game gave the room button. */
118
+ .room .pills { display: flex; align-items: center; gap: 8px; }
119
+ .room.at-top-right .pills, .room.at-bottom-right .pills { flex-direction: row-reverse; }
114
120
  .pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: rgba(6,9,16,.62); color: inherit; font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
115
121
  .pill svg { width: 15px; height: 15px; flex: none; }
116
122
  .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
@@ -148,16 +154,28 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
148
154
  .notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
149
155
  .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); }
150
156
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
151
- [hidden] { display: none !important; }${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
157
+ [hidden] { display: none !important; }
158
+ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
152
159
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
153
160
  // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
154
- const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}) };
161
+ // The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
162
+ 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;
163
+ // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
164
+ const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
165
+ 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 };
155
166
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
156
167
  const joinCorner = corner('join', 'bottom-right');
157
168
  const first = places.desk;
158
169
  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>` : '';
159
170
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
160
- <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>
171
+ <div class="pills"><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>
172
+ <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></div>
173
+ <div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
174
+ <div class="code"><b data-server-name></b><span data-server-badge></span></div>
175
+ <p class="sline" data-server-line></p>
176
+ <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>
177
+ <label class="srow quiet-ai"><span>Quiet AI <small>hides AI chat on this screen</small></span><input type="checkbox" data-quiet-ai></label>
178
+ </div>
161
179
  <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
162
180
  <div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
163
181
  <div class="acts">
@@ -173,6 +191,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
173
191
  return layoutless(`${g.name} · play`, `
174
192
  <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
175
193
  <div class="chip" data-chip><span data-status>finding a room…</span></div>
194
+ <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
176
195
  <div class="card" data-results hidden></div>
177
196
  <div class="card" data-screen hidden></div>
178
197
  ${joinCard}${share}
@@ -181,6 +200,49 @@ ${joinCard}${share}
181
200
  <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
182
201
  }
183
202
 
203
+ /*
204
+ * SERVERS AND THE AI DIAL (0.16.0, NETPLAY.md section 17): the server pill beside the room button, in the same band
205
+ * (its name and badge, or on a phone held upright its dot; its sheet has the policy's line, the AI's level with
206
+ * "Change", and Quiet AI), and the party's vote card on how strong the AI should be (an opaque bottom sheet on a
207
+ * phone; keys 1-5 on a computer). Nothing covers the middle of the screen except the card, for a few seconds, when a
208
+ * vote is open.
209
+ */
210
+ const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--hot); flex: none; }
211
+ .spill.dim .sdot { display: block; }
212
+ /* A phone held upright has no width to spare at its top: the server pill is its dot from the start (its sheet says
213
+ the name, the badge and the line; the chip names the server), so the band is the room button's label and one dot. */
214
+ @media (max-width: 540px) {
215
+ .spill, .spill:hover, .spill:focus-visible, .spill[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
216
+ .spill span[data-server-label], .spill:hover span[data-server-label], .spill:focus-visible span[data-server-label], .spill[aria-expanded="true"] span[data-server-label] { display: none; }
217
+ }
218
+ .ssheet .sline { margin: 0 2px 10px; color: #c3cad9; font-weight: 500; line-height: 1.4; }
219
+ .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; }
220
+ .ssheet .srow small { display: block; color: #8b93a7; font-weight: 500; font-size: 11px; }
221
+ .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; }
222
+ .ssheet .srow input { width: 20px; height: 20px; accent-color: var(--hot); }
223
+ .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; }
224
+ .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: #080c16; 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; }
225
+ @keyframes rise { from { opacity: 0; transform: translate(-50%, -46%); } }
226
+ .vote .vh { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 2px 12px; }
227
+ .vote .vh b { font-size: 18px; letter-spacing: -.01em; }
228
+ .vote .vh span { color: #aab3c7; font-weight: 600; font-size: 12px; white-space: nowrap; }
229
+ .vote .opts { display: grid; grid-template-columns: repeat(var(--n, 5), 1fr); gap: 8px; }
230
+ .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; }
231
+ .vote .opts button b { font-size: 16px; }
232
+ .vote .opts button small { color: #9aa3b7; font-size: 11px; font-weight: 600; }
233
+ .vote .opts button[aria-pressed="true"] { background: var(--hot); color: #0b0b10; border-color: transparent; }
234
+ .vote .opts button[aria-pressed="true"] small { color: #3b3420; }
235
+ .vote .legend { display: flex; justify-content: space-between; gap: 10px; margin: 10px 4px 0; color: #8b93a7; font-size: 11px; font-weight: 600; }
236
+ .vote .legend i { flex: 1; border-bottom: 2px dotted rgba(255,255,255,.18); margin-bottom: 4px; }
237
+ .vote .foot { margin: 10px 2px 0; text-align: center; color: #c3cad9; font-weight: 600; }
238
+ @media (max-width: 540px) {
239
+ .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; }
240
+ @keyframes up { from { opacity: 0; transform: translateY(20px); } }
241
+ .vote .opts { grid-template-columns: repeat(3, 1fr); }
242
+ .vote .opts button { min-height: 64px; }
243
+ .vote .legend { display: none; }
244
+ }`;
245
+
184
246
  /** The play page's own document (no site chrome: the game owns the whole screen). */
185
247
  function layoutless(title, body, css, ancestors = "'self'") {
186
248
  return new Response(`<!doctype html>
@@ -212,7 +274,7 @@ const SHELL_JS = String.raw`(function () {
212
274
  var statusEl = document.querySelector('[data-status]');
213
275
  var results = document.querySelector('[data-results]');
214
276
  var screenCard = document.querySelector('[data-screen]');
215
- 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 };
277
+ 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 };
216
278
  window.__shell = state;
217
279
  // This browser's room key: random, kept in this site's own storage, and sent only to this site's rooms. It is what
218
280
  // an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
@@ -257,9 +319,27 @@ const SHELL_JS = String.raw`(function () {
257
319
  }
258
320
  place();
259
321
  addEventListener('resize', place);
260
- function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
322
+ function labelOf(room) {
323
+ var m = /^pub-(\d+)$/.exec(room);
324
+ if (m) return 'Room ' + m[1];
325
+ // A room of this page's server (s-<server>-<n>) is "Room <n>" too: the server pill beside the button names it.
326
+ var at = state.server ? 's-' + state.server.id + '-' : null;
327
+ var n = at && room.indexOf(at) === 0 ? room.slice(at.length) : '';
328
+ return /^\d+$/.test(n) ? 'Room ' + n : room;
329
+ }
261
330
  function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
262
- function wake() { if (!toggle) return; toggle.classList.remove('dim'); clearTimeout(dimTimer); dimTimer = setTimeout(function () { if (sheet.hidden) toggle.classList.add('dim'); }, 6000); }
331
+ // The room pill and the server pill (in one band, at the room button's place) fade to dots after 6 s.
332
+ function wake() {
333
+ if (!toggle) return;
334
+ var sp = document.querySelector('[data-server-toggle]');
335
+ toggle.classList.remove('dim'); if (sp) sp.classList.remove('dim');
336
+ clearTimeout(dimTimer);
337
+ dimTimer = setTimeout(function () {
338
+ if (sheet.hidden) toggle.classList.add('dim');
339
+ var ss = document.querySelector('[data-server-sheet]');
340
+ if (sp && (!ss || ss.hidden)) sp.classList.add('dim');
341
+ }, 6000);
342
+ }
263
343
  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) {} } }
264
344
  function copy(text, done) {
265
345
  var ok = function () { flash(done || 'Link copied'); };
@@ -309,6 +389,8 @@ const SHELL_JS = String.raw`(function () {
309
389
  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) {}
310
390
  }
311
391
  if (params.get('debug') === '1') q.set('debug', '1');
392
+ // Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
393
+ if (state.quietAi) q.set('hush', '1');
312
394
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
313
395
  ['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); });
314
396
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
@@ -323,7 +405,7 @@ const SHELL_JS = String.raw`(function () {
323
405
  if (m.what === 'stats') state.stats = m.stats;
324
406
  if (m.what === 'round') onRound(m.round);
325
407
  if (m.what === 'roster') state.roster = m.slots;
326
- if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
408
+ 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); }
327
409
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
328
410
  if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
329
411
  paint();
@@ -349,7 +431,9 @@ const SHELL_JS = String.raw`(function () {
349
431
  var ol = document.createElement('ol');
350
432
  r.results.slice(0, 6).forEach(function (row) {
351
433
  var li = document.createElement('li');
352
- li.textContent = row.name + (row.bot ? ' (bot)' : '') + ' — ' + row.score;
434
+ li.textContent = String(row.name) + ' — ' + row.score;
435
+ // Every AI row (an agent, or a bot) carries the AI pill.
436
+ if (row.agent || row.bot) { var pill = document.createElement('span'); pill.className = 'aipill'; pill.textContent = 'AI'; li.append(pill); }
353
437
  ol.append(li);
354
438
  });
355
439
  results.append(h, ol); results.hidden = params.get('debug') !== '1'; // the game draws its own results; the card is for ?debug=1
@@ -370,10 +454,13 @@ const SHELL_JS = String.raw`(function () {
370
454
  if (m.t === 'closed') { notice('closed', m); return; }
371
455
  if (m.t === 'announce') { banner(m); return; }
372
456
  if (m.t === 'muted') { mutedNote(m); return; }
457
+ if (m.t === 'vote') { onVote(m); return; }
373
458
  if (m.t !== 'net') return;
374
459
  state.facts = m;
460
+ if (typeof m.st === 'number') state.offset = m.st - Date.now();
375
461
  if (m.announce && m.announce.text) banner(m.announce, true);
376
462
  if (state.facts && state.facts.round) onRound(state.facts.round);
463
+ if (m.vote) onVote(m.vote, true); else if (state.vote && state.vote.open) onVote({ open: false, id: state.vote.id }, true);
377
464
  paint();
378
465
  };
379
466
  ws.onclose = function () { if (!state.notice) setTimeout(function () { watch(room); }, 1500); };
@@ -438,24 +525,141 @@ const SHELL_JS = String.raw`(function () {
438
525
  var f = state.facts;
439
526
  var n = f && f.counts ? f.counts.players : null;
440
527
  var bits = [];
441
- if (n !== null) bits.push(n + (n === 1 ? ' player' : ' players') + ' here');
442
- if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
528
+ if (state.server) bits.push(state.server.name);
529
+ if (n !== null) bits.push(n + ' playing');
530
+ // Every AI is counted as AI (agents, the seats kept for them, and the game's bots), never as a player.
531
+ var aiN = f && f.counts ? (Number(f.counts.ai) || 0) + (Number(f.counts.bots) || 0) : 0;
532
+ if (aiN) bits.push(aiN + ' AI');
443
533
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
534
+ if (state.full) bits.push('waiting for a seat');
444
535
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
445
536
  say(bits.join(' · ') || 'joining…');
446
537
  var count = ui && ui.querySelector('[data-room-count]');
447
- if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here');
538
+ if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here') + (aiN ? ' · ' + aiN + ' AI' : '');
539
+ serverUi();
540
+ }
541
+
542
+ /* ---------------------------------------------------------- the server pill, Quiet AI and the vote (section 17) */
543
+ var SKILL = boot.skills || [];
544
+ var sToggle = document.querySelector('[data-server-toggle]');
545
+ var sSheet = document.querySelector('[data-server-sheet]');
546
+ var voteCard = document.querySelector('[data-vote]');
547
+ try { state.quietAi = localStorage.getItem('homie-quiet-ai') === '1'; } catch (e) { state.quietAi = false; }
548
+ function skillName(n) { var s = SKILL[n - 1]; return s ? s.name : 'level ' + n; }
549
+ function hasAi() { var f = state.facts; return Boolean(f && f.counts && (f.counts.ai || f.counts.bots || f.counts.agents)); }
550
+ function readsDial() { var f = state.facts; return Boolean(f && Array.isArray(f.caps) && f.caps.indexOf('skill') >= 0); }
551
+ // The room's live policy says the badge and line (the owner's change reaches an open page at once); the
552
+ // server's words from the page load are the fallback.
553
+ function policyText(pol) {
554
+ var w = pol && boot.words ? boot.words[pol.kind] : null;
555
+ 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.' };
556
+ var n = pol.kind === 'hybrid' ? (Number(pol.aiSeats) || 0) : 0;
557
+ return { badge: pol.kind === 'hybrid' ? w.badge + ' · ' + n : w.badge, line: w.line.replace('{n}', String(n)) + (pol.kids && boot.kidsLine ? ' ' + boot.kidsLine : '') };
558
+ }
559
+ function serverUi() {
560
+ if (!sToggle || !sSheet) return;
561
+ var f = state.facts; var pol = f && f.policy ? f.policy : null;
562
+ var show = Boolean(state.server) || (hasAi() && readsDial());
563
+ sToggle.hidden = !show;
564
+ if (!show) return;
565
+ var name = state.server ? state.server.name : 'Quick play';
566
+ var words = policyText(pol);
567
+ var label = name + ' · ' + words.badge + (state.server && state.server.mentor ? ' · Mentor' : '');
568
+ var labelEl = sToggle.querySelector('[data-server-label]');
569
+ // A new policy (the owner's change) shows in full for a moment, then fades with the room pill.
570
+ if (labelEl.textContent !== label) { labelEl.textContent = label; sToggle.setAttribute('aria-label', label + ': this server'); wake(); }
571
+ sSheet.querySelector('[data-server-name]').textContent = name;
572
+ sSheet.querySelector('[data-server-badge]').textContent = words.badge;
573
+ sSheet.querySelector('[data-server-line]').textContent = words.line;
574
+ var row = sSheet.querySelector('[data-level-row]');
575
+ row.hidden = !(readsDial() && hasAi() && boot.vote !== false);
576
+ if (pol && pol.skill) sSheet.querySelector('[data-level-name]').textContent = pol.skill.name;
577
+ sSheet.querySelector('[data-quiet-ai]').checked = state.quietAi;
578
+ }
579
+ if (sToggle) {
580
+ 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(); });
581
+ 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);
582
+ sSheet.querySelector('[data-quiet-ai]').addEventListener('change', function (e) {
583
+ state.quietAi = e.target.checked;
584
+ try { localStorage.setItem('homie-quiet-ai', state.quietAi ? '1' : '0'); } catch (e2) {}
585
+ try { frame.contentWindow.postMessage({ t: 'homie-hush', on: state.quietAi }, '*'); } catch (e3) {}
586
+ flash(state.quietAi ? 'AI chat is hidden on this screen.' : 'AI chat shows again.');
587
+ });
588
+ sSheet.querySelector('[data-level-vote]').addEventListener('click', function () { sendVote({ open: true }); sSheet.hidden = true; sToggle.setAttribute('aria-expanded', 'false'); });
589
+ }
590
+ function sendVote(m) {
591
+ var ws = state.watchSocket;
592
+ if (!ws || ws.readyState !== 1) { flash('Not connected yet; try again in a moment.'); return; }
593
+ try { ws.send(JSON.stringify(Object.assign({ t: 'vote', of: 'skill' }, m))); } catch (e) {}
448
594
  }
595
+ function cast(n) { if (!state.vote || !state.vote.open) return; state.myVote[state.vote.id] = n; sendVote({ n: n }); renderVote(); }
596
+ var shownResults = {};
597
+ function onVote(v, quiet) {
598
+ if (!v || typeof v !== 'object') return;
599
+ if (v.t === 'error' || v.code) return;
600
+ var prev = state.vote;
601
+ state.vote = v.options ? v : (prev && prev.id === v.id ? Object.assign({}, prev, { open: false }) : prev);
602
+ if (v.result && !v.open && !shownResults[v.id] && !quiet) {
603
+ shownResults[v.id] = true;
604
+ flash('The party set the AI to ' + v.result.name + ' (' + v.result.votes + (v.result.votes === 1 ? ' vote' : ' votes') + ').');
605
+ } else if (v.result && !v.open) shownResults[v.id] = true;
606
+ renderVote();
607
+ }
608
+ var voteTimer = null;
609
+ function renderVote() {
610
+ var v = state.vote;
611
+ if (!voteCard) return;
612
+ // A game that draws its own card (game.json "agents": { "vote": "game" }), or no vote: the shell shows none.
613
+ if (!v || !v.open || boot.vote !== 'shell' || boot.screen || state.seat === null || state.seat === undefined) { voteCard.hidden = true; clearInterval(voteTimer); voteTimer = null; return; }
614
+ var left = Math.max(0, Math.ceil((v.until - (Date.now() + (state.offset || 0))) / 1000));
615
+ var mine = state.myVote[v.id];
616
+ voteCard.textContent = '';
617
+ var head = document.createElement('div'); head.className = 'vh';
618
+ var t = document.createElement('b'); t.textContent = 'How strong should the AI be?';
619
+ var sub = document.createElement('span'); sub.textContent = 'for this party · ' + left + ' s';
620
+ head.append(t, sub);
621
+ var opts = document.createElement('div'); opts.className = 'opts'; opts.style.setProperty('--n', String(v.options.length));
622
+ v.options.forEach(function (n) {
623
+ var b = document.createElement('button'); b.type = 'button'; b.setAttribute('data-vote-n', String(n));
624
+ b.setAttribute('aria-pressed', mine === n ? 'true' : 'false');
625
+ var big = document.createElement('b'); big.textContent = n + ' ' + skillName(n);
626
+ var small = document.createElement('small'); small.textContent = (v.counts && v.counts[n] ? v.counts[n] + (v.counts[n] === 1 ? ' vote' : ' votes') : '\u00a0');
627
+ b.append(big, small);
628
+ b.addEventListener('click', function () { cast(n); });
629
+ opts.appendChild(b);
630
+ });
631
+ var legend = document.createElement('div'); legend.className = 'legend';
632
+ var l1 = document.createElement('span'); l1.textContent = SKILL[0] ? SKILL[0].card.split(',')[0] : 'stays at the back';
633
+ var l2 = document.createElement('span'); var top = SKILL[v.options.length - 1]; l2.textContent = top ? top.card.split(',')[0] : '';
634
+ legend.append(l1, document.createElement('i'), l2);
635
+ var foot = document.createElement('div'); foot.className = 'foot';
636
+ foot.textContent = (v.voters || 0) + ' of ' + (v.of_total || 1) + ' voted · the middle vote wins';
637
+ voteCard.append(head, opts, legend, foot);
638
+ voteCard.hidden = false;
639
+ if (!voteTimer) voteTimer = setInterval(renderVote, 500);
640
+ }
641
+ document.addEventListener('keydown', function (e) {
642
+ if (!state.vote || !state.vote.open || voteCard.hidden || e.metaKey || e.ctrlKey || e.altKey) return;
643
+ var n = Number(e.key);
644
+ if (state.vote.options.indexOf(n) >= 0) { e.preventDefault(); cast(n); }
645
+ });
449
646
 
450
647
  // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
451
648
  // The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
452
649
  var askedRoom = params.get('room');
650
+ var lobbyQ = [];
651
+ if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
652
+ if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
453
653
  if (boot.room) start(boot.room);
454
654
  else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
455
655
  else if (askedRoom !== null) { say('that room link does not work'); }
456
- else fetch('/' + boot.game + '/api/lobby' + (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '') ? '?not=' + encodeURIComponent(params.get('not')) : ''), { method: 'POST' })
656
+ else fetch('/' + boot.game + '/api/lobby' + (lobbyQ.length ? '?' + lobbyQ.join('&') : ''), { method: 'POST' })
457
657
  .then(function (r) { return r.json(); })
458
- .then(function (j) { start(j.room || 'main'); })
658
+ .then(function (j) {
659
+ // Every room this server may have is full: the fullest one, watched until a seat frees (the relay seats it).
660
+ if (j.full) { state.full = true; flash((state.server ? state.server.name : 'This server') + ' is full. You\u2019re next for a seat.'); }
661
+ start(j.room || 'main');
662
+ })
459
663
  .catch(function () { start('main'); });
460
664
  }());`;
461
665
 
@@ -481,6 +685,7 @@ const OWNER_CSS = `.owner { position: fixed; z-index: 10; top: max(8px, env(safe
481
685
  .osheet .tag { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid rgba(255,255,255,.2); color: #aab3c7; margin-left: 6px; }
482
686
  .osheet .tag.you { color: #c9b8ff; border-color: #c9b8ff; }
483
687
  .osheet .tag.muted { color: #ff8a9a; border-color: rgba(255,107,125,.6); }
688
+ .osheet .tag.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
484
689
  .osheet .bot { color: #6c7489; font-weight: 500; padding: 2px 10px 8px; }
485
690
  .osheet .det { padding: 4px 2px; }
486
691
  .osheet .det b { font-size: 18px; display: block; margin-bottom: 4px; overflow-wrap: anywhere; }
@@ -553,7 +758,7 @@ const OWNER_JS = String.raw`(function () {
553
758
  var back = el('button', 'back', '← Everyone here'); back.type = 'button'; back.onclick = function () { view.pick = null; view.msg = ''; render(); };
554
759
  var det = el('div', 'det');
555
760
  det.appendChild(el('b', '', picked.name || 'Someone'));
556
- 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' : '')));
761
+ 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' : '')));
557
762
  if (me) det.appendChild(el('p', '', 'This is you.'));
558
763
  if (picked.muted) det.appendChild(el('p', '', 'Muted: their chat and emotes reach nobody.'));
559
764
  sheet.append(back, det);
@@ -579,12 +784,13 @@ const OWNER_JS = String.raw`(function () {
579
784
  var left = el('span'); left.appendChild(el('span', '', c.name || 'Someone'));
580
785
  if (c.seat !== null && c.seat === state.seat) left.appendChild(el('span', 'tag you', 'you'));
581
786
  if (c.muted) left.appendChild(el('span', 'tag muted', 'muted'));
787
+ if (c.agent) left.appendChild(el('span', 'tag ai', 'AI' + (c.agent.role === 'guide' ? ' guide' : c.agent.role === 'party' ? ' companion' : '')));
582
788
  if (c.role === 'host') left.appendChild(el('span', 'tag', 'host'));
583
789
  row.append(left, el('small', '', c.seat !== null ? 'seat ' + (c.seat + 1) + ' · ' + (c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'screen' : 'computer') : 'waiting'));
584
790
  row.onclick = function () { view.pick = c.id; view.msg = ''; render(); };
585
791
  sheet.appendChild(row);
586
792
  });
587
- var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'Bots: ' + b.map(function (s) { return s.name; }).join(', ')));
793
+ var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'AI bots: ' + b.map(function (s) { return s.name; }).join(', ')));
588
794
  }
589
795
  var form = el('form'); form.setAttribute('data-announce', '');
590
796
  var ta = el('textarea'); ta.maxLength = 280; ta.placeholder = 'Announce something to the players'; ta.setAttribute('aria-label', 'Announcement');
@@ -695,6 +901,7 @@ iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0
695
901
  .chip .sc.lead { color: var(--hot); }
696
902
  .chip svg { width: 1.1em; height: 1.1em; flex: none; }
697
903
  .chip .tag { font-size: .68em; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--hot); }
904
+ .chip .tag.ai { padding: .1em .45em; border-radius: 999px; background: color-mix(in srgb, var(--hot) 20%, transparent); color: #fff; letter-spacing: .06em; }
698
905
  .hint { flex: none; align-self: center; color: #7d8699; font-size: .76em; font-weight: 600; padding: 0 .2em; white-space: nowrap; }
699
906
  @media (hover: none) { .chip .k, .hint { display: none; } }
700
907
  /* A phone held upright: the caption over the strip, the strip scrolling sideways. */
@@ -895,7 +1102,7 @@ const WATCH_JS = String.raw`(function () {
895
1102
  var scores = state.scores || {};
896
1103
  var lead = null; var best = -Infinity;
897
1104
  list.forEach(function (p) { var s = scores[p.seat]; if (typeof s === 'number' && s > best) { best = s; lead = p.seat; } });
898
- var sig = JSON.stringify([choose, v.seat, v.following, list.map(function (p) { return [p.seat, p.name, p.colour, scores[p.seat]]; }), lead, state.follows]);
1105
+ 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]);
899
1106
  if (sig === drawn) return;
900
1107
  drawn = sig;
901
1108
  strip.textContent = '';
@@ -917,6 +1124,8 @@ const WATCH_JS = String.raw`(function () {
917
1124
  chip(p.seat, i < 9 ? String(i + 1) : '', function (b) {
918
1125
  var dot = el('i', 'dot'); dot.style.background = colourOf(p); b.appendChild(dot);
919
1126
  b.appendChild(el('span', 'nm', p.name || 'A player'));
1127
+ // An AI in the room is marked AI here too (section 17).
1128
+ if (p.agent) b.appendChild(el('span', 'tag ai', 'AI'));
920
1129
  if (auto) b.appendChild(el('span', 'tag', 'auto'));
921
1130
  if (typeof scores[p.seat] === 'number') b.appendChild(el('span', 'sc' + (p.seat === lead && list.length > 1 ? ' lead' : ''), scores[p.seat]));
922
1131
  b.setAttribute('aria-label', (on || auto ? 'Watching ' : 'Watch ') + (p.name || 'a player'));
@@ -25,6 +25,7 @@
25
25
  * A player sees, downloads (/api/player/export) and deletes (/api/player/delete) everything of theirs.
26
26
  */
27
27
  import { handleFor } from './room.mjs';
28
+ import { isAiName } from './agents.mjs';
28
29
  import { cookieValue } from './stats.mjs';
29
30
  import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
30
31
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
@@ -147,8 +148,9 @@ const ROLE_WORDS = /^(?:the\s+)?(?:admin|administrator|mod|moderator|owner|staff
147
148
  /**
148
149
  * A display name, by the same rules as a room's typed names and handles: one line of at most 24 characters,
149
150
  * whitespace collapsed, no control, format or invisible characters (no zero-width or bidi tricks), at least one
150
- * letter or digit; never a role (admin, moderator, owner…) or the studio's own name, except for the owner. It is
151
- * always shown as text (escaped), never as markup. `max` lets a character's name run to 32.
151
+ * letter or digit; never a role (admin, moderator, owner…) or the studio's own name, except for the owner; and never
152
+ * ending in an AI or bot mark ("· AI", "(bot)"…), for anyone: only an agent's name says AI (0.16.0). It is always
153
+ * shown as text (escaped), never as markup. `max` lets a character's name run to 32.
152
154
  */
153
155
  export function cleanName(raw, { studio = '', owner = false, max = 24 } = {}) {
154
156
  const text = String(raw ?? '').normalize('NFKC')
@@ -161,6 +163,7 @@ export function cleanName(raw, { studio = '', owner = false, max = 24 } = {}) {
161
163
  if (!owner && (ROLE_WORDS.test(name) || (studio && name.toLowerCase() === String(studio).trim().toLowerCase()))) {
162
164
  return { ok: false, error: 'name-taken', message: 'that name belongs to the studio; pick another' };
163
165
  }
166
+ if (isAiName(name)) return { ok: false, error: 'name-ai', message: 'a person\'s name cannot end in "AI" or "bot": only an AI player is marked AI' };
164
167
  return { ok: true, name, cut: chars.length > max };
165
168
  }
166
169
 
@@ -299,7 +302,10 @@ async function meOf(env, player) {
299
302
  const e = await env.DB.prepare('SELECT email, verified_at FROM player_emails WHERE player = ?1').bind(player.id).first();
300
303
  if (e) email = { address: e.email, verified: Boolean(e.verified_at) };
301
304
  }
302
- return { id: player.id, name: player.name, named: Boolean(player.named), guest: Boolean(player.guest), owner: Boolean(player.owner), since: new Date(player.since).toISOString(), passkeys: Number(keys?.n) || 0, email };
305
+ // The servers this player belongs to (0.16.0; none before migration 0006).
306
+ let servers = [];
307
+ try { servers = ((await env.DB.prepare('SELECT game, server, role, home FROM server_members WHERE player = ?1 ORDER BY home DESC, seen_at DESC LIMIT 50').bind(player.id).all()).results ?? []).map((r) => ({ game: r.game, server: r.server, role: r.role, home: Number(r.home) === 1 })); } catch { servers = []; }
308
+ return { id: player.id, name: player.name, named: Boolean(player.named), guest: Boolean(player.guest), owner: Boolean(player.owner), since: new Date(player.since).toISOString(), passkeys: Number(keys?.n) || 0, email, servers };
303
309
  }
304
310
 
305
311
  const features = (env) => ({ accounts: Boolean(env.DB) && env.HOMIE_PREVIEW !== '1', email: Boolean(env.PLAYER_MAIL && env.PLAYER_MAIL_FROM) });
@@ -315,6 +321,8 @@ async function removePlayer(env, id) {
315
321
  env.DB.prepare('DELETE FROM player_emails WHERE player = ?1').bind(id),
316
322
  env.DB.prepare('DELETE FROM players WHERE id = ?1').bind(id),
317
323
  ]);
324
+ // Their servers' memberships too (0.16.0); a studio before migration 0006 has none to delete.
325
+ try { await env.DB.prepare('DELETE FROM server_members WHERE player = ?1').bind(id).run(); } catch { /* not migrated */ }
318
326
  await data.after();
319
327
  }
320
328