@homie-rocks/studio 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/office.mjs CHANGED
@@ -36,8 +36,16 @@
36
36
  * device; `get(id, env)` names them in the office; `isOwner(request, env)` lets the owner's own account (a passkey,
37
37
  * `homie-studio players owner`) count as the owner everywhere the owner's session does. A player with no account is
38
38
  * a guest with a handle.
39
+ *
40
+ * SERVERS AND AGENT SEATS (0.16.0, worker/servers.mjs and worker/agents.mjs): each game's servers (create at once;
41
+ * a change that narrows who may come in, closing one, or removing a member is an ASK from an office key), agent
42
+ * passes (issued and revoked at once; the secret is shown once), a room's dial (room_level), and whether AI guides
43
+ * may talk (the first time is an ASK: the owner's consent). A server change reaches its live rooms as a signed
44
+ * `policy` control; AI leave after the round when a server becomes humans-only.
39
45
  */
40
46
  import { SEAT_MAX, seatsOf } from './seats.mjs';
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';
41
49
  import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession } from './stats.mjs';
42
50
  import { esc, layout, notFoundPage } from './site.mjs';
43
51
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
@@ -146,7 +154,7 @@ export async function isOwner(request, env) {
146
154
 
147
155
  const settingsCache = new WeakMap();
148
156
 
149
- /** Every game's live settings (D1 office_games), read at most every 5 s per Worker instance. Empty before migration 0003. */
157
+ /** Every game's live settings (D1 office_games), read at most every 5 s per Worker instance. Empty before migration 0005. */
150
158
  export async function settingsOf(env, { fresh = false } = {}) {
151
159
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
152
160
  const hit = settingsCache.get(env.DB);
@@ -211,10 +219,11 @@ async function officeKey(env) {
211
219
  async function mac(key, text) { return b64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))); }
212
220
 
213
221
  /**
214
- * Who a ticket names: `o` the owner, `i-<invite>` an invited browser, `p-<player>` a player account, or two of them
215
- * joined with `~` (an invited player who is signed in: `i-…~p-…`, so a kick holds the account too).
222
+ * Who a ticket names: `o` the owner, `i-<invite>` an invited browser, `p-<player>` a player account, `a-<pass>` an
223
+ * AI with an agent pass (0.16.0), or two of them joined with `~` (an invited player who is signed in: `i-…~p-…`, so
224
+ * a kick holds the account too).
216
225
  */
217
- const PART = '(?:o|i-[a-f0-9]{10}|p-[A-Za-z0-9_-]{1,64})';
226
+ const PART = '(?:o|i-[a-f0-9]{10}|p-[A-Za-z0-9_-]{1,64}|a-[a-f0-9]{10})';
218
227
  const SUB = new RegExp(`^${PART}(?:~${PART})?$`);
219
228
  const TICKET = new RegExp(`^([a-z0-9]{6,12})\\.(${PART}(?:~${PART})?)\\.([A-Za-z0-9_-]{32})$`);
220
229
  /** A ticket's holders, as parts. */
@@ -310,11 +319,16 @@ export async function accessOf(request, env, game, launch) {
310
319
  return { ok: false, sub: null, owner: false };
311
320
  }
312
321
 
313
- /** Whether a ticket's holder may still play now (the state may have changed, or the invite been revoked, since it was minted). */
322
+ /**
323
+ * Whether a ticket's holder may still play now (the state may have changed, or the invite been revoked, since it was
324
+ * minted). An agent's own pass opens a game that is not public only when it is the owner's (only the owner mints one).
325
+ */
314
326
  export async function ticketAllows(env, sub, launch) {
315
327
  if (launch === 'public') return true;
316
328
  const parts = holders(sub);
317
329
  if (parts.includes('o')) return true;
330
+ const agent = parts.find((p) => p.startsWith('a-'));
331
+ if (agent) { const pass = await passById(env, agent.slice(2)); return Boolean(pass?.live && pass.kind === 'owner'); }
318
332
  const invite = parts.find((p) => p.startsWith('i-'));
319
333
  if (launch === 'invite' && invite) return inviteOpen(env, invite.slice(2));
320
334
  return false;
@@ -343,16 +357,22 @@ ${error ? `<p class="lead" role="alert"><b>${esc(error)}</b></p>` : ''}
343
357
  export async function redeemInvite(request, env, url, cat, meta, settings) {
344
358
  const launch = launchOf(meta, settings, env);
345
359
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403, headers: { 'cache-control': 'no-store' } });
346
- if (launch === 'public') return Response.redirect(`${url.origin}/${meta.id}/play`, 303);
347
360
  let code = '';
348
361
  try { const form = await request.formData(); code = String(form.get('code') ?? ''); } catch { code = ''; }
