@homie-rocks/studio 0.16.0 → 0.17.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.
@@ -6,6 +6,8 @@
6
6
  * else, connect only to this site). Everything a player typed is set as text, never as markup. Never cached,
7
7
  * never indexed, never framed.
8
8
  */
9
+ import { SKILLS } from './agents.mjs';
10
+ import { KIDS_LINE, POLICY_WORDS, SERVER_LIMITS } from './servers.mjs';
9
11
  import { esc } from './site.mjs';
10
12
 
11
13
  const PRIVATE = {
@@ -103,8 +105,54 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
103
105
  .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
106
  .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
107
  .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
+ /* The New server form: one panel, grouped, every control labelled, help under it in the office's quiet grey. */
109
+ .newsrv { display: grid; gap: 16px; max-width: 820px; margin-top: 10px; padding: 16px; border-radius: 14px; background: var(--panel2); border: 1px solid var(--line); }
110
+ .newsrv [hidden] { display: none !important; }
111
+ .newsrv h4 { margin: 0; font-size: 17px; letter-spacing: -.01em; }
112
+ .newsrv fieldset { border: 0; border-top: 1px solid var(--line); margin: 0; padding: 14px 0 0; min-width: 0; display: grid; gap: 10px; }
113
+ .newsrv legend { float: left; width: 100%; padding: 0 0 4px; font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); font-weight: 700; }
114
+ .newsrv legend + * { clear: both; }
115
+ .newsrv .frow { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 6px 14px; align-items: start; }
116
+ .newsrv .frow > label, .newsrv .frow > .lbl { padding-top: 9px; font-size: 14px; font-weight: 600; color: var(--ink); }
117
+ .newsrv .ctl { display: grid; gap: 5px; min-width: 0; }
118
+ .newsrv .help { margin: 0; color: var(--faint); font-size: 13px; line-height: 1.45; }
119
+ .newsrv .help b { color: var(--dim); font-weight: 600; }
120
+ .newsrv .note { margin: 0; padding: 10px 12px; border-radius: 12px; border: 1px dashed var(--line2); color: var(--dim); font-size: 14px; max-width: none; }
121
+ .newsrv input[type=text] { width: 100%; }
122
+ .policies { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
123
+ .policy { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; align-items: start; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--line2); background: var(--bg); cursor: pointer; }
124
+ .policy:hover { border-color: rgba(255,255,255,.3); }
125
+ .policy.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
126
+ .policy input { margin: 2px 0 0; width: 16px; height: 16px; min-height: 0; padding: 0; border: 0; background: none; accent-color: var(--accent); }
127
+ .policy b { font-size: 14px; letter-spacing: .04em; text-transform: uppercase; }
128
+ .policy span { grid-column: 2; color: var(--dim); font-size: 13px; line-height: 1.4; }
129
+ .stepper { display: inline-flex; align-items: center; gap: 6px; }
130
+ .stepper button { width: 38px; padding: 0; font-size: 18px; line-height: 1; }
131
+ .stepper input[type=number] { width: 64px; text-align: center; -moz-appearance: textfield; }
132
+ .stepper input::-webkit-outer-spin-button, .stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
133
+ .stepper .of { color: var(--dim); font-size: 14px; }
134
+ .seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--line2); border-radius: 999px; padding: 3px; gap: 2px; justify-self: start; }
135
+ .seg label { position: relative; cursor: pointer; }
136
+ .seg input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; min-height: 0; }
137
+ .seg span { display: inline-block; padding: 6px 14px; border-radius: 999px; font-size: 14px; font-weight: 600; color: var(--dim); }
138
+ .seg input:checked + span { background: var(--ink); color: var(--bg); }
139
+ .seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
140
+ .dial { display: grid; gap: 4px; max-width: 420px; }
141
+ .dial input[type=range] { width: 100%; min-height: 0; padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
142
+ .dial .ticks { display: grid; grid-template-columns: repeat(var(--n, 5), 1fr); font-size: 11px; color: var(--faint); }
143
+ .dial .ticks span:not(:first-child):not(:last-child) { text-align: center; }
144
+ .dial .ticks span:last-child { text-align: right; }
145
+ .dial output { font-size: 14px; color: var(--ink); }
146
+ .dial output b { color: var(--accent); }
147
+ .check { display: inline-flex; gap: 10px; align-items: center; font-weight: 600; font-size: 14px; cursor: pointer; }
148
+ .check input { width: 18px; height: 18px; min-height: 0; padding: 0; accent-color: var(--accent); }
149
+ .newsrv .acts { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; border-top: 1px solid var(--line); padding-top: 14px; }
150
+ @media (max-width: 720px) {
151
+ .newsrv { padding: 14px 12px; }
152
+ .newsrv .frow { grid-template-columns: 1fr; gap: 6px; }
153
+ .newsrv .frow > label, .newsrv .frow > .lbl { padding-top: 0; }
154
+ .policies { grid-template-columns: 1fr; }
155
+ }
108
156
  @media (max-width: 720px) { .srvhead { grid-template-columns: 1fr 1fr; } }
109
157
  @media (max-width: 720px) {
110
158
  .rhead { grid-template-columns: 1fr 1fr 1fr; }
@@ -116,7 +164,7 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
116
164
  /* The office's one script (allowed by its hash). It reads /_studio/api/office every 3 s while the page is visible. */
117
165
  export const OFFICE_SCRIPT = String.raw`(function () {
118
166
  'use strict';
119
- var S = { data: null, open: {}, busy: false, err: 0, timer: null };
167
+ var S = { data: null, open: {}, drafts: {}, busy: false, err: 0, timer: null };
120
168
  var root = document.getElementById('games');
121
169
  var liveEl = document.getElementById('live');
122
170
  var holdEl = document.getElementById('hold');
@@ -205,6 +253,12 @@ export const OFFICE_SCRIPT = String.raw`(function () {
205
253
 
206
254
  /* ---------------------------------------------------------------- servers and agent seats (0.16.0) */
207
255
  var POLICY = { open: 'Open', 'humans-only': 'Humans only', hybrid: 'Hybrid', beginner: 'Beginner' };
256
+ // Each policy's badge and line, the kids line and the dial's card names: the same words the site and the play page say.
257
+ var WORDS = ${JSON.stringify(POLICY_WORDS)};
258
+ var KIDS_LINE = ${JSON.stringify(KIDS_LINE)};
259
+ var CARDS = ${JSON.stringify(SKILLS.map((k) => k.card))};
260
+ var ROOMS_MAX = ${SERVER_LIMITS.roomsMax};
261
+ var BEGINNER_DAYS = ${SERVER_LIMITS.beginnerDays};
208
262
  var LEVELS = ['Rookie', 'Steady', 'Fair', 'Strong', 'Maxed'];
209
263
  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
264
  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; }
@@ -266,6 +320,14 @@ export const OFFICE_SCRIPT = String.raw`(function () {
266
320
  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
321
  act('/_studio/api/agents/brain', { game: g.id, server: sv.id, mode: brain.value });
268
322
  })));
323
+ // What today's brains used of the day's budget (the whole studio), and what is missing for the one chosen.
324
+ var bd = S.data.brain;
325
+ if (bd && (sv.brain === 'workers-ai' || sv.brain === 'owner-key')) {
326
+ var used = sv.brain === 'workers-ai'
327
+ ? 'Workers AI: ' + Math.round(bd.used.neurons).toLocaleString('en-US') + ' / ' + Math.round(bd.budget.neurons).toLocaleString('en-US') + ' neurons today' + (bd.ai ? '' : ' · not bound yet: deploy once more')
328
+ : 'Your key: $' + bd.used.usd.toFixed(2) + ' / $' + bd.budget.usd.toFixed(2) + ' today' + (bd.key ? '' : ' · no key yet: homie-studio agents brain key');
329
+ add(body, add(el('div', 'line'), el('span', 'faint', used + (g.vocab ? '' : ' · this game has no agents.json: its guides have no words'))));
330
+ }
269
331
  }
270
332
  if (sv.id !== 'public') {
271
333
  var mbox = el('div', 'line'); mbox.appendChild(el('span', 'dim', 'Members:'));
@@ -288,31 +350,209 @@ export const OFFICE_SCRIPT = String.raw`(function () {
288
350
  box.appendChild(body);
289
351
  return box;
290
352
  }
353
+ /** A server's address from its name, as the Worker makes it (servers.mjs checkServer). */
354
+ function serverId(name) { return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20).replace(/-+$/, ''); }
355
+ /** A number with − and + beside it, labelled; onset gets every change. */
356
+ function stepper(id, value, min, max, label, onset) {
357
+ var box = el('div', 'stepper');
358
+ var input = el('input'); input.type = 'number'; input.id = id; input.inputMode = 'numeric'; input.min = String(min); input.max = String(max); input.value = String(value);
359
+ var show = function (v) { var n = Math.max(Number(input.min), Math.min(Number(input.max), Math.round(Number(v)) || 0)); input.value = String(n); less.disabled = n <= Number(input.min); more.disabled = n >= Number(input.max); return n; };
360
+ var set = function (v) { onset(show(v)); };
361
+ var less = btn('−', 'ghost small', function () { set(Number(input.value) - 1); }); less.setAttribute('aria-label', 'Fewer ' + label);
362
+ var more = btn('+', 'ghost small', function () { set(Number(input.value) + 1); }); more.setAttribute('aria-label', 'More ' + label);
363
+ input.onchange = function () { set(input.value); };
364
+ less.disabled = value <= min; more.disabled = value >= max;
365
+ add(box, less, input, more);
366
+ // sync: the value and its bounds as the form now has them (no onset: update() is what calls it).
367
+ return { box: box, input: input, sync: function (v, lo, hi) { input.min = String(lo); input.max = String(hi); return show(v); } };
368
+ }
369
+ /** One choice of a few, as the office's pill switch; each option is a real radio button. */
370
+ function segment(name, options, value, label, onset) {
371
+ var box = el('div', 'seg'); box.setAttribute('role', 'radiogroup'); box.setAttribute('aria-label', label);
372
+ var inputs = {};
373
+ options.forEach(function (o) {
374
+ var r = el('input'); r.type = 'radio'; r.name = name; r.value = o[0]; r.checked = o[0] === value;
375
+ r.onchange = function () { if (r.checked) onset(o[0]); };
376
+ inputs[o[0]] = r;
377
+ box.appendChild(add(el('label'), r, el('span', '', o[1])));
378
+ });
379
+ return { box: box, inputs: inputs };
380
+ }
381
+ /** A 1-5 dial in the vote card's names, with what the chosen level plays like. */
382
+ function dialOf(id, value, label, onset) {
383
+ var box = el('div', 'dial');
384
+ var range = el('input'); range.type = 'range'; range.id = id; range.min = '1'; range.max = '5'; range.step = '1'; range.value = String(value);
385
+ var out = el('output'); out.setAttribute('for', id);
386
+ var ticks = el('div', 'ticks'); ticks.setAttribute('aria-hidden', 'true');
387
+ var show = function () {
388
+ var n = Number(range.value); var top = Number(range.max);
389
+ out.textContent = ''; add(out, el('b', '', LEVELS[n - 1]), document.createTextNode(': ' + CARDS[n - 1]));
390
+ range.setAttribute('aria-valuetext', LEVELS[n - 1]);
391
+ ticks.textContent = ''; ticks.style.setProperty('--n', String(top));
392
+ for (var i = 0; i < top; i++) ticks.appendChild(el('span', '', LEVELS[i]));
393
+ };
394
+ range.oninput = function () { show(); onset(Number(range.value)); };
395
+ range.setAttribute('aria-label', label);
396
+ show();
397
+ add(box, range, ticks, out);
398
+ return { box: box, range: range, set: function (n, top) { range.max = String(top); range.value = String(Math.min(n, top)); show(); } };
399
+ }
400
+ function row(label, forId, control, help) {
401
+ var r = el('div', 'frow');
402
+ var l = forId ? el('label', '', label) : el('span', 'lbl', label); if (forId) l.htmlFor = forId;
403
+ add(r, l, add(el('div', 'ctl'), control, help));
404
+ return r;
405
+ }
406
+ /**
407
+ * The New server form (DESIGN 7.2): name; policy, with each badge's copy; AI seats or guides (capped at the room's
408
+ * seats less one) and kids, for the policies that have them; the level it starts at and its ceiling, in the vote
409
+ * card's names; who can come in; chat; rooms; listed. Only what applies to the chosen policy shows. What the owner
410
+ * set lives in S.drafts, so the office's redraw every few seconds never loses it. It creates at once.
411
+ */
291
412
  function newServer(g) {
292
413
  var key = 'new:' + g.id;
293
414
  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(); })));
