@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.
- package/README.md +11 -4
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +71 -8
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +23 -0
- package/lib/cloudflare.mjs +28 -0
- package/lib/mcp-tools.mjs +56 -0
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +14 -1
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +7 -1
- package/site/SITE.md +16 -4
- package/starters/ember-vale/agents.json +34 -0
- package/starters/ember-vale/index.html +8 -0
- package/starters/ember-vale/src/main.ts +252 -5
- package/starters/gem-rush/src/main.ts +4 -1
- package/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +115 -7
- package/worker/office-page.mjs +273 -26
- package/worker/office.mjs +62 -8
- package/worker/pages.mjs +28 -10
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
package/worker/office-page.mjs
CHANGED
|
@@ -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
|
-
|
|
107
|
-
.newsrv
|
|
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
|
|
296
|
-
var
|
|
297
|
-
var
|
|
298
|
-
var
|
|
299
|
-
var
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
var
|
|
304
|
-
var
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
419
|
-
|
|
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 {
|
|
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
|
-
|
|
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' ?
|
|
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')
|
|
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
|
|
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
|
-
|
|
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
|
|
201
|
-
* badge; its sheet has the policy's line, the AI's level with
|
|
202
|
-
*
|
|
203
|
-
* screen except the card, for a few seconds, when a
|
|
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:
|
|
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) {
|
|
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
|
|
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;
|