349
362
  const norm = code.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
350
- if (launch !== 'invite' || !env?.DB) return gatePage(cat, meta, launch);
351
363
  const now = Date.now();
352
364
  let row = null;
353
- try { row = await env.DB.prepare('SELECT id, game, uses, max_uses, expires_at, revoked FROM office_invites WHERE code = ?1').bind(norm).first(); } catch { row = null; }
365
+ // An invite to one server (0.16.0, its door `invite`) works in a public game too; one to the game, only in a beta.
366
+ try { row = env?.DB ? await env.DB.prepare('SELECT id, game, server, uses, max_uses, expires_at, revoked FROM office_invites WHERE code = ?1').bind(norm).first() : null; } catch {
367
+ // A D1 from before migration 0006 has no `server` column: the game's own invites still work.
368
+ try { row = await env.DB.prepare('SELECT id, game, uses, max_uses, expires_at, revoked FROM office_invites WHERE code = ?1').bind(norm).first(); } catch { row = null; }
369
+ }
370
+ if (!row?.server) {
371
+ if (launch === 'public') return Response.redirect(`${url.origin}/${meta.id}/play`, 303);
372
+ if (launch !== 'invite' || !env?.DB) return gatePage(cat, meta, launch);
373
+ }
354
374
  const bad = !row || row.game !== meta.id || row.revoked || (row.expires_at && Number(row.expires_at) < now) || (row.max_uses && Number(row.uses) >= Number(row.max_uses));
355
- if (bad) return gatePage(cat, meta, launch, { code: norm, error: 'That invite code does not work (used up, ended, or for another game). Ask whoever sent it for a new one.' });
375
+ if (bad) return gatePage(cat, meta, launch === 'public' ? 'invite' : launch, { code: norm, error: 'That invite code does not work (used up, ended, or for another game). Ask whoever sent it for a new one.' });
356
376
  const pass = randomHex(24);
357
377
  try {
358
378
  await env.DB.batch([
@@ -362,9 +382,10 @@ export async function redeemInvite(request, env, url, cat, meta, settings) {
362
382
  ]);
363
383
  } catch { return gatePage(cat, meta, launch, { code: norm, error: 'Something went wrong; try again.' }); }
364
384
  const secure = url.protocol === 'https:' ? '; Secure' : '';
385
+ const cookie = row.server ? serverPassCookie(meta.id, row.server) : passCookie(meta.id);
365
386
  return new Response(null, {
366
387
  status: 303,
367
- headers: { location: `/${meta.id}/play`, 'cache-control': 'no-store', 'set-cookie': `${passCookie(meta.id)}=${pass}; Path=/${meta.id}/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(PASS_TTL_MS / 1000)}${secure}` },
388
+ headers: { location: row.server ? `/${meta.id}/s/${row.server}/play` : `/${meta.id}/play`, 'cache-control': 'no-store', 'set-cookie': `${cookie}=${pass}; Path=/${meta.id}/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(PASS_TTL_MS / 1000)}${secure}` },
368
389
  });
369
390
  }
370
391
 