415
+ if (!S.open[key]) { wrap.appendChild(btn('+ New server', 'small', function () { S.open[key] = true; S.focusNew = key; render(); })); return wrap; }
416
+ var seats = Math.max(2, Number(g.maxPlayers) || Number(g.seats) || 2);
417
+ var cap = seats - 1;
418
+ var d = S.drafts[key] || (S.drafts[key] = { name: '', policy: 'hybrid', aiSeats: Math.min(2, cap), guides: Math.min(2, cap), kids: false, door: 'open', level: 3, levelMax: 5, levelSet: false, speech: 'game', rooms: 4, listed: true });
419
+ var id = 'ns-' + g.id + '-';
420
+ var form = el('form', 'newsrv'); form.noValidate = true; form.setAttribute('aria-label', 'New server for ' + g.name);
421
+ add(form, el('h4', '', 'New server for ' + g.name));
422
+
423
+ // Name, and the address it makes.
424
+ var name = el('input'); name.type = 'text'; name.id = id + 'name'; name.placeholder = 'Night Shift'; name.maxLength = 40; name.value = d.name; name.autocomplete = 'off';
425
+ var nameHelp = el('p', 'help');
426
+ add(form, row('Name', name.id, name, nameHelp));
427
+
428
+ // Policy: four cards, each with its badge and its line.
429
+ var pf = add(el('fieldset'), el('legend', '', 'Who plays'));
430
+ var cards = el('div', 'policies'); cards.setAttribute('role', 'radiogroup'); cards.setAttribute('aria-label', 'Policy');
431
+ var pol = {};
432
+ ['hybrid', 'beginner', 'humans-only', 'open'].forEach(function (p) {
433
+ var r = el('input'); r.type = 'radio'; r.name = id + 'policy'; r.value = p; r.checked = d.policy === p;
434
+ var badge = el('b'); var line = el('span');
435
+ var card = add(el('label', 'policy'), r, badge, line);
436
+ r.onchange = function () { if (!r.checked) return; d.policy = p; if (!d.levelSet) d.level = p === 'beginner' ? 2 : 3; update(); };
437
+ pol[p] = { input: r, card: card, badge: badge, line: line };
438
+ cards.appendChild(card);
439
+ });
440
+ add(form, add(pf, cards));
441
+
442
+ // AI in every room: seats (hybrid) or guides and kids (beginner); a plain note for the other two.
443
+ var af = add(el('fieldset'), el('legend', '', 'AI in every room'));
444
+ var aiHelp = el('p', 'help');
445
+ var ai = stepper(id + 'ai', d.policy === 'beginner' ? d.guides : d.aiSeats, d.policy === 'hybrid' ? 1 : 0, cap, 'AI seats', function (n) { if (d.policy === 'beginner') d.guides = n; else d.aiSeats = n; update(); });
446
+ var aiLabel = el('label', '', 'AI seats'); aiLabel.htmlFor = id + 'ai';
447
+ var aiRow = add(el('div', 'frow'), aiLabel, add(el('div', 'ctl'), ai.box, aiHelp));
448
+ var kids = el('input'); kids.type = 'checkbox'; kids.id = id + 'kids'; kids.checked = d.kids;
449
+ kids.onchange = function () { d.kids = kids.checked; update(); };
450
+ var kidsRow = row('Kids', null, add(el('label', 'check'), kids, el('span', '', 'A server for kids')), el('p', 'help', KIDS_LINE + ' The AI plays at Fair or gentler, and chat stays quick lines only.'));
451
+ var aiNote = el('p', 'note');
452
+ add(form, add(af, aiRow, kidsRow, aiNote));
453
+
454
+ // The AI's level: where it starts and how high the party may vote it.
455
+ var lf = add(el('fieldset'), el('legend', '', 'AI level'));
456
+ var lvl = dialOf(id + 'level', d.level, 'Starts at', function (n) { d.level = n; d.levelSet = true; if (levels().max < n) d.levelMax = n; update(); });
457
+ var top = dialOf(id + 'ceiling', d.levelMax, 'Ceiling', function (n) { d.levelMax = n; if (d.level > n) { d.level = n; d.levelSet = true; } update(); });
458
+ /** The level and ceiling as the server gets them: a kids server keeps both at Fair or gentler. */
459
+ function levels() { var cap = d.policy === 'beginner' && d.kids ? 3 : 5; var max = Math.min(d.levelMax, cap); return { cap: cap, max: max, level: Math.min(d.level, max) }; }
460
+ add(form, add(lf,
461
+ row('Starts at', id + 'level', lvl.box, null),
462
+ row('Ceiling', id + 'ceiling', top.box, el('p', 'help', 'Before play the party votes how strong the AI should be (the middle vote wins), never above the ceiling. Only games whose bots read the dial change how they play.'))));
463
+
464
+ // Who can come in: the door, how many rooms, and whether it is listed.
465
+ var df = add(el('fieldset'), el('legend', '', 'Who can come in'));
466
+ var DOOR_HELP = { open: 'Anyone with the link plays.', accounts: 'Players sign in with a passkey first.', invite: 'Only people with an invite to this server. Ask your AI for invite links.' };
467
+ var doorHelp = el('p', 'help');
468
+ var door = segment(id + 'door', [['open', 'Anyone'], ['accounts', 'Accounts'], ['invite', 'Invite only']], d.door, 'Door', function (v) { d.door = v; update(); });
469
+ var rooms = stepper(id + 'rooms', d.rooms, 1, ROOMS_MAX, 'rooms', function (n) { d.rooms = n; update(); });
470
+ var roomsHelp = el('p', 'help');
471
+ var listed = el('input'); listed.type = 'checkbox'; listed.id = id + 'listed'; listed.checked = d.listed;
472
+ listed.onchange = function () { d.listed = listed.checked; update(); };
473
+ var listedHelp = el('p', 'help');
474
+ add(form, add(df,
475
+ row('Door', null, door.box, doorHelp),
476
+ row('Rooms', id + 'rooms', rooms.box, roomsHelp),
477
+ row('Listed', null, add(el('label', 'check'), listed, el('span', '', 'Show it to everyone')), listedHelp)));
478
+
479
+ // Chat.
480
+ var cf = add(el('fieldset'), el('legend', '', 'Chat'));
481
+ var CHAT_HELP = { game: 'Players chat the way the game lets them.', lines: 'Only the game\'s quick lines: nobody types.', off: 'No chat at all.' };
482
+ var chatHelp = el('p', 'help');
483
+ var chat = segment(id + 'chat', [['game', 'The game\'s chat'], ['lines', 'Quick lines only'], ['off', 'Off']], d.speech, 'Chat', function (v) { d.speech = v; update(); });
484
+ add(form, add(cf, row('Chat', null, chat.box, chatHelp)));
485
+
486
+ var create = btn('Create', '', function () {});
487
+ create.type = 'submit';
488
+ var close = function () { S.open[key] = false; if (document.activeElement) document.activeElement.blur(); render(); };
489
+ add(form, add(el('div', 'acts'), create, btn('Cancel', 'ghost', close), el('span', 'faint', 'It opens at once; you can change any of it later.')));
490
+ name.oninput = function () { d.name = name.value; update(); };
491
+
492
+ function update() {
493
+ var p = d.policy;
494
+ var n = p === 'beginner' ? d.guides : d.aiSeats;
495
+ // Each card says its own badge and line; Hybrid's with the seats chosen.
496
+ Object.keys(pol).forEach(function (k) {
497
+ var w = WORDS[k];
498
+ pol[k].badge.textContent = w.badge + (k === 'hybrid' ? ' · ' + d.aiSeats : '');
499
+ var line = w.line.replace('{n}', String(d.aiSeats)).replace(/^[^:]+: /, '');
500
+ pol[k].line.textContent = line.charAt(0).toUpperCase() + line.slice(1) + (k === 'beginner' && d.kids ? ' ' + KIDS_LINE : '');
501
+ pol[k].card.classList.toggle('on', k === p);
502
+ });
503
+ // The name and the address it makes.
504
+ var sid = serverId(d.name);
505
+ var nameOk = /[A-Za-z0-9À-￿]/.test(d.name) && sid.length >= 2 && sid !== 'public';
506
+ nameHelp.textContent = '';
507
+ if (!d.name.trim()) nameHelp.textContent = 'Players see it on ' + g.name + '\'s page and on the play page.';
508
+ else if (!nameOk) nameHelp.textContent = 'Use at least two letters or digits (and not "public").';
509
+ else add(nameHelp, document.createTextNode('Its page: '), el('b', '', '/' + g.id + '/s/' + sid + '/'));
510
+ // AI in every room.
511
+ n = ai.sync(n, p === 'hybrid' ? 1 : 0, cap);
512
+ if (p === 'beginner') d.guides = n; else if (p === 'hybrid') d.aiSeats = n;
513
+ aiLabel.textContent = p === 'beginner' ? 'AI guides' : 'AI seats';
514
+ aiRow.hidden = !(p === 'hybrid' || p === 'beginner');
515
+ kidsRow.hidden = p !== 'beginner';
516
+ aiHelp.textContent = p === 'hybrid'
517
+ ? n + ' of the ' + seats + ' seats in every room ' + (n === 1 ? 'is an AI companion' : 'are AI companions') + ', always marked AI; people take the other ' + (seats - n) + '. At least one seat is always a person\'s.'
518
+ : n + ' guide seat' + (n === 1 ? '' : 's') + ' in every room, marked AI, beside new players. The game\'s own bots play them; guides talk only once you say yes.';
519
+ aiNote.hidden = !(p === 'humans-only' || p === 'open');
520
+ aiNote.textContent = p === 'humans-only'
521
+ ? 'No AI can join, not even one with a pass. The game\'s practice bots are off; you can switch them on later.'
522
+ : 'No seats are kept for AI. An AI with an agent pass may sit, always marked AI, and the game\'s bots fill empty seats.';
523
+ // The level: no AI on a humans-only server; kids keep it at Fair or gentler.
524
+ lf.hidden = p === 'humans-only';
525
+ var L = levels();
526
+ lvl.set(L.level, L.cap); top.set(L.max, L.cap);
527
+ // The door.
528
+ doorHelp.textContent = DOOR_HELP[d.door] + (p === 'beginner' ? ' New players only: an account older than ' + BEGINNER_DAYS + ' days is shown another server, unless you made that player a mentor.' : '');
529
+ d.rooms = rooms.sync(d.rooms, 1, ROOMS_MAX);
530
+ roomsHelp.textContent = 'Up to ' + d.rooms + (d.rooms === 1 ? ' room' : ' rooms') + ' at once. When ' + (d.rooms === 1 ? 'it is' : 'they are all') + ' full, newcomers wait for a seat.';
531
+ listedHelp.textContent = d.listed ? 'On ' + g.name + '\'s page, its servers list and the room lists.' : 'Left out of the lists: only people with its link find it.';
532
+ // Chat: a beginner server keeps chat to quick lines (or off).
533
+ var chatNow = p === 'beginner' && d.speech === 'game' ? 'lines' : d.speech;
534
+ chat.inputs.game.parentNode.hidden = p === 'beginner';
535
+ Object.keys(chat.inputs).forEach(function (k) { chat.inputs[k].checked = k === chatNow; });
536
+ chatHelp.textContent = CHAT_HELP[chatNow] + (p === 'beginner' ? ' A beginner server keeps chat to quick lines.' : '');
537
+ create.disabled = !nameOk;
538
+ }
539
+ form.onsubmit = function (e) {
540
+ e.preventDefault();
541
+ update();
542
+ if (create.disabled) { name.focus(); return; }
543
+ var p = d.policy;
544
+ var L = levels();
545
+ var body = { game: g.id, name: d.name, policy: p, door: d.door, level: L.level, levelMax: L.max, speech: p === 'beginner' && d.speech === 'game' ? 'lines' : d.speech, rooms: d.rooms, listed: d.listed };
546
+ if (p === 'hybrid') body.aiSeats = d.aiSeats;
547
+ if (p === 'beginner') { body.guides = d.guides; body.kids = d.kids; }
548
+ create.disabled = true;
549
+ act('/_studio/api/servers', body, d.name + ' is open.').then(function (r) {
550
+ if (r && r.ok) { delete S.drafts[key]; close(); } else create.disabled = false;
551
+ });
552
+ };
553
+ update();
315
554
  wrap.appendChild(form);
