@homie-rocks/studio 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/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);
@@ -148,16 +150,28 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
148
150
  .notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
149
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); }
150
152
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
151
- [hidden] { display: none !important; }${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
153
+ [hidden] { display: none !important; }
154
+ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
152
155
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
153
156
  // 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 } : {}) };
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 };
155
162
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
156
163
  const joinCorner = corner('join', 'bottom-right');
157
164
  const first = places.desk;
158
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>` : '';
159
166
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
160
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>
161
175
  <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
162
176
  <div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
163
177
  <div class="acts">
@@ -173,6 +187,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
173
187
  return layoutless(`${g.name} · play`, `
174
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>
175
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>
176
191
  <div class="card" data-results hidden></div>
177
192
  <div class="card" data-screen hidden></div>
178
193
  ${joinCard}${share}
@@ -181,6 +196,42 @@ ${joinCard}${share}
181
196
  <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
182
197
  }
183
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
+
184
235
  /** The play page's own document (no site chrome: the game owns the whole screen). */
185
236
  function layoutless(title, body, css, ancestors = "'self'") {
186
237
  return new Response(`<!doctype html>
@@ -212,7 +263,7 @@ const SHELL_JS = String.raw`(function () {
212
263
  var statusEl = document.querySelector('[data-status]');
213
264
  var results = document.querySelector('[data-results]');
214
265
  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 };
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 };
216
267
  window.__shell = state;
217
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
218
269
  // an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
@@ -259,7 +310,18 @@ const SHELL_JS = String.raw`(function () {
259
310
  addEventListener('resize', place);
260
311
  function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
261
312
  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); }
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
+ }
263
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) {} } }
264
326
  function copy(text, done) {
265
327
  var ok = function () { flash(done || 'Link copied'); };
@@ -309,6 +371,8 @@ const SHELL_JS = String.raw`(function () {
309
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) {}
310
372
  }
311
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');
312
376
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
313
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); });
314
378
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
@@ -323,7 +387,7 @@ const SHELL_JS = String.raw`(function () {
323
387
  if (m.what === 'stats') state.stats = m.stats;
324
388
  if (m.what === 'round') onRound(m.round);
325
389
  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); }
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); }
327
391
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
328
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) {} }
329
393
  paint();
@@ -349,7 +413,9 @@ const SHELL_JS = String.raw`(function () {
349
413
  var ol = document.createElement('ol');
350
414
  r.results.slice(0, 6).forEach(function (row) {
351
415
  var li = document.createElement('li');
352
- 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); }
353
419
  ol.append(li);
354
420
  });
355
421
  results.append(h, ol); results.hidden = params.get('debug') !== '1'; // the game draws its own results; the card is for ?debug=1
@@ -370,10 +436,13 @@ const SHELL_JS = String.raw`(function () {
370
436
  if (m.t === 'closed') { notice('closed', m); return; }
371
437
  if (m.t === 'announce') { banner(m); return; }
372
438
  if (m.t === 'muted') { mutedNote(m); return; }
439
+ if (m.t === 'vote') { onVote(m); return; }
373
440
  if (m.t !== 'net') return;
374
441
  state.facts = m;
442
+ if (typeof m.st === 'number') state.offset = m.st - Date.now();
375
443
  if (m.announce && m.announce.text) banner(m.announce, true);
376
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);
377
446
  paint();
378
447
  };
379
448
  ws.onclose = function () { if (!state.notice) setTimeout(function () { watch(room); }, 1500); };
@@ -438,24 +507,141 @@ const SHELL_JS = String.raw`(function () {
438
507
  var f = state.facts;
439
508
  var n = f && f.counts ? f.counts.players : null;
440
509
  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');
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');
443
515
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
516
+ if (state.full) bits.push('waiting for a seat');
444
517
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
445
518
  say(bits.join(' · ') || 'joining…');
446
519
  var count = ui && ui.querySelector('[data-room-count]');
447
- 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 : '') };
448
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'); });
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
+ });
449
628
 
450
629
  // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
451
630
  // The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
452
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')));
453
635
  if (boot.room) start(boot.room);