@@ -388,7 +409,7 @@ async function roomFacts(env, game, room, max) {
388
409
  /** Hand one room one signed control; what the room says back. */
389
410
  async function roomControl(env, meta, settings, room, op, args) {
390
411
  const ctl = await signControl(env, { op, game: meta.id, room, args });
391
- if (!ctl) return { ok: false, error: 'no-key', message: 'This studio has no database for its office yet: `npm run deploy` applies migration 0003_studio_office.sql.' };
412
+ if (!ctl) return { ok: false, error: 'no-key', message: 'This studio has no database for its office yet: `npm run deploy` applies migration 0005_studio_office.sql.' };
392
413
  try {
393
414
  const res = await tableOf(env, meta.id, room).fetch(new Request(`https://table/__office?game=${enc(meta.id)}&room=${enc(room)}&max=${seatsFor(meta, settings)}`, { method: 'POST', body: JSON.stringify(ctl) }));
394
415
  return await res.json();
@@ -407,40 +428,57 @@ async function roomRow(env, meta, room, max) {
407
428
  const pid = parts.find((p) => p.startsWith('p-'))?.slice(2) ?? c.player ?? null;
408
429
  const invite = parts.find((p) => p.startsWith('i-'))?.slice(2) ?? null;
409
430
  const account = pid ? await playerOf(env, pid) : null;
431
+ const agentPass = parts.find((p) => p.startsWith('a-'))?.slice(2) ?? null;
410
432
  return {
411
433
  id: c.id, seat: c.seat, name: c.name, device: c.device, role: c.role, want: c.want, hidden: Boolean(c.hidden), waiting: Boolean(c.waiting),
412
434
  joinedAt: c.joinedAt, browser: c.browser, muted: Boolean(c.muted), mutedUntil: c.mutedUntil ?? null,
413
- as: parts.includes('o') ? 'owner' : invite ? 'invited' : account ? 'player' : 'guest',
435
+ // An AI (an agent pass, section 17) is always marked AI, here as everywhere.
436
+ ...(c.agent ? { agent: { role: c.agent.role, hands: c.agent.hands, pass: c.agent.pass ?? agentPass } } : {}),
437
+ as: c.agent ? 'ai' : parts.includes('o') ? 'owner' : invite ? 'invited' : account ? 'player' : 'guest',
414
438
  ...(invite ? { invite } : {}),
415
439
  ...(account ? { account } : {}),
416
440
  };
417
441
  }));
418
442
  const slots = Array.isArray(f.roster) ? f.roster : [];
419
443
  return {
420
- room, label: roomLabel(room), public: /^pub-\d+$/.test(room),
444
+ room, label: roomLabel(room), public: /^pub-\d+$/.test(room), server: roomServer(room),
421
445
  players: f.counts?.players ?? 0, screens: f.counts?.screens ?? 0, waiting: f.counts?.waiting ?? 0,
422
446
  humans: f.counts?.humans ?? 0, bots: f.counts?.bots ?? 0, max: f.counts?.maxPlayers ?? max,
447
+ agents: f.counts?.agents ?? 0, ai: f.counts?.ai ?? 0, humanSeats: f.counts?.humanSeats ?? null,
448
+ // The room's dial now and who set it (the party's vote, the owner), its policy, and what its game reads.
449
+ policy: f.policy ? { kind: f.policy.kind, level: f.policy.skill?.level ?? f.policy.level, levelName: f.policy.skill?.name ?? levelName(f.policy.level), levelMax: f.policy.levelMax, by: f.policy.by ?? null, speech: f.policy.speech } : null,
450
+ caps: Array.isArray(f.caps) ? f.caps : [], rev: f.rev ?? null,
451
+ vote: f.vote ?? null,
423
452
  round: f.round && typeof f.round === 'object' ? { n: f.round.n ?? null, phase: f.round.phase ?? null, startedAt: f.round.startedAt ?? null, endsAt: f.round.endsAt ?? null } : null,
424
453
  openedAt: office.openedAt ?? f.openedAt ?? null, closedUntil: office.closedUntil ?? null, snapHz: f.snapHz ?? 0,
425
454
  regate: office.regate ?? null,
426
455
  host: f.host ?? null, announce: f.announce ?? null,
427
- slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot) })),
456
+ 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 } } : {}) })),
428
457
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
429
458
  };
430
459
  }
431
460
 
432
461
  async function invitesOf(env, game, origin) {
433
462
  try {
434
- const { results } = await env.DB.prepare('SELECT id, code, label, uses, max_uses, expires_at, revoked, created_at FROM office_invites WHERE game = ?1 ORDER BY created_at DESC LIMIT 100').bind(game).all();
463
+ 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();
435
464
  return (results ?? []).map((r) => inviteView(r, game, origin));
436
- } catch { return []; }
465
+ } catch {
466
+ // Before migration 0006 an invite is the game's (no `server`).
467
+ try {
468
+ const { results } = await env.DB.prepare('SELECT id, code, label, uses, max_uses, expires_at, revoked, created_at FROM office_invites WHERE game = ?1 ORDER BY created_at DESC LIMIT 100').bind(game).all();
469
+ return (results ?? []).map((r) => inviteView(r, game, origin));
470
+ } catch { return []; }
471
+ }
437
472
  }
438
473
  const prettyCode = (c) => `${String(c).slice(0, 4)}-${String(c).slice(4)}`;
439
474
  function inviteView(r, game, origin) {
440
475
  const ended = Boolean(r.revoked) || (r.expires_at && Number(r.expires_at) < Date.now()) || (r.max_uses && Number(r.uses) >= Number(r.max_uses));
441
476
  return {
442
477
  id: r.id, code: prettyCode(r.code), label: r.label ?? '', uses: Number(r.uses) || 0, maxUses: r.max_uses ?? null,
443
- expiresAt: r.expires_at ?? null, revoked: Boolean(r.revoked), open: !ended, link: `${origin}/${game}/?invite=${prettyCode(r.code)}`,
478
+ expiresAt: r.expires_at ?? null, revoked: Boolean(r.revoked), open: !ended,
479
+ // An invite to one server (0.16.0) opens that server's door; any other, the game's invite-only beta.
480
+ ...(r.server ? { server: r.server } : {}),
481
+ link: r.server ? `${origin}/${game}/s/${r.server}/play?invite=${prettyCode(r.code)}` : `${origin}/${game}/?invite=${prettyCode(r.code)}`,
444
482
  };
445
483
  }