555
+ if (S.focusNew === key) { S.focusNew = null; setTimeout(function () { name.focus(); }, 0); }
316
556
  return wrap;
317
557
  }
318
558
  function passes(g) {
@@ -403,6 +643,11 @@ export const OFFICE_SCRIPT = String.raw`(function () {
403
643
  var bots = (r.slots || []).filter(function (s) { return s.bot; });
404
644
  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
645
  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.'));
646
+ // The house guides' brains: which one runs (and why not), and their last few decisions (ids only, never words).
647
+ if (r.brains) {
648
+ var last = (r.brains.decisions || []).filter(function (x) { return x.goal || x.say; }).slice(-4).reverse().map(function (x) { return 'seat ' + (x.seat + 1) + ': ' + (x.goal ? x.goal + (x.args && Object.keys(x.args).length ? ' ' + Object.keys(x.args).map(function (k) { return x.args[k]; }).join(' ') : '') : 'no change') + (x.say ? ', said ' + x.say : '') + ' (' + x.provider + (x.ms ? ', ' + (x.ms / 1000).toFixed(1) + ' s' : '') + ', ' + dur(now - x.at) + ' ago)'; });
649
+ people.appendChild(el('div', 'held', 'Guides\' brain: ' + (r.brains.brain || 'script') + (r.brains.why ? ' · ' + r.brains.why : '') + ' · ' + r.brains.calls + ' calls' + (last.length ? ' · ' + last.join('; ') : '')));
650
+ }
406
651
  (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)' : ''))); });
407
652
  box.appendChild(people);
408
653
  }
@@ -415,8 +660,10 @@ export const OFFICE_SCRIPT = String.raw`(function () {
415
660
  var now = Date.now() - (S.skew || 0);
416
661
  liveEl.textContent = d.playing ? d.playing + (d.playing === 1 ? ' playing now' : ' playing now') : 'nobody playing right now';
417
662
  document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
418
- var focus = document.activeElement && root.contains(document.activeElement) && /INPUT|SELECT/.test(document.activeElement.tagName);
419
- // Never redraw under a typing owner, or a control waiting for its second tap, or a pass secret shown once.
663
+ var at = document.activeElement;
664
+ var focus = at && root.contains(at) && (/INPUT|SELECT/.test(at.tagName) || Boolean(at.closest && at.closest('.newsrv')));
665
+ // Never redraw under a typing owner (or one working in the New server form), a control waiting for its second
666
+ // tap, or a pass secret shown once.
420
667
  if (focus || root.querySelector('[data-armed="1"]') || root.querySelector('.secret')) return;
421
668
  root.textContent = '';
422
669
  if (!d.games.length) { root.appendChild(el('p', 'empty', 'This studio has no games yet.')); return; }
package/worker/office.mjs CHANGED
@@ -45,8 +45,9 @@
45
45
  */
46
46
  import { SEAT_MAX, seatsOf } from './seats.mjs';
47
47
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
48
- import { aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
49
- import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession } from './stats.mjs';
48
+ import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
49
+ import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
50
+ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
50
51
  import { esc, layout, notFoundPage } from './site.mjs';
51
52
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
52
53
  import { licenseOf } from './license.mjs';
@@ -455,9 +456,35 @@ async function roomRow(env, meta, room, max) {
455
456
  host: f.host ?? null, announce: f.announce ?? null,
456
457
  slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
457
458
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
459
+ // The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
460
+ ...(f.brains && typeof f.brains === 'object' ? { brains: {
461
+ brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
462
+ guides: (Array.isArray(f.brains.guides) ? f.brains.guides : []).slice(0, 8).map((g) => ({ name: oneLine(g.name, 40), seat: g.seat ?? null, provider: g.provider ?? null })),
463
+ decisions: (Array.isArray(f.brains.decisions) ? f.brains.decisions : []).slice(-50),
464
+ } } : {}),
458
465
  };
459
466
  }
460
467
 
468
+ /** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
469
+ export async function brainDay(env) {
470
+ const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
471
+ const used = { neurons: 0, usd: 0, calls: 0 };
472
+ try {
473
+ const b = JSON.parse((await env.DB.prepare("SELECT value FROM meta WHERE key = 'brain_budget'").first())?.value ?? 'null');
474
+ if (b && Number.isFinite(Number(b.neurons))) budget.neurons = Number(b.neurons);
475
+ if (b && Number.isFinite(Number(b.usd))) budget.usd = Number(b.usd);
476
+ } catch { /* the defaults */ }
477
+ try {
478
+ const { results } = await env.DB.prepare("SELECT metric, SUM(n) AS n FROM stats_daily WHERE day = ?1 AND metric IN ('brain-calls', 'brain-neurons', 'brain-microdollars') GROUP BY metric").bind(today()).all();
479
+ for (const r of results ?? []) {
480
+ if (r.metric === 'brain-calls') used.calls = Number(r.n) || 0;
481
+ if (r.metric === 'brain-neurons') used.neurons = Number(r.n) || 0;
482
+ if (r.metric === 'brain-microdollars') used.usd = Math.round(Number(r.n) || 0) / 1e6;
483
+ }
484
+ } catch { /* nothing counted yet */ }
485
+ return { budget, used, ai: Boolean(env.AI), key: Boolean(env.HOMIE_BRAIN_KEY), model: env.HOMIE_BRAIN_MODEL || DEFAULT_MODEL, ownerModel: OWNER_MODEL };
486
+ }
487
+
461
488
  async function invitesOf(env, game, origin) {
462
489
  try {
463
490
  const { results } = await env.DB.prepare('SELECT id, code, label, uses, max_uses, expires_at, revoked, created_at, server FROM office_invites WHERE game = ?1 ORDER BY created_at DESC LIMIT 100').bind(game).all();
@@ -500,7 +527,9 @@ export async function officeView(env, cat, origin) {
500
527
  id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
501
528
  // Servers (0.16.0): each with its policy and what is on it now; its build reads the dial only from netplay rev 6.
502
529
  servers: servers.map((sv) => ({ ...serverView(sv, { origin, game: g.id }), live: byServer[sv.id] ?? { rooms: 0, players: 0, ai: 0 }, members: members[sv.id] ?? 0 })),
503
- build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null },
530
+ build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null, guides: Number(g.netplayRev) >= 7 },
531
+ // 0.17.0: the game's AI guides have words of their own (agents.json): without it they play but never talk.
532
+ vocab: g.vocab === true,
504
533
  passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
505
534
  remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
506
535
  seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
@@ -515,6 +544,7 @@ export async function officeView(env, cat, origin) {
515
544
  // The fill-a-spot service (an AI seated in an empty spot for a price) is a money decision: not offered yet.
516
545
  fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
517
546
  agentsTalk: await talkConsented(env),
547
+ brain: await brainDay(env),
518
548
  };
519
549
  }
