@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/README.md +34 -4
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +45 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +41 -2
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +56 -0
- package/netplay/NETPLAY.md +207 -17
- package/netplay/netplay.ts +360 -30
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/index.ts +5 -2
- package/port/room.ts +33 -10
- package/port/skill.ts +28 -0
- package/site/SITE.md +49 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +88 -20
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +414 -12
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +226 -17
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +491 -44
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +138 -7
- package/worker/stats.mjs +6 -1
- package/worker/version.mjs +1 -1
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
|
|
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,
|
|
215
|
-
* joined with `~` (an invited player who is signed in: `i-…~p-…`, so
|
|
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
|
-
/**
|
|
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
|
-
|
|
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': `${
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
670
|
-
.
|
|
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
|
|
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
|
|
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
|
-
|
|
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' &&
|
|
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
|
|
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);
|