446
484
 
@@ -454,8 +492,16 @@ export async function officeView(env, cat, origin) {
454
492
  const rooms = (await Promise.all((await liveRooms(env, g.id)).map((r) => roomRow(env, g, r.room, max)))).filter(Boolean)
455
493
  .filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length);
456
494
  rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
495
+ const servers = await serversOf(env, g, { fresh: true });
496
+ const byServer = {};
497
+ for (const r of rooms) { const b = (byServer[r.server] ??= { rooms: 0, players: 0, ai: 0 }); b.rooms += 1; b.players += r.players; b.ai += r.ai; }
498
+ const members = await memberCountsOf(env, g.id);
457
499
  return {
458
500
  id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
501
+ // 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
+ 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 },
504
+ passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
459
505
  remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
460
506
  seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
461
507
  play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
@@ -466,14 +512,28 @@ export async function officeView(env, cat, origin) {
466
512
  return {
467
513
  ok: true, kind: 'homie-studio-office', v: 1, studio: cat.studio?.name ?? 'Studio', site: origin, now: Date.now(),
468
514
  accounts: Boolean(players?.get), games, playing: games.reduce((n, g) => n + g.playing, 0),
515
+ // The fill-a-spot service (an AI seated in an empty spot for a price) is a money decision: not offered yet.
516
+ fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
517
+ agentsTalk: await talkConsented(env),
469
518
  };
470
519
  }
520
+ async function memberCountsOf(env, game) {
521
+ try {
522
+ const { results } = await env.DB.prepare('SELECT server, COUNT(*) AS n FROM server_members WHERE game = ?1 GROUP BY server').bind(game).all();
523
+ return Object.fromEntries((results ?? []).map((r) => [r.server, Number(r.n) || 0]));
524
+ } catch { return {}; }
525
+ }
526
+ /** Whether the owner has said yes to AI guides talking (a `meta` row, the first `agents-brain` ask). */
527
+ async function talkConsented(env) {
528
+ try { return Boolean((await env.DB.prepare("SELECT value FROM meta WHERE key = 'agents_talk_ok'").first())?.value); } catch { return false; }
529
+ }
471
530
  async function hasInvites(env, game) {
472
531
  try { return Boolean(await env.DB.prepare('SELECT 1 AS x FROM office_invites WHERE game = ?1 LIMIT 1').bind(game).first()); } catch { return false; }
473
532
  }
474
533
 
475
534
  /* ------------------------------------------------------------------ the controls */
476
535
 
536
+ /** Always an ask from an office key; servers' asks are decided case by case (needsAsk: what narrows, or consent). */
477
537
  const DESTRUCTIVE = new Set(['kick', 'mute', 'close', 'game']);
478
538
 
479
539
  /** An action, checked against the catalogue: `{ ok, action }` or `{ ok: false, error, message }`. */
@@ -520,6 +580,52 @@ function checkAction(cat, op, body) {
520
580
  if (remix === true && meta.landing?.source === false) return bad(`${meta.name}'s source is not in its build (game.json "share": { "source": false }); set it to true and deploy first`);
521
581
  return { ok: true, action: { op, game: meta.id, ...(launch !== undefined ? { launch } : {}), ...(remix !== undefined ? { remix } : {}), ...(maxPlayers !== undefined ? { maxPlayers } : {}) } };
522
582
  }