520
550
  async function memberCountsOf(env, game) {
@@ -624,7 +654,10 @@ function checkAction(cat, op, body) {
624
654
  if (!meta) return bad('game is one of this studio\'s game ids');
625
655
  if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
626
656
  if (!['off', 'script', 'workers-ai', 'owner-key'].includes(body.mode)) return bad('mode is off, script, workers-ai or owner-key');
627
- return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode } };
657
+ // The day's budget for the studio's brains: Workers AI neurons (workers-ai) or dollars of the owner's key (owner-key).
658
+ const budget = body.budget === undefined || body.budget === null ? undefined : Number(body.budget);
659
+ if (budget !== undefined && (!['workers-ai', 'owner-key'].includes(body.mode) || !(budget >= 0) || budget > (body.mode === 'owner-key' ? 100 : 1e7))) return bad('budget goes with workers-ai (neurons a day; the free allocation is 10,000 an account) or owner-key (dollars a day, at most 100)');
660
+ return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
628
661
  }
629
662
  default:
630
663
  return bad('unknown control');
@@ -670,7 +703,7 @@ export function describe(cat, a) {
670
703
  case 'pass': return a.action === 'revoke' ? `Revoke the agent pass ${a.id}: that AI leaves every room and cannot sit again.` : `Issue an agent pass for an AI called "${a.label} · AI".`;
671
704
  case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
672
705
  case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
673
- ? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? 'this studio\'s own Workers AI (free allowance)' : 'your own AI provider key (your money, capped daily)'}.`
706
+ ? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
674
707
  : `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
