@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.
@@ -93,6 +93,19 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
93
93
  .toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: #1f2433; border: 1px solid var(--line2); padding: 10px 16px; border-radius: 12px; font-weight: 600; max-width: calc(100vw - 32px); z-index: 9; }
94
94
  .note { color: var(--dim); font-size: 13px; max-width: 75ch; margin-top: 26px; }
95
95
  .err { color: var(--bad); }
96
+ .servers { padding: 12px 16px 14px; border-bottom: 1px solid var(--line); }
97
+ .srv { border-top: 1px dashed var(--line); padding: 8px 0; }
98
+ .srv:first-of-type { border-top: 0; }
99
+ .srvhead { display: grid; grid-template-columns: minmax(140px, 1.3fr) minmax(160px, 1.6fr) minmax(150px, 1.3fr) auto; gap: 6px 12px; align-items: center; cursor: pointer; font-size: 14px; }
100
+ .srvhead b { font-size: 15px; }
101
+ .srvbody { padding: 8px 0 4px 14px; display: grid; gap: 8px; font-size: 14px; }
102
+ .srvbody .line { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
103
+ .warnbox { margin: 10px 16px; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(255,179,92,.45); color: var(--warn); font-size: 14px; }
104
+ .secret { font: 600 13px/1.4 ui-monospace, Menlo, monospace; word-break: break-all; padding: 10px; border-radius: 10px; background: var(--bg); border: 1px solid var(--warn); }
105
+ .chip.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
106
+ .newsrv { display: grid; gap: 8px; padding: 10px 0 0; }
107
+ .newsrv .line { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
108
+ @media (max-width: 720px) { .srvhead { grid-template-columns: 1fr 1fr; } }
96
109
  @media (max-width: 720px) {
97
110
  .rhead { grid-template-columns: 1fr 1fr 1fr; }
98
111
  .rhead .racts { grid-column: 1 / -1; justify-content: flex-start; }
@@ -190,6 +203,151 @@ export const OFFICE_SCRIPT = String.raw`(function () {
190
203
  return box;
191
204
  }
192
205
 
206
+ /* ---------------------------------------------------------------- servers and agent seats (0.16.0) */
207
+ var POLICY = { open: 'Open', 'humans-only': 'Humans only', hybrid: 'Hybrid', beginner: 'Beginner' };
208
+ var LEVELS = ['Rookie', 'Steady', 'Fair', 'Strong', 'Maxed'];
209
+ function sel(options, value, label) { var x = el('select'); options.forEach(function (o) { var op = el('option', '', o[1]); op.value = o[0]; if (String(o[0]) === String(value)) op.selected = true; x.appendChild(op); }); if (label) x.setAttribute('aria-label', label); return x; }
210
+ function num(value, min, max, label) { var x = el('input'); x.type = 'number'; x.min = String(min); x.max = String(max); x.value = String(value); x.setAttribute('aria-label', label); return x; }
211
+ function srvSummary(sv) {
212
+ var bits = [POLICY[sv.policy] + (sv.policy === 'hybrid' ? ' · ' + sv.aiSeats : ''), sv.policy === 'beginner' ? sv.guides + ' guides' : null, sv.kids ? 'kids' : null,
213
+ sv.bots === 'off' ? 'bots off' : 'bots fill', LEVELS[sv.level - 1], sv.door !== 'open' ? 'door: ' + sv.door : null, sv.state !== 'open' ? sv.state.toUpperCase() : null];
214
+ return bits.filter(Boolean).join(' · ');
215
+ }
216
+ /** The owner may narrow a server: a second thought first (a stricter door, humans-only, fewer rooms, closing). */
217
+ function narrowing(sv, f) {
218
+ var rank = { open: 0, accounts: 1, invite: 2 };
219
+ return (f.door && rank[f.door] > rank[sv.door]) || (f.policy && f.policy !== sv.policy && (f.policy === 'humans-only' || f.policy === 'beginner')) || (f.rooms !== undefined && f.rooms < sv.rooms);
220
+ }
221
+ function setServer(g, sv, f, done) {
222
+ if (narrowing(sv, f) && !confirm('This takes something away on ' + sv.name + (f.policy === 'humans-only' ? ': its AI players leave after the current round' : '') + '. Go ahead?')) return;
223
+ act('/_studio/api/servers/set', Object.assign({ game: g.id, server: sv.id }, f), done || sv.name + ' changed.');
224
+ }
225
+ function serverRow(g, sv) {
226
+ var box = el('div', 'srv');
227
+ var key = 'srv:' + g.id + '/' + sv.id;
228
+ var head = el('div', 'srvhead');
229
+ head.onclick = function (e) { if (e.target.closest('button,select,input')) return; S.open[key] = !S.open[key]; render(); };
230
+ var live = sv.live || { rooms: 0, players: 0, ai: 0 };
231
+ add(head, add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + sv.name + (sv.id === 'public' ? ' (public)' : '')), el('div', 'faint', sv.id === 'public' ? 'Quick play' : sv.id)),
232
+ el('div', 'dim', srvSummary(sv)),
233
+ el('div', 'stat', live.rooms ? live.rooms + (live.rooms === 1 ? ' room' : ' rooms') + ' · ' + live.players + ' playing · ' + live.ai + ' AI' : '—'),
234
+ el('div', 'faint', sv.members ? sv.members + ' members' : ''));
235
+ box.appendChild(head);
236
+ if (!S.open[key]) return box;
237
+ var body = el('div', 'srvbody');
238
+ var pol = sel([['open', 'Open'], ['hybrid', 'Hybrid'], ['beginner', 'Beginner'], ['humans-only', 'Humans only']], sv.policy, 'Policy');
239
+ var door = sel([['open', 'Door: anyone'], ['accounts', 'Door: accounts'], ['invite', 'Door: invite']], sv.door, 'Door');
240
+ var bots = sel([['fill', 'Bots fill empty seats'], ['off', 'Bots off']], sv.bots, 'Bots');
241
+ var level = sel(LEVELS.map(function (n, i) { return [i + 1, 'Default ' + n]; }), sv.level, 'Default level');
242
+ var levelMax = sel(LEVELS.map(function (n, i) { return [i + 1, 'Ceiling ' + n]; }), sv.levelMax, 'Ceiling');
243
+ var ai = num(sv.policy === 'beginner' ? sv.guides : sv.aiSeats, 0, 31, sv.policy === 'beginner' ? 'Guides' : 'AI seats');
244
+ add(body, add(el('div', 'line'), pol, door, bots, level, levelMax, el('span', 'faint', sv.policy === 'beginner' ? 'guides' : 'AI seats'), ai,
245
+ btn('Save', 'small', function () {
246
+ var f = {};
247
+ if (pol.value !== sv.policy) f.policy = pol.value;
248
+ if (door.value !== sv.door) f.door = door.value;
249
+ if (bots.value !== sv.bots) f.bots = bots.value;
250
+ if (Number(level.value) !== sv.level) f.level = Number(level.value);
251
+ if (Number(levelMax.value) !== sv.levelMax) f.levelMax = Number(levelMax.value);
252
+ var n = Math.floor(Number(ai.value));
253
+ if ((pol.value === 'beginner' ? 'guides' : 'aiSeats') && n !== (sv.policy === 'beginner' ? sv.guides : sv.aiSeats)) f[pol.value === 'beginner' ? 'guides' : 'aiSeats'] = n;
254
+ if (!Object.keys(f).length) { toast('Nothing changed.'); return; }
255
+ setServer(g, sv, f);
256
+ })));
257
+ add(body, el('div', 'faint', sv.line));
258
+ if (sv.id !== 'public' || sv.state !== 'open') {
259
+ add(body, add(el('div', 'line'), Object.assign(el('a', '', 'Its page'), { href: sv.page, target: '_blank', rel: 'noopener' }),
260
+ sv.state === 'open' ? armed('Close server', 'bad', function () { act('/_studio/api/servers/close', { game: g.id, server: sv.id }, sv.name + ' closes after the current round.'); })
261
+ : btn('Open it again', 'ghost small', function () { act('/_studio/api/servers/close', { game: g.id, server: sv.id, reopen: true }, sv.name + ' is open again.'); })));
262
+ } else add(body, add(el('div', 'line'), armed('Hide Quick play', 'bad', function () { act('/_studio/api/servers/close', { game: g.id, server: 'public' }, 'Quick play is hidden: Play shows the other servers.'); })));
263
+ if (sv.policy !== 'humans-only') {
264
+ var brain = sel([['script', 'Guides: scripted'], ['off', 'Guides: off'], ['workers-ai', 'Guides talk (Workers AI)'], ['owner-key', 'Guides talk (your key)']], sv.brain, 'AI guides');
265
+ add(body, add(el('div', 'line'), el('span', 'dim', 'AI guides\' brain'), brain, btn('Set', 'ghost small', function () {
266
+ if ((brain.value === 'workers-ai' || brain.value === 'owner-key') && !S.data.agentsTalk && !confirm('Let AI guides talk? They speak only the lines the game\'s agents.json gives them, at most one every 8 seconds, never about a person, and any player can quiet them.')) return;
267
+ act('/_studio/api/agents/brain', { game: g.id, server: sv.id, mode: brain.value });
268
+ })));
269
+ }
270
+ if (sv.id !== 'public') {
271
+ var mbox = el('div', 'line'); mbox.appendChild(el('span', 'dim', 'Members:'));
272
+ var showM = btn('Show', 'ghost small', function () {
273
+ fetch('/_studio/api/servers/members?game=' + encodeURIComponent(g.id) + '&server=' + encodeURIComponent(sv.id), { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (d) {
274
+ mbox.textContent = ''; mbox.appendChild(el('span', 'dim', 'Members:'));
275
+ (d.members || []).forEach(function (m) {
276
+ var who = m.account ? (m.account.handle || m.account.id) : m.player;
277
+ var span = add(el('span', 'chip' + (m.role !== 'member' ? ' owner' : '')), document.createTextNode(who + (m.home ? ' ★' : '') + (m.role !== 'member' ? ' · ' + m.role : '')));
278
+ mbox.appendChild(span);
279
+ if (m.role !== 'mentor') mbox.appendChild(btn('Mentor', 'ghost small', function () { act('/_studio/api/servers/member', { game: g.id, server: sv.id, player: m.player, role: 'mentor', name: who }, who + ' is a mentor of ' + sv.name + '.'); }));
280
+ mbox.appendChild(armed('Remove', 'bad', function () { act('/_studio/api/servers/member', { game: g.id, server: sv.id, player: m.player, remove: true, name: who }, who + ' was removed from ' + sv.name + '.'); }));
281
+ });
282
+ if (!(d.members || []).length) mbox.appendChild(el('span', 'faint', d.ok ? 'nobody yet' : (d.message || 'not available')));
283
+ });
284
+ });
285
+ mbox.appendChild(showM);
286
+ body.appendChild(mbox);
287
+ }
288
+ box.appendChild(body);
289
+ return box;
290
+ }
291
+ function newServer(g) {
292
+ var key = 'new:' + g.id;
293
+ var wrap = el('div');
294
+ if (!S.open[key]) { wrap.appendChild(btn('+ New server', 'small', function () { S.open[key] = true; render(); })); return wrap; }
295
+ var form = el('div', 'newsrv');
296
+ var name = el('input'); name.type = 'text'; name.placeholder = 'Name (Night Shift)'; name.maxLength = 40;
297
+ var pol = sel([['hybrid', 'Hybrid: AI seats in every room'], ['beginner', 'Beginner: new players, AI guides'], ['humans-only', 'Humans only'], ['open', 'Open']], 'hybrid', 'Policy');
298
+ var n = num(2, 0, Math.max(0, g.maxPlayers - 1), 'AI seats or guides');
299
+ var kids = el('input'); kids.type = 'checkbox'; kids.setAttribute('aria-label', 'Kids');
300
+ var door = sel([['open', 'Door: anyone'], ['accounts', 'Door: accounts'], ['invite', 'Door: invite']], 'open', 'Door');
301
+ var level = sel(LEVELS.map(function (x, i) { return [i + 1, 'Level ' + x]; }), 3, 'Default level');
302
+ var levelMax = sel(LEVELS.map(function (x, i) { return [i + 1, 'Ceiling ' + x]; }), 5, 'Ceiling');
303
+ var speech = sel([['game', 'Chat: the game\'s'], ['lines', 'Chat: quick lines only'], ['off', 'Chat: off']], 'game', 'Speech');
304
+ var rooms = num(4, 1, 16, 'Rooms');
305
+ var listed = el('input'); listed.type = 'checkbox'; listed.checked = true; listed.setAttribute('aria-label', 'Listed');
306
+ add(form, add(el('div', 'line'), name, pol),
307
+ add(el('div', 'line'), el('span', 'dim', 'AI seats / guides'), n, add(el('label'), kids, el('span', '', 'kids (beginner)')), door),
308
+ add(el('div', 'line'), level, levelMax, speech, el('span', 'dim', 'rooms'), rooms, add(el('label'), listed, el('span', '', 'listed'))),
309
+ add(el('div', 'line'), btn('Create', 'small', function () {
310
+ var body = { game: g.id, name: name.value, policy: pol.value, door: door.value, level: Number(level.value), levelMax: Number(levelMax.value), speech: speech.value, rooms: Number(rooms.value), listed: listed.checked };
311
+ if (pol.value === 'hybrid') body.aiSeats = Number(n.value);
312
+ if (pol.value === 'beginner') { body.guides = Number(n.value); body.kids = kids.checked; }
313
+ act('/_studio/api/servers', body, (name.value || 'The server') + ' is open.').then(function (r) { if (r && r.ok) { S.open[key] = false; render(); } });
314
+ }), btn('Cancel', 'ghost small', function () { S.open[key] = false; render(); })));
315
+ wrap.appendChild(form);
316
+ return wrap;
317
+ }
318
+ function passes(g) {
319
+ var box = el('div', 'servers');
320
+ add(box, el('h3', '', 'Agent passes (an AI\'s way into a seat, always marked AI)'));
321
+ (g.passes || []).forEach(function (p) {
322
+ var row = el('div', 'inv' + (p.live ? '' : ' closed'));
323
+ add(row, el('code', '', p.name), el('span', 'faint', p.role + ' · hands ' + p.hands + (p.server ? ' · ' + p.server : ' · any server') + (p.expiresAt ? ' · until ' + new Date(p.expiresAt).toLocaleDateString() : '') + (p.revoked ? ' · revoked' : !p.live ? ' · ended' : '')));
324
+ if (p.live) row.appendChild(armed('Revoke', 'bad', function () { act('/_studio/api/agents/pass', { action: 'revoke', game: g.id, id: p.id }, 'Pass revoked: that AI left.'); }));
325
+ box.appendChild(row);
326
+ });
327
+ var label = el('input'); label.type = 'text'; label.placeholder = 'Its name (Claude)'; label.maxLength = 16;
328
+ var server = sel([['', 'any server that lets AI in']].concat((g.servers || []).filter(function (x) { return x.policy !== 'humans-only'; }).map(function (x) { return [x.id, x.name]; })), '', 'Server');
329
+ var hands = sel([['self', 'runs the game itself'], ['host', 'moved by the host\'s bots']], 'self', 'Hands');
330
+ var out = el('div');
331
+ add(box, add(el('div', 'newinv'), label, server, hands, btn('Issue a pass', 'small', function () {
332
+ post('/_studio/api/agents/pass', { action: 'create', game: g.id, label: label.value, server: server.value || null, hands: hands.value, days: 7 }).then(function (r) {
333
+ out.textContent = '';
334
+ if (!r || !r.ok) { toast((r && r.message) || 'No pass made.', true); return; }
335
+ add(out, el('p', 'dim', 'Shown once. Give it to the AI only; it plays as ' + r.pass.name + ' for 7 days.'), el('div', 'secret', r.secret));
336
+ label.value = ''; S.keep = true;
337
+ });
338
+ })), out);
339
+ var fill = el('input', 'switch'); fill.type = 'checkbox'; fill.disabled = true; fill.setAttribute('aria-label', 'Fill-a-spot service');
340
+ add(box, add(el('label', 'faint'), fill, el('span', '', (S.data.fillSpot && S.data.fillSpot.note) || 'Let a fill-a-spot service seat AI (coming later)')));
341
+ return box;
342
+ }
343
+ function serversBox(g) {
344
+ var box = el('div', 'servers');
345
+ add(box, el('h3', '', 'Servers'));
346
+ (g.servers || []).forEach(function (sv) { box.appendChild(serverRow(g, sv)); });
347
+ box.appendChild(newServer(g));
348
+ return box;
349
+ }
350
+
193
351
  function person(g, r, c, now) {
194
352
  var row = el('div', 'person');
195
353
  var who = el('div', 'who');
@@ -197,7 +355,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
197
355
  c.seat !== null && c.seat !== undefined ? el('span', 'faint', 'seat ' + (c.seat + 1)) : el('span', 'faint', c.waiting ? 'waiting for a seat' : 'watching'),
198
356
  el('span', 'chip', c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'big screen' : 'computer'),
199
357
  c.role === 'host' ? el('span', 'chip host', 'host') : null,
200
- c.as === 'owner' ? el('span', 'chip owner', 'you') : c.as === 'invited' ? el('span', 'chip invited', 'invited') : c.account ? el('span', 'chip owner', 'player ' + (c.account.handle || c.account.id)) : el('span', 'chip', 'guest'),
358
+ c.agent ? el('span', 'chip ai', 'AI ' + (c.agent.role === 'guide' ? 'guide' : c.agent.role === 'party' ? 'companion' : 'player')) : c.as === 'owner' ? el('span', 'chip owner', 'you') : c.as === 'invited' ? el('span', 'chip invited', 'invited') : c.account ? el('span', 'chip owner', 'player ' + (c.account.handle || c.account.id)) : el('span', 'chip', 'guest'),
201
359
  c.muted ? el('span', 'chip muted', 'muted') : null,
202
360
  el('span', 'faint', 'here ' + dur(now - c.joinedAt)),
203
361
  c.browser ? el('span', 'faint', 'browser ' + c.browser) : null);
@@ -215,7 +373,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
215
373
  var box = el('div', 'room');
216
374
  var key = g.id + '/' + r.room;
217
375
  var head = el('div', 'rhead');
218
- head.onclick = function (e) { if (e.target.closest('button')) return; S.open[key] = !S.open[key]; render(); };
376
+ head.onclick = function (e) { if (e.target.closest('button,select')) return; S.open[key] = !S.open[key]; render(); };
219
377
  var round = r.round ? 'Round ' + (r.round.n || '?') + (r.round.phase === 'live' && r.round.endsAt ? ' · ' + clock(r.round.endsAt - now) + ' left' : r.round.phase === 'over' ? ' · results' : '') : 'no round yet';
220
378
  var announce = btn('Announce', 'ghost small', function () {
221
379
  var text = prompt('Announce to everyone in ' + r.label + ' of ' + g.name + ':');
@@ -224,10 +382,14 @@ export const OFFICE_SCRIPT = String.raw`(function () {
224
382
  var acts = add(el('div', 'racts'), announce, r.closedUntil
225
383
  ? btn('Reopen', 'ghost small', function () { act('/_studio/api/close', { game: g.id, room: r.room, reopen: true }, r.label + ' is open again.'); })
226
384
  : armed('Close', 'bad', function () { act('/_studio/api/close', { game: g.id, room: r.room, minutes: hold() }, r.label + ' closed for ' + hold() + ' min.'); }));
385
+ // The room's dial (room_level): the owner sets it at once, as the party's vote does.
386
+ var lvl = r.policy ? sel([1, 2, 3, 4, 5].filter(function (n) { return n <= (r.policy.levelMax || 5); }).map(function (n) { return [n, LEVELS[n - 1]]; }), r.policy.level, 'AI level') : null;
387
+ if (lvl) lvl.onchange = function () { act('/_studio/api/room-level', { game: g.id, room: r.room, level: Number(lvl.value) }, 'AI in ' + r.label + ' set to ' + LEVELS[Number(lvl.value) - 1] + '.'); };
388
+ var srvName = ((g.servers || []).filter(function (x) { return x.id === r.server; })[0] || {}).name;
227
389
  add(head,
228
- add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + r.label), el('div', 'faint', r.public ? 'public room' : 'named room')),
229
- add(el('div', 'stat'), el('span', '', 'players'), document.createTextNode((r.players) + ' / ' + r.max)),
230
- add(el('div', 'stat'), el('span', '', 'bots'), document.createTextNode(String(r.bots))),
390
+ add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + r.label), el('div', 'faint', (r.server && r.server !== 'public' ? (srvName || r.server) + ' · ' : '') + (r.public ? 'public room' : r.server !== 'public' ? 'server room' : 'named room'))),
391
+ add(el('div', 'stat'), el('span', '', 'players'), document.createTextNode((r.players) + ' / ' + (r.humanSeats || r.max))),
392
+ add(el('div', 'stat'), el('span', '', 'AI · bots'), document.createTextNode((r.ai || 0) + ' · ' + String(r.bots)), lvl ? add(el('div'), lvl) : null),
231
393
  add(el('div', 'stat'), el('span', '', 'round'), document.createTextNode(round)),
232
394
  add(el('div', 'stat'), el('span', '', 'up'), document.createTextNode(r.closedUntil ? 'closed ' + dur(r.closedUntil - now) : dur(now - r.openedAt))),
233
395
  acts);
@@ -239,7 +401,8 @@ export const OFFICE_SCRIPT = String.raw`(function () {
239
401
  var people = el('div', 'people');
240
402
  (r.clients || []).forEach(function (c) { people.appendChild(person(g, r, c, now)); });
241
403
  var bots = (r.slots || []).filter(function (s) { return s.bot; });
242
- if (bots.length) people.appendChild(el('div', 'held', 'Bots: ' + bots.map(function (s) { return s.name; }).join(', ')));
404
+ if (bots.length) people.appendChild(el('div', 'held', 'AI and bots: ' + bots.map(function (s) { return s.name + (s.agent ? ' (AI ' + (s.agent.role === 'guide' ? 'guide' : 'seat') + ')' : ''); }).join(', ')));
405
+ if (r.vote && r.vote.open) people.appendChild(el('div', 'held', 'The party is voting on the AI\'s level: ' + r.vote.voters + ' of ' + r.vote.of_total + ' voted.'));
243
406
  (r.bans || []).forEach(function (b) { people.appendChild(el('div', 'held', 'Kicked: ' + (b.name || 'someone') + ', may come back in ' + dur(b.until - now) + (b.address ? ' (their network too)' : ''))); });
244
407
  box.appendChild(people);
245
408
  }
@@ -253,8 +416,8 @@ export const OFFICE_SCRIPT = String.raw`(function () {
253
416
  liveEl.textContent = d.playing ? d.playing + (d.playing === 1 ? ' playing now' : ' playing now') : 'nobody playing right now';
254
417
  document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
255
418
  var focus = document.activeElement && root.contains(document.activeElement) && /INPUT|SELECT/.test(document.activeElement.tagName);
256
- // Never redraw under a typing owner, or a control waiting for its second tap; the next poll will.
257
- if (focus || root.querySelector('[data-armed="1"]')) return;
419
+ // Never redraw under a typing owner, or a control waiting for its second tap, or a pass secret shown once.
420
+ if (focus || root.querySelector('[data-armed="1"]') || root.querySelector('.secret')) return;
258
421
  root.textContent = '';
259
422
  if (!d.games.length) { root.appendChild(el('p', 'empty', 'This studio has no games yet.')); return; }
260
423
  d.games.forEach(function (g) {
@@ -263,6 +426,10 @@ export const OFFICE_SCRIPT = String.raw`(function () {
263
426
  add(head, add(el('div', 'name'), el('h2', '', g.name), el('span', 'faint', g.playing ? g.playing + ' playing' : 'quiet')),
264
427
  add(el('div', 'links'), Object.assign(el('a', '', 'Play'), { href: g.play, target: '_blank', rel: 'noopener' }), Object.assign(el('a', '', 'Page'), { href: g.page, target: '_blank', rel: 'noopener' })));
265
428
  add(card, head, gameSettings(g));
429
+ // A build from before servers (netplay rev 5 or older): what it cannot do yet, and the fix.
430
+ if (g.build && g.build.predates && (g.servers || []).some(function (x) { return x.id !== 'public' && (x.aiSeats || x.guides); })) card.appendChild(el('div', 'warnbox', 'This build predates servers (netplay rev ' + (g.build.netplayRev || '5 or older') + '): reserved AI seats stay empty and its bots don\'t read the dial. Rebuild with @homie-rocks/studio 0.16.'));
431
+ card.appendChild(serversBox(g));
432
+ card.appendChild(passes(g));
266
433
  if (g.launch === 'invite' || (g.invites && g.invites.length)) card.appendChild(invites(g));
267
434
  var rooms = el('div', 'rooms');
268
435
  if (!g.rooms.length) rooms.appendChild(el('div', 'empty', g.launch === 'private' ? 'Private: only you can open it. Nobody is in a room.' : 'Nobody is in a room right now.'));
@@ -322,6 +489,7 @@ export async function officePage(cat, headers = {}) {
322
489
  <p class="dim" id="status" role="status"></p>
323
490
  <div id="games" aria-live="polite"><p class="empty">Reading the rooms…</p></div>
324
491
  <div class="toast" id="toast" role="status" hidden></div>
492
+ <p class="note">Servers: each game's named room pools. Open: anyone, AI with a pass marked AI. Humans only: no AI at all. Hybrid: some seats in every room are AI companions; the party votes their level. Beginner: new players, AI guides, quick lines only. Changing a server reaches its live rooms at once; AI leave after the round when a server becomes humans-only.</p>
325
493
  <p class="note">Live from this studio's own rooms, every few seconds. A kick holds that player out of that room for the time you pick (their browser, and their account once players sign in); a mute stops their chat and emotes; Close sends everyone in a room out with a thank-you. Private: only you can open the game. Invite-only: invited players only (each invite link or code lets a browser in). Public: anyone, and listed. A game that is not public leaves the Homie directory the next time it reads this studio. This page is yours alone, never cached or indexed.</p>`;
326
494
  return new Response(shell(cat, `Office · ${name}`, body, { script: OFFICE_SCRIPT }), {
327
495
  headers: {
@@ -341,7 +509,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
341
509
  return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
342
510
  }
343
511
 
344
- const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it' };
512
+ const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
345
513
 
346
514
  /** The owner's one tap for what their AI asked, and what came of it. */
347
515
  export function confirmPage(cat, ask, { missing = false } = {}) {
@@ -353,7 +521,7 @@ export function confirmPage(cat, ask, { missing = false } = {}) {
353
521
  const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
354
522
  body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
355
523
  <p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
356
- <form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
524
+ <form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
357
525
  <p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
358
526
  } else {
359
527
  const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;