583
+ case 'server-create': {
584
+ if (!meta) return bad('game is one of this studio\'s game ids');
585
+ const c = checkServer(body, { create: true });
586
+ if (!c.ok) return c;
587
+ return { ok: true, action: { op, game: meta.id, fields: c.fields } };
588
+ }
589
+ case 'server-set': {
590
+ if (!meta) return bad('game is one of this studio\'s game ids');
591
+ 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');
592
+ const { game: _g, server: _s, ...rest } = body;
593
+ const c = checkServer(rest);
594
+ if (!c.ok) return c;
595
+ if (!Object.keys(c.fields).length) return bad('say what to change: name, blurb, policy, aiSeats, guides, bots, level, levelMax, speech, door, kids, beginnerDays, beginnerLevel, rooms, seats, listed');
596
+ return { ok: true, action: { op, game: meta.id, server: body.server, fields: c.fields } };
597
+ }
598
+ case 'server-close': {
599
+ if (!meta) return bad('game is one of this studio\'s game ids');
600
+ if (typeof body.server !== 'string' || !/^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server)) return bad('server is the server\'s id (the public server is Quick play: close it with "public")');
601
+ return { ok: true, action: { op, game: meta.id, server: body.server, reopen: body.reopen === true } };
602
+ }
603
+ case 'member': {
604
+ if (!meta) return bad('game is one of this studio\'s game ids');
605
+ if (typeof body.server !== 'string' || !/^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server)) return bad('server is the server\'s id');
606
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(body.player ?? ''))) return bad('player is the player\'s id (from the office)');
607
+ if (body.remove !== true && !['member', 'mentor', 'mod'].includes(body.role)) return bad('role is member, mentor or mod; or remove: true');
608
+ return { ok: true, action: { op, game: meta.id, server: body.server, player: body.player, ...(body.remove === true ? { remove: true } : { role: body.role }), name: oneLine(body.name, 40) || null } };
609
+ }
610
+ case 'pass': {
611
+ const action = ['create', 'list', 'revoke'].includes(body.action) ? body.action : 'create';
612
+ if (body.game !== undefined && body.game !== null && !meta) return bad('game is one of this studio\'s game ids, or leave it out for any');
613
+ if (action === 'revoke' && !/^[a-f0-9]{10}$/.test(String(body.id ?? ''))) return bad('id is the pass\'s id');
614
+ return { ok: true, action: { op, action, game: meta?.id ?? null, id: body.id ?? null, label: oneLine(body.label, 24), role: body.role ?? 'party', hands: body.hands ?? 'self', server: body.server ?? null, days: body.days ?? null, kind: body.kind ?? 'owner' } };
615
+ }
616
+ case 'room-level': {
617
+ if (!meta) return bad('game is one of this studio\'s game ids');
618
+ if (!room) return bad('room is the room\'s code');
619
+ const level = Math.floor(Number(body.level));
620
+ if (!(level >= 1 && level <= 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
621
+ return { ok: true, action: { op, game: meta.id, room, level } };
622
+ }
623
+ case 'agents-brain': {
624
+ if (!meta) return bad('game is one of this studio\'s game ids');
625
+ 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
+ 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 } };
628
+ }
523
629
  default:
524
630
  return bad('unknown control');
525
631
  }
@@ -545,10 +651,58 @@ export function describe(cat, a) {
545
651
  const s = bits.join('; ');
546
652
  return `${s.charAt(0).toUpperCase()}${s.slice(1)}.`;
547
653
  }