675
708
  default: return 'A control.';
676
709
  }
@@ -686,7 +719,12 @@ export async function needsAsk(env, cat, a) {
686
719
  const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
687
720
  return narrows(before, a.fields);
688
721
  }
689
- if (a.op === 'agents-brain') return ['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env));
722
+ if (a.op === 'agents-brain') {
723
+ if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
724
+ // Raising the cap on the owner's own money is the owner's to confirm; Workers AI's free allocation is not money.
725
+ if (a.mode === 'owner-key' && a.budget !== undefined) return a.budget > (await brainDay(env)).budget.usd;
726
+ return false;
727
+ }
690
728
  return false;
691
729
  }
692
730
 
@@ -765,7 +803,7 @@ export async function perform(env, cat, a) {
765
803
  try { await writeServer(env, meta.id, sv); } catch (error) { return { ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
766
804
  const view = serverView(sv, { origin: a.origin ?? '', game: meta.id });
767
805
  const notes = [];
768
- if (sv.policy === 'beginner' && sv.guides) notes.push(`${sv.guides} AI guide seat${sv.guides === 1 ? '' : 's'} in every room, marked AI: the game's own bots play them now; guides talk once AI talk arrives (a later version, and only with the owner's yes).`);
806
+ if (sv.policy === 'beginner' && sv.guides) notes.push(`${sv.guides} AI guide seat${sv.guides === 1 ? '' : 's'} in every room, marked AI: they play from the game's script, silent. To let them talk (only the game's own agents.json lines), turn on a brain: agents brain ${meta.id} ${sv.id} workers-ai (the first time asks the owner).${meta.vocab ? '' : ` ${meta.name} has no agents.json yet: write one first.`}`);
769
807
  if (sv.policy === 'hybrid') notes.push(`${sv.aiSeats} seat${sv.aiSeats === 1 ? '' : 's'} in every room are AI companions, marked AI; the party votes their level.`);
770
808
  if (sv.policy === 'humans-only') notes.push('No AI can join; the game\'s practice bots are off unless the owner turns them on (bots: fill).');
771
809
  if (!(Number(meta.netplayRev) >= 6)) notes.push(`${meta.name}'s build predates servers (netplay revision ${meta.netplayRev ?? '5 or older'}): reserved AI seats stay empty and its bots do not read the dial until it is rebuilt with @homie-rocks/studio 0.16.`);
@@ -839,8 +877,24 @@ export async function perform(env, cat, a) {
839
877
  await writeServer(env, meta.id, sv);
840
878
  if (['workers-ai', 'owner-key'].includes(a.mode)) await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('agents_talk_ok', ?1)").bind(String(Date.now())).run();
841
879
  } catch (error) { return { ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
880
+ if (a.budget !== undefined) {
881
+ const day = await brainDay(env);
882
+ const next = { neurons: day.budget.neurons, usd: day.budget.usd, ...(a.mode === 'workers-ai' ? { neurons: Math.round(a.budget) } : { usd: Math.round(a.budget * 100) / 100 }) };
883
+ await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run().catch(() => {});
884
+ }
842
885
  await pushPolicy(env, meta, settings, sv);
843
- return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, note: ['workers-ai', 'owner-key'].includes(a.mode) ? 'Saved. AI guides\' talk arrives with @homie-rocks/studio 0.17; until then they play as the game\'s bots, silent.' : 'Saved.' };
886
+ const day = await brainDay(env);
887
+ const notes = [];
888
+ if (!meta.vocab && ['workers-ai', 'owner-key'].includes(a.mode)) notes.push(`${meta.name} has no agents.json yet: its guides play but have no words. Write one (the game skill's "Write the guide vocabulary"), build and deploy.`);
889
+ if (a.mode === 'workers-ai') notes.push(!env.AI ? 'This Worker has no Workers AI binding yet: run `npm run deploy` once more (it binds Workers AI when a server uses it). Until then the guides answer from the game\'s script.'
890
+ : !(day.budget.neurons > 0) ? 'The day\'s Workers AI budget is 0: the guides answer from the game\'s script (set --budget to let them think).'
891
+ : `The guides think with Workers AI (${day.model}) from their next decision: at most ${day.budget.neurons.toLocaleString('en-US')} neurons a day for the whole studio (${Math.round(day.used.neurons).toLocaleString('en-US')} used today), then the game's script until 00:00 UTC.`);
892
+ if (a.mode === 'owner-key') notes.push(!env.HOMIE_BRAIN_KEY ? 'Set your key on your own computer: `npx --no-install homie-studio agents brain key` opens a page there (the key goes straight to the Worker, never through a chat). Until then the guides answer from the game\'s script.'
893
+ : !(day.budget.usd > 0) ? 'The day\'s dollar cap is 0: the guides answer from the game\'s script.'
894
+ : `The guides think with your key (claude-haiku-4-5) from their next decision, at most $${day.budget.usd.toFixed(2)} a day ($${day.used.usd.toFixed(2)} used today), then the game's script until 00:00 UTC.`);
895
+ if (a.mode === 'script') notes.push('The guides play from the game\'s script, silent.');
896
+ if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
897
+ return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
844
898
  }
845
899
  default: return { ok: false, error: 'op' };
846
900
  }
package/worker/pages.mjs CHANGED
@@ -113,6 +113,10 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
113
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; }
114
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; }
115
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; }
116
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; }
117
121
  .pill svg { width: 15px; height: 15px; flex: none; }