454
636
  else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
455
637
  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' })
638
+ else fetch('/' + boot.game + '/api/lobby' + (lobbyQ.length ? '?' + lobbyQ.join('&') : ''), { method: 'POST' })
457
639
  .then(function (r) { return r.json(); })
458
- .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
+ })
459
645
  .catch(function () { start('main'); });
460
646
  }());`;
461
647
 
@@ -481,6 +667,7 @@ const OWNER_CSS = `.owner { position: fixed; z-index: 10; top: max(8px, env(safe
481
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; }
482
668
  .osheet .tag.you { color: #c9b8ff; border-color: #c9b8ff; }
483
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); }
484
671
  .osheet .bot { color: #6c7489; font-weight: 500; padding: 2px 10px 8px; }
485
672
  .osheet .det { padding: 4px 2px; }
486
673
  .osheet .det b { font-size: 18px; display: block; margin-bottom: 4px; overflow-wrap: anywhere; }
@@ -553,7 +740,7 @@ const OWNER_JS = String.raw`(function () {
553
740
  var back = el('button', 'back', '← Everyone here'); back.type = 'button'; back.onclick = function () { view.pick = null; view.msg = ''; render(); };
554
741
  var det = el('div', 'det');
555
742
  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' : '')));
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' : '')));
557
744
  if (me) det.appendChild(el('p', '', 'This is you.'));
558
745
  if (picked.muted) det.appendChild(el('p', '', 'Muted: their chat and emotes reach nobody.'));
559
746
  sheet.append(back, det);
@@ -579,12 +766,13 @@ const OWNER_JS = String.raw`(function () {
579
766
  var left = el('span'); left.appendChild(el('span', '', c.name || 'Someone'));
580
767
  if (c.seat !== null && c.seat === state.seat) left.appendChild(el('span', 'tag you', 'you'));
581
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' : '')));
582
770
  if (c.role === 'host') left.appendChild(el('span', 'tag', 'host'));
583
771
  row.append(left, el('small', '', c.seat !== null ? 'seat ' + (c.seat + 1) + ' · ' + (c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'screen' : 'computer') : 'waiting'));
584
772
  row.onclick = function () { view.pick = c.id; view.msg = ''; render(); };
585
773
  sheet.appendChild(row);
586
774
  });
587
- 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(', ')));
588
776
  }
589
777
  var form = el('form'); form.setAttribute('data-announce', '');
590
778
  var ta = el('textarea'); ta.maxLength = 280; ta.placeholder = 'Announce something to the players'; ta.setAttribute('aria-label', 'Announcement');
@@ -695,6 +883,7 @@ iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0
695
883
  .chip .sc.lead { color: var(--hot); }
696
884
  .chip svg { width: 1.1em; height: 1.1em; flex: none; }
697
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; }
698
887
  .hint { flex: none; align-self: center; color: #7d8699; font-size: .76em; font-weight: 600; padding: 0 .2em; white-space: nowrap; }
699
888
  @media (hover: none) { .chip .k, .hint { display: none; } }
700
889
  /* A phone held upright: the caption over the strip, the strip scrolling sideways. */
@@ -895,7 +1084,7 @@ const WATCH_JS = String.raw`(function () {
895
1084
  var scores = state.scores || {};
896
1085
  var lead = null; var best = -Infinity;
897
1086
  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]);
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]);
899
1088
  if (sig === drawn) return;
900
1089
  drawn = sig;
901
1090
  strip.textContent = '';
@@ -917,6 +1106,8 @@ const WATCH_JS = String.raw`(function () {
917
1106
  chip(p.seat, i < 9 ? String(i + 1) : '', function (b) {
918
1107
  var dot = el('i', 'dot'); dot.style.background = colourOf(p); b.appendChild(dot);
919
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'));
920
1111
  if (auto) b.appendChild(el('span', 'tag', 'auto'));
921
1112
  if (typeof scores[p.seat] === 'number') b.appendChild(el('span', 'sc' + (p.seat === lead && list.length > 1 ? ' lead' : ''), scores[p.seat]));
922
1113
  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