654
+ case 'server-create': return `Make a ${a.fields.policy} server "${a.fields.name}" for ${gname}.`;
655
+ case 'server-set': {
656
+ const f = a.fields;
657
+ const bits = [];
658
+ if (f.policy) bits.push(f.policy === 'humans-only' ? 'make it humans-only (its AI players leave after the current round, and no AI can join)' : `make it ${f.policy}`);
659
+ if (f.door) bits.push(f.door === 'open' ? 'open its door to anyone' : f.door === 'accounts' ? 'let in only players with an account (a passkey)' : 'let in only players with an invite');
660
+ if (f.rooms !== undefined) bits.push(`allow ${f.rooms} room${f.rooms === 1 ? '' : 's'} at most`);
661
+ if (f.seats !== undefined) bits.push(f.seats === null ? 'give its rooms the game\'s own seats' : `give its rooms ${f.seats} seats`);
662
+ if (f.beginnerDays !== undefined) bits.push(`count players as new for ${f.beginnerDays} days`);
663
+ if (f.name) bits.push(`rename it "${f.name}"`);
664
+ const rest = Object.keys(f).filter((k) => !['policy', 'door', 'rooms', 'seats', 'beginnerDays', 'name'].includes(k));
665
+ if (rest.length) bits.push(`change its ${rest.join(', ')}`);
666
+ return `On ${gname}'s server ${a.server}: ${bits.join('; ')}.`;
667
+ }
668
+ case 'server-close': return a.reopen ? `Open ${gname}'s server ${a.server} again.` : `Close ${gname}'s server ${a.server}: its rooms finish the current round, then everyone leaves with a thank-you, and nobody can join until it opens again.`;
669
+ case 'member': return a.remove ? `Remove ${a.name ?? 'that player'} from ${gname}'s server ${a.server} (they can join again unless its door keeps them out).` : `Make ${a.name ?? 'that player'} a ${a.role} of ${gname}'s server ${a.server}.`;
670
+ 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
+ case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
672
+ 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)'}.`
674
+ : `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
548
675
  default: return 'A control.';
549
676
  }
550
677
  }
551
678
 
679
+ /** Whether an office key (the owner's AI) only ASKS for this (DESIGN D13): what takes something away, or consent. */
680
+ export async function needsAsk(env, cat, a) {
681
+ if (DESTRUCTIVE.has(a.op)) return true;
682
+ if (a.op === 'server-close') return !a.reopen;
683
+ if (a.op === 'member') return a.remove === true;
684
+ if (a.op === 'server-set') {
685
+ const meta = (cat.games ?? []).find((g) => g.id === a.game);
686
+ const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
687
+ return narrows(before, a.fields);
688
+ }
689
+ if (a.op === 'agents-brain') return ['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env));
690
+ return false;
691
+ }
692
+
693
+ /** The live rooms of one server of a game. */
694
+ async function serverRooms(env, game, server) {
695
+ return (await liveRooms(env, game)).filter((r) => roomServer(r.room) === server);
696
+ }
697
+
698
+ /** A server's policy reaches every live room of it now (a signed `policy` control; section 17). */
699
+ async function pushPolicy(env, meta, settings, server) {
700
+ const pol = policyOf(server, { seats: seatsFor(meta, settings) });
701
+ const rooms = await serverRooms(env, meta.id, server.id);
702
+ const res = await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'policy', { pol })));
703
+ return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
704
+ }
705
+
552
706
  /** Do it: the owner's own session did, or the owner confirmed what the AI asked. */
553
707
  export async function perform(env, cat, a) {
554
708
  const settings = await settingsOf(env, { fresh: true });
@@ -602,6 +756,92 @@ export async function perform(env, cat, a) {
602
756
  }
603
757
  return done;
604
758
  }
759
+ case 'server-create': {
760
+ if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for servers.' };
761
+ const list = await serversOf(env, meta, { fresh: true });
762
+ if (list.some((x) => x.id === a.fields.id)) return { ok: false, error: 'exists', message: `${meta.name} already has a server called ${a.fields.id}; change it with server_set.` };
763
+ if (list.length >= SERVER_LIMITS.perGame) return { ok: false, error: 'limit', message: `A game has at most ${SERVER_LIMITS.perGame} servers.` };
764
+ const sv = rowFor(meta.id, null, a.fields);
765
+ 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
+ const view = serverView(sv, { origin: a.origin ?? '', game: meta.id });
767
+ 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).`);
769
+ 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
+ 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
+ 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.`);
772
+ return { ok: true, op: a.op, game: meta.id, server: view, notes };
773
+ }
774
+ case 'server-set': {
775
+ const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
776
+ if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
777
+ const sv = rowFor(meta.id, before, a.fields);
778
+ 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)})` }; }
779
+ const pushed = await pushPolicy(env, meta, settings, sv);
780
+ return { ok: true, op: a.op, game: meta.id, server: serverView(sv, { game: meta.id }), rooms: pushed.rooms, aiLeaving: pushed.leaving };
781
+ }
782
+ case 'server-close': {
783
+ const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) ?? (a.server === 'public' ? PUBLIC_SERVER : null);
784
+ if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
785
+ const sv = rowFor(meta.id, before, { state: a.reopen ? 'open' : 'closed' });
786
+ 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)})` }; }
787
+ const rooms = await serverRooms(env, meta.id, sv.id);
788
+ // Its rooms finish the round they are in, then everyone but the owner leaves (the launch-change road).
789
+ const res = a.reopen
790
+ ? await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'regate', {})))
791
+ : await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'regate', { allow: ['o'], notice: `${sv.name} closes after this round. Thanks for playing!`, message: `${sv.name} is closed now. Thanks for playing!` })));
792
+ return { ok: true, op: a.op, game: meta.id, server: sv.id, state: sv.state, rooms: res.filter((r) => r.ok).length };
793
+ }
794
+ case 'member': {
795
+ try {
796
+ if (a.remove) await env.DB.prepare('DELETE FROM server_members WHERE game = ?1 AND server = ?2 AND player = ?3').bind(meta.id, a.server, a.player).run();
797
+ else {
798
+ const now = Date.now();
799
+ await env.DB.prepare(`INSERT INTO server_members (game, server, player, role, home, joined_at, seen_at) VALUES (?1, ?2, ?3, ?4, 0, ?5, ?5)
800
+ ON CONFLICT(game, server, player) DO UPDATE SET role = excluded.role`).bind(meta.id, a.server, a.player, a.role, now).run();
801
+ }
802
+ } 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)})` }; }
803
+ return { ok: true, op: a.op, game: meta.id, server: a.server, player: a.player, ...(a.remove ? { removed: true } : { role: a.role }) };
804
+ }
805
+ case 'pass': {
806
+ try {
807
+ if (a.action === 'list') return { ok: true, op: a.op, passes: await passList(env, { game: a.game }) };
808
+ if (a.action === 'revoke') {
809
+ const r = await passRevoke(env, a.id);
810
+ if (!r.ok) return r;
811
+ // The AI leaves every room it sits in now, and is held out of them.
812
+ let left = 0;
813
+ for (const g of a.game ? [meta] : (cat.games ?? [])) {
814
+ for (const room of await liveRooms(env, g.id)) {
815
+ const f = await roomFacts(env, g.id, room.room, seatsFor(g, settings));
816
+ for (const c of (f?.clients ?? []).filter((x) => x.agent?.pass === a.id)) {
817
+ const k = await roomControl(env, g, settings, room.room, 'kick', { id: c.id, minutes: 24 * 60, message: 'This AI\'s pass was revoked.' });
818
+ if (k.ok) left += 1;
819
+ }
820
+ }
821
+ }
822
+ return { ok: true, op: a.op, revoked: a.id, left };
823
+ }
824
+ const r = await passCreate(env, { label: a.label, kind: a.kind, role: a.role, hands: a.hands, game: a.game, server: a.server, days: a.days });
825
+ if (!r.ok) return r;
826
+ return {
827
+ ok: true, op: a.op, pass: r.pass, secret: r.secret,
828
+ use: `This is the only time the pass is shown. The AI sits with POST ${a.origin ?? ''}/${a.game ?? '<game>'}/api/agent and the header "Authorization: Bearer <pass>"; it plays as "${aiName(a.label)}", marked AI, never on a humans-only server. Revoke it any time.`,
829
+ };
830
+ } catch (error) { return { ok: false, error: 'not-migrated', message: `Agent passes need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
831
+ }
832
+ case 'room-level': return roomControl(env, meta, settings, a.room, 'level', { level: a.level });
833
+ case 'agents-brain': {
834
+ const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
835
+ if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
836
+ if (before.policy === 'humans-only') return { ok: false, error: 'humans-only', message: 'A humans-only server has no AI to talk.' };
837
+ const sv = rowFor(meta.id, before, { brain: a.mode });
838
+ try {
839
+ await writeServer(env, meta.id, sv);
840
+ 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
+ } 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)})` }; }
842
+ 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.' };
844
+ }
605
845
  default: return { ok: false, error: 'op' };