118
122
  .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
@@ -164,8 +168,8 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
164
168
  const first = places.desk;
165
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>` : '';
166
170
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
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>
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>
169
173
  <div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
170
174
  <div class="code"><b data-server-name></b><span data-server-badge></span></div>
171
175
  <p class="sline" data-server-line></p>
@@ -197,20 +201,27 @@ ${joinCard}${share}
197
201
  }
198
202
 
199
203
  /*
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
+ * 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.
204
209
  */
205
210
  const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--hot); flex: none; }
206
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
+ }
207
218
  .ssheet .sline { margin: 0 2px 10px; color: #c3cad9; font-weight: 500; line-height: 1.4; }
208
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; }
209
220
  .ssheet .srow small { display: block; color: #8b93a7; font-weight: 500; font-size: 11px; }
210
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; }
211
222
  .ssheet .srow input { width: 20px; height: 20px; accent-color: var(--hot); }
212
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; }
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; }
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; }
214
225
  @keyframes rise { from { opacity: 0; transform: translate(-50%, -46%); } }
215
226
  .vote .vh { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 2px 12px; }
216
227
  .vote .vh b { font-size: 18px; letter-spacing: -.01em; }
@@ -308,9 +319,16 @@ const SHELL_JS = String.raw`(function () {
308
319
  }
309
320
  place();
310
321
  addEventListener('resize', place);
311
- 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
+ }
312
330
  function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
313
- // The room pill and the server pill fade to dots after 6 s, so neither sits on the game's own HUD.
331
+ // The room pill and the server pill (in one band, at the room button's place) fade to dots after 6 s.
314
332
  function wake() {
315
333
  if (!toggle) return;
316
334
  var sp = document.querySelector('[data-server-toggle]');
@@ -549,7 +567,7 @@ const SHELL_JS = String.raw`(function () {
549
567
  var label = name + ' · ' + words.badge + (state.server && state.server.mentor ? ' · Mentor' : '');
550
568
  var labelEl = sToggle.querySelector('[data-server-label]');
551
569
  // 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(); }
570
+ if (labelEl.textContent !== label) { labelEl.textContent = label; sToggle.setAttribute('aria-label', label + ': this server'); wake(); }
553
571
  sSheet.querySelector('[data-server-name]').textContent = name;
554
572
  sSheet.querySelector('[data-server-badge]').textContent = words.badge;
555
573
  sSheet.querySelector('[data-server-line]').textContent = words.line;