606
846
  }
607
847
  }
@@ -660,14 +900,22 @@ async function invitesApi(request, env, url, cat, body, who) {
660
900
  const uses = body.uses === null || body.uses === undefined || body.uses === 'any' ? null : Math.max(1, Math.min(10_000, Math.floor(Number(body.uses) || 1)));
661
901
  const days = body.days === null || body.days === undefined ? null : Math.max(1, Math.min(365, Math.floor(Number(body.days) || 30)));
662
902
  const label = oneLine(body.label, 60) || null;
903
+ // An invite to one server's door (0.16.0): the server must be this game's.
904
+ const server = typeof body.server === 'string' && body.server ? body.server : null;
905
+ if (server && !(await serversOf(env, meta, { fresh: true })).some((x) => x.id === server && x.id !== 'public')) return json({ ok: false, error: 'bad-request', message: `${meta.name} has no server called ${server}` }, 400);
663
906
  const made = [];
664
907
  for (let i = 0; i < count; i += 1) {
665
908
  const raw = new Uint8Array(8);
666
909
  crypto.getRandomValues(raw);
667
910
  const code = [...raw].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
668
- const row = { id: randomHex(5), code, label, uses: 0, max_uses: uses, expires_at: days ? Date.now() + days * 86400_000 : null, revoked: 0, created_at: Date.now() };
669
- await env.DB.prepare('INSERT INTO office_invites (id, game, code, label, uses, max_uses, expires_at, revoked, created_at) VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6, 0, ?7)')
670
- .bind(row.id, meta.id, row.code, row.label, row.max_uses, row.expires_at, row.created_at).run();
911
+ const row = { id: randomHex(5), code, label, uses: 0, max_uses: uses, expires_at: days ? Date.now() + days * 86400_000 : null, revoked: 0, created_at: Date.now(), server };
912
+ if (server) {
913
+ await env.DB.prepare('INSERT INTO office_invites (id, game, code, label, uses, max_uses, expires_at, revoked, created_at, server) VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6, 0, ?7, ?8)')
914
+ .bind(row.id, meta.id, row.code, row.label, row.max_uses, row.expires_at, row.created_at, server).run();
915
+ } else {
916
+ await env.DB.prepare('INSERT INTO office_invites (id, game, code, label, uses, max_uses, expires_at, revoked, created_at) VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6, 0, ?7)')
917
+ .bind(row.id, meta.id, row.code, row.label, row.max_uses, row.expires_at, row.created_at).run();
918
+ }
671
919
  made.push(inviteView(row, meta.id, origin));
672
920
  }
673
921
  return json({ ok: true, game: meta.id, launch: launchOf(meta, await settingsOf(env), env), invites: made });
@@ -694,13 +942,13 @@ async function api(request, env, url, cat) {
694
942
 
695
943
  if (path === '/_studio/api/office' && request.method === 'GET') {
696
944
  try { return json({ ...(await officeView(env, cat, url.origin)), by: who }); } catch (error) {
697
- return json({ ok: false, error: 'no-office', message: `The office is not in this studio's D1 yet: \`npm run deploy\` applies migration 0003_studio_office.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
945
+ return json({ ok: false, error: 'no-office', message: `The office is not in this studio's D1 yet: \`npm run deploy\` applies migration 0005_studio_office.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
698
946
  }
699
947
  }
700
948
  if (path === '/_studio/api/invites' || path === '/_studio/api/invites/revoke') {
701
949
  if (path.endsWith('/revoke') && request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
702
950
  try { return await invitesApi(request, env, url, cat, body, who); } catch (error) {
703
- return json({ ok: false, error: 'no-office', message: `Invites need migration 0003_studio_office.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
951
+ return json({ ok: false, error: 'no-office', message: `Invites need migration 0005_studio_office.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
704
952
  }
705
953
  }
706
954
  const askPath = /^\/_studio\/api\/asks\/(ask_[a-f0-9]{16})$/.exec(path);
@@ -708,13 +956,34 @@ async function api(request, env, url, cat) {
708
956
  const row = await readAsk(env, askPath[1]);
709
957
  return row ? json({ ok: true, ask: askView(row, cat, url.origin) }) : json({ ok: false, error: 'no-ask', message: 'No such ask: they last 15 minutes.' }, 404);
710
958
  }
711
- const op = /^\/_studio\/api\/(kick|mute|close|announce|game)$/.exec(path)?.[1];
959
+ // Servers and agent seats (0.16.0): the list, and each server's members.
960
+ if (path === '/_studio/api/servers' && request.method === 'GET') {
961
+ const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
962
+ const view = await officeView(env, { ...cat, games: metas }, url.origin);
963
+ return json({ ok: true, games: view.games.map((g) => ({ id: g.id, name: g.name, build: g.build, servers: g.servers, passes: g.passes })), fillSpot: view.fillSpot, agentsTalk: view.agentsTalk });
964
+ }
965
+ if (path === '/_studio/api/servers/members' && request.method === 'GET') {
966
+ const game = url.searchParams.get('game');
967
+ const server = url.searchParams.get('server');
968
+ try {
969
+ const { results } = await env.DB.prepare('SELECT player, role, home, joined_at, seen_at FROM server_members WHERE game = ?1 AND server = ?2 ORDER BY seen_at DESC LIMIT 200').bind(game, server).all();
970
+ const members = await Promise.all((results ?? []).map(async (r) => ({ player: r.player, account: await playerOf(env, r.player), role: r.role, home: Number(r.home) === 1, joinedAt: Number(r.joined_at), seenAt: Number(r.seen_at) })));
971
+ return json({ ok: true, game, server, members });
972
+ } catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
973
+ }
974
+ const OPS = {
975
+ '/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
976
+ '/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
977
+ '/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
978
+ };
979
+ const op = OPS[path];
712
980
  if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
713
981
  const checked = checkAction(cat, op, body);
714
982
  if (!checked.ok) return json(checked, 400);
715
983
  const action = checked.action;
984
+ if (op === 'server-create' || op === 'pass') action.origin = url.origin;
716
985
  // An office key (the owner's AI) only ASKS for what takes something away; the owner confirms with one tap.
717
- if (who === 'office' && DESTRUCTIVE.has(op)) {
986
+ if (who === 'office' && await needsAsk(env, cat, action)) {
718
987
  if (op === 'kick' || op === 'mute') {
719
988
  const found = await nameIn(env, cat, action);
720
989
  if (!found) return json({ ok: false, error: 'no-player', message: 'Nobody is in that seat of that room now (studio_office lists who is).' }, 404);
@@ -725,7 +994,7 @@ async function api(request, env, url, cat) {
725
994
  const ask = await makeAsk(env, cat, action, url.origin);
726
995
  return json({ ok: true, needs: 'owner', ask, message: `Waiting for the owner: ${ask.what} The owner confirms it with one tap at ${ask.confirm} (signed in to the studio; it lasts 15 minutes).` }, 202);
727
996
  } catch (error) {
728
- return json({ ok: false, error: 'no-office', message: `Asks need migration 0003_studio_office.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
997
+ return json({ ok: false, error: 'no-office', message: `Asks need migration 0005_studio_office.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
729
998
  }
730
999
  }
731
1000
  const result = await perform(env, cat, action);