@homie-rocks/studio 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ npx homie-studio deploy # the studio's own Cloudf
20
20
  npx homie-studio publish # the homie.rocks directory
21
21
  npx homie-studio stats # the studio's own numbers, for its owner
22
22
  npx homie-studio office # who is playing now, in every live room (the back office)
23
+ npx homie-studio servers # each game's servers: humans-only, hybrid AI seats, beginner guides
23
24
  npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
24
25
  ```
25
26
 
@@ -109,8 +110,8 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
109
110
  A studio pins one version, so nothing changes until it asks. To take a newer one:
110
111
 
111
112
  ```sh
112
- npx -y @homie-rocks/studio@0.15.0 upgrade # the plan; changes nothing
113
- npx -y @homie-rocks/studio@0.15.0 upgrade --apply # after the person agrees
113
+ npx -y @homie-rocks/studio@0.16.0 upgrade # the plan; changes nothing
114
+ npx -y @homie-rocks/studio@0.16.0 upgrade --apply # after the person agrees
114
115
  npm install && npm run build
115
116
  ```
116
117
 
@@ -232,16 +233,45 @@ look, announce and invite at once; a kick, a mute, a closed room or a launch cha
232
233
  **asked** for, and the owner confirms it with one tap in their own browser. No key can
233
234
  confirm. The Worker signs each control with the studio's own secret and the room verifies
234
235
  it before it acts (`netplay/NETPLAY.md` section 15). Everything is in the studio's own
235
- Worker and D1 (migration `0003_studio_office.sql`); homie.rocks stores none of it.
236
+ Worker and D1 (migration `0005_studio_office.sql`); homie.rocks stores none of it.
236
237
 
237
238
  With player accounts (0.12.0), a signed-in player's play page names their account in its room ticket (an invited
238
239
  player's names the invite and the account), so the office shows them by name and a kick holds the account on every
239
240
  device; the owner's own passkey account (`homie-studio players owner`) counts as the owner.
240
241
 
242
+ ## Servers and AI seats
243
+
244
+ A game's **servers** (0.16.0) are named, lasting pools of rooms with their own rules; strangers are matched only
245
+ inside one server, and every game's public rooms are its Quick play server, so nothing old changes:
246
+
247
+ ```sh
248
+ npx homie-studio servers new night-rush "Night Shift" --policy hybrid --ai 2 # 2 AI companions in every room
249
+ npx homie-studio servers new night-rush "People Only" --policy humans-only # no AI of any kind
250
+ npx homie-studio servers new night-rush "First Light" --policy beginner --guides 2 --kids
251
+ npx homie-studio servers # every server, live, and builds that predate them
252
+ npx homie-studio agents pass night-rush --label Claude # an AI's way into a seat (shown once)
253
+ ```
254
+
255
+ - **Policies:** open (an AI with a pass may sit, always marked AI), humans-only (the site and the room refuse
256
+ every AI; the game's bots are off), hybrid (the top N seats of every room are AI companions), beginner (new
257
+ accounts, AI guides, quick lines only; `--kids`: handles only, gentle AI). **Doors:** open, accounts (a passkey
258
+ account) or invite (`office invite <id> --server <server>`).
259
+ - **AI is always marked AI**, by the room's relay, not the game: every agent is named "<label> · AI", and the
260
+ relay rewrites a host's roster and results so a game cannot hide one.
261
+ - **The skill dial:** 1 Rookie to 5 Maxed (`{ reactionMs, aimNoise, aggression, positioning }`). The party votes it
262
+ on a card in the play page (the middle vote wins); `net.skillOf(slot)` is what a bot reads (`NETPLAY.md` section
263
+ 17), and `BotBrain` reads it with a rebuild. In the starters, Gem Rush's Rookie bots collect far fewer gems than
264
+ its Maxed ones.
265
+ - **Asks:** making a server, a widening change, a pass and a room's level happen at once; narrowing one
266
+ (humans-only, a stricter door), closing it, removing a member, and the first time AI guides may talk are asked
267
+ for, and the owner confirms with one tap.
268
+ - Everything is in the studio's own Worker and D1 (migration `0006_studio_servers.sql`). The site's side is in
269
+ [site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) section 17.
270
+
241
271
  ## Setup status
242
272
 
243
273
  ```sh
244
- npx -y @homie-rocks/studio@0.15.0 setup status --connector yes # before a studio exists
274
+ npx -y @homie-rocks/studio@0.16.0 setup status --connector yes # before a studio exists
245
275
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
246
276
  ```
247
277
 
@@ -51,7 +51,7 @@
51
51
  * --to /<game>/play: a private game on the owner's own phone)
52
52
  * homie-studio office key [--hours 1] a key for the Homie MCP's owner tools (studio_office, room_kick, ...)
53
53
  * homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
54
- * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
54
+ * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>] [--server <id>]
55
55
  * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
56
56
  * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
57
57
  * homie-studio office close <game> <room> [--minutes 10] [--reopen]
@@ -59,6 +59,22 @@
59
59
  * tap in their own browser, from the link this prints)
60
60
  * homie-studio office revoke (every office key, play ticket and pending ask ends)
61
61
  *
62
+ * homie-studio servers [--game <id>] every server of every game (worker/servers.mjs): its policy, door, the AI's
63
+ * level, live rooms and AI, members, and builds that predate servers
64
+ * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
65
+ * [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
66
+ * [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
67
+ * homie-studio servers set <game> <server> [the same flags]
68
+ * homie-studio servers close <game> <server> [--reopen]
69
+ * homie-studio servers level <game> <room> <1-5> (the AI's level in one room: Rookie, Steady, Fair, Strong, Maxed)
70
+ * homie-studio servers member <game> <server> <player> --role member|mentor|mod | --remove
71
+ * homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--days 7]
72
+ * (an AI's way into a seat, always marked AI; the pass is shown once)
73
+ * homie-studio agents passes [<game>] homie-studio agents revoke <pass id>
74
+ * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key
75
+ * (a narrowing change, closing a server, removing a member and the first
76
+ * time AI guides may talk are ASKED for, like office kick)
77
+ *
62
78
  * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
63
79
  * [--budget <dollars>] [--unit usd|credits] [--share]
64
80
  * (a progress feed for one build; --share shows it in the Claude app
@@ -129,6 +145,7 @@ import { STUDIO_VERSION } from '../lib/version.mjs';
129
145
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
130
146
  import { playersOwner, playersShow } from '../lib/players.mjs';
131
147
  import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
148
+ import { agentsBrain, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
132
149
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
133
150
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
134
151
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
@@ -140,7 +157,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
140
157
 
141
158
  const argv = process.argv.slice(2);
142
159
  const flags = new Map();
143
- const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen'];
160
+ const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids'];
144
161
  const positional = [];
145
162
  for (let i = 0; i < argv.length; i++) {
146
163
  const a = argv[i];
@@ -277,6 +294,27 @@ function print(result) {
277
294
  for (const i of result.invites) lines.push(`Invite ${i.code}${i.label ? ` (${i.label})` : ''}, ${i.maxUses ? `${i.maxUses} use${i.maxUses === 1 ? '' : 's'}` : 'any number of uses'}: ${i.link}`);
278
295
  if (result.note) lines.push(result.note);
279
296
  break;
297
+ case 'servers':
298
+ lines.push(...serversLines(result));
299
+ break;
300
+ case 'servers new':
301
+ lines.push(result.message, ...(result.notes ?? []).map((n) => ` ${n}`));
302
+ break;
303
+ case 'servers level':
304
+ case 'agents revoke':
305
+ lines.push(result.message);
306
+ break;
307
+ case 'agents pass':
308
+ lines.push(`${result.pass.name} (pass ${result.pass.id}, ${result.pass.role}, hands ${result.pass.hands}${result.pass.server ? `, ${result.pass.server} only` : ''}):`, ` ${result.secret}`, result.use);
309
+ break;
310
+ case 'agents passes':
311
+ for (const p of result.passes) lines.push(`${p.id} ${p.name} ${p.role}, hands ${p.hands}${p.game ? `, ${p.game}` : ''}${p.server ? `/${p.server}` : ''}${p.live ? '' : p.revoked ? ' (revoked)' : ' (ended)'}`);
312
+ if (!result.passes.length) lines.push('No agent passes yet: homie-studio agents pass <game> --label "<Name>"');
313
+ break;
314
+ case 'servers set':
315
+ case 'servers close':
316
+ case 'servers member':
317
+ case 'agents brain':
280
318
  case 'office launch':
281
319
  case 'office kick':
282
320
  case 'office close':
@@ -477,12 +515,30 @@ async function main() {
477
515
  if (sub === 'link') return officeLink(root, { url, to: flags.get('to') });
478
516
  if (sub === 'key') return officeKey(root, { url, hours: flags.get('hours') });
479
517
  if (sub === 'announce') return officeAnnounce(root, positional.slice(2).join(' '), { url, game: flags.get('game'), room: flags.get('room'), seconds: flags.get('seconds') });
480
- if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days') });
518
+ if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days'), server: flags.get('server') });
481
519
  if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
482
520
  if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
483
521
  if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
484
522
  if (sub === 'revoke') return officeRevoke(root, { url });
485
523
  }
524
+ if (cmd === 'servers') {
525
+ const url = flags.get('url');
526
+ if (!sub || sub === 'list') return serversList(root, { url, game: flags.get('game') });
527
+ if (sub === 'new') return serversNew(root, positional[2], positional.slice(3).join(' ') || flags.get('name'), flags, { url });
528
+ if (sub === 'set') return serversSet(root, positional[2], positional[3], flags, { url });
529
+ if (sub === 'close') return serversClose(root, positional[2], positional[3], { url, reopen: flags.has('reopen') });
530
+ if (sub === 'level') return serversLevel(root, positional[2], positional[3], positional[4], { url });
531
+ if (sub === 'member') return serversMember(root, positional[2], positional[3], positional[4], { url, role: flags.get('role'), remove: flags.has('remove') });
532
+ return { ok: false, command: 'servers', why: `unknown: servers ${sub} (list, new, set, close, level, member)` };
533
+ }
534
+ if (cmd === 'agents') {
535
+ const url = flags.get('url');
536
+ if (sub === 'pass') return agentsPass(root, positional[2], { url, label: flags.get('label'), server: flags.get('server'), hands: flags.get('hands'), role: flags.get('role'), days: flags.get('days') });
537
+ if (sub === 'passes') return agentsPasses(root, positional[2], { url });
538
+ if (sub === 'revoke') return agentsRevoke(root, positional[2], { url });
539
+ if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url });
540
+ return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain)` };
541
+ }
486
542
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
487
543
  if (cmd === 'media' && sub === 'list') {
488
544
  const studio = readStudio(root);
package/lib/build.mjs CHANGED
@@ -34,6 +34,7 @@ import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
34
34
  import { SEAT_MAX } from '../worker/seats.mjs';
35
35
  import { STUDIO_VERSION } from './version.mjs';
36
36
  import { licenseOf, remixRow } from '../worker/license.mjs';
37
+ import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
37
38
 
38
39
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
39
40
  /** Never copied into a static game's served folder. */
@@ -126,6 +127,44 @@ function readJson(path) {
126
127
  * A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
127
128
  * built output (a ported Vite game ships public/netplay.json), over nothing.
128
129
  */
130
+ /**
131
+ * Which netplay revision a game's build speaks (0.16.0): the helper writes `homie-netplay-rev:<n>` into every
132
+ * bundle (netplay/netplay.ts NETPLAY_MARK). A build without it is revision 5 or older: it plays on every server, but
133
+ * reserved AI seats stay empty and its bots do not read the dial, and the office says so. Null when nothing says.
134
+ */
135
+ export function netplayRevOf(out) {
136
+ const files = [];
137
+ const walk = (dir, depth) => {
138
+ if (depth > 3 || !existsSync(dir)) return;
139
+ for (const name of readdirSync(dir)) {
140
+ const f = join(dir, name);
141
+ let st;
142
+ try { st = statSync(f); } catch { continue; }
143
+ if (st.isDirectory()) { if (!['node_modules', '_landing'].includes(name)) walk(f, depth + 1); } else if (/\.m?js$/.test(name) && st.size < 16 * 1024 * 1024) files.push(f);
144
+ }
145
+ };
146
+ walk(out, 0);
147
+ let rev = null;
148
+ for (const f of files.slice(0, 200)) {
149
+ const m = /homie-netplay-rev:(\d{1,3})/.exec(readFileSync(f, 'utf8'));
150
+ if (m) rev = Math.max(rev ?? 0, Number(m[1]));
151
+ }
152
+ return rev;
153
+ }
154
+
155
+ /** game.json "servers": the seeds a game ships with (worker/servers.mjs; a D1 row of the same id wins). */
156
+ function serverSeeds(g, log) {
157
+ if (!Array.isArray(g.servers)) return [];
158
+ const out = [];
159
+ for (const raw of g.servers.slice(0, SERVER_LIMITS.perGame)) {
160
+ const s = serverOf({ ...raw, id: raw?.id ?? String(raw?.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20) }, { from: 'game.json' });
161
+ if (!s || (s.id === 'public' && raw?.policy === undefined)) { log(`warning: games/${g.id}/game.json: a server needs an id (or a name) and a policy; skipped`); continue; }
162
+ const { from, createdAt, updatedAt, ...seed } = s;
163
+ out.push(seed);
164
+ }
165
+ return out;
166
+ }
167
+
129
168
  export function netplayOf(g, out = null) {
130
169
  const file = readJson(join(g.dir, 'netplay.json')) ?? (out ? readJson(join(out, 'netplay.json')) : null) ?? {};
131
170
  return { ...(file && typeof file === 'object' ? file : {}), ...(g.netplay && typeof g.netplay === 'object' ? g.netplay : {}) };
@@ -221,6 +260,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
221
260
  // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
222
261
  const net = netplayOf(g, join(dist, 'games', g.id));
223
262
  const { min, max } = seatsFor(g, net);
263
+ const seeds = serverSeeds(g, log);
224
264
  return {
225
265
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
226
266
  roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
@@ -233,6 +273,11 @@ export async function build(root, { only = null, log = () => {} } = {}) {
233
273
  // game.json `"watch"` (NETPLAY.md section 16): watchers see any player's view (the default), only the whole room
234
274
  // ("overview": hidden hands or roles), or nothing (false: no watch door).
235
275
  ...(g.watch === 'overview' ? { watch: 'overview' } : g.watch === false || g.watch === 'off' ? { watch: 'off' } : {}),
276
+ // Servers (0.16.0): the seeds game.json ships, the netplay revision its build speaks (the office warns when it
277
+ // predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
278
+ ...(seeds.length ? { servers: seeds } : {}),
279
+ netplayRev: netplayRevOf(join(dist, 'games', g.id)),
280
+ ...(g.agents && typeof g.agents === 'object' && (g.agents.vote === 'game' || g.agents.vote === false) ? { agents: { vote: g.agents.vote } } : {}),
236
281
  // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
237
282
  license: licenseOf(g.license),
238
283
  ...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
package/lib/office.mjs CHANGED
@@ -26,7 +26,7 @@ const HOUR = 3600_000;
26
26
  const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
27
 
28
28
  /** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
29
- async function withKey(root, url, fn) {
29
+ export async function withKey(root, url, fn) {
30
30
  const { studio, site } = siteOf(root, url);
31
31
  if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
32
32
  const local = loopback(site);
@@ -57,7 +57,7 @@ function signinTo(root, url, to) {
57
57
  return { ok: true, site, link: `${site}/_studio/signin?k=${k.key}${to && to !== '/_studio/stats' ? `&to=${encodeURIComponent(to)}` : ''}`, expiresAt: k.expiresAt };
58
58
  }
59
59
 
60
- const askedFor = (root, url, r, command) => {
60
+ export const askedFor = (root, url, r, command) => {
61
61
  if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
62
62
  if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
63
63
  const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
@@ -102,13 +102,14 @@ export async function officeAnnounce(root, text, { url, game, room, seconds } =
102
102
  return r.ok ? { ok: true, command: 'office announce', ...r, message: `Announced to ${r.people ?? 0} people in ${r.rooms ?? 0} room(s).` } : { ok: false, command: 'office announce', why: r.message ?? r.why };
103
103
  }
104
104
 
105
- export async function officeInvite(root, game, { url, label, uses, count, days } = {}) {
106
- if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'office invite', why: 'usage: homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1]' };
105
+ export async function officeInvite(root, game, { url, label, uses, count, days, server } = {}) {
106
+ if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'office invite', why: 'usage: homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--server <id>]' };
107
107
  const r = await withKey(root, url, (call) => call('/_studio/api/invites', {
108
- game, ...(label ? { label } : {}), uses: uses === 'any' ? 'any' : uses ? Number(uses) : 1, ...(count ? { count: Number(count) } : {}), ...(days ? { days: Number(days) } : {}),
108
+ game, ...(label ? { label } : {}), uses: uses === 'any' ? 'any' : uses ? Number(uses) : 1, ...(count ? { count: Number(count) } : {}), ...(days ? { days: Number(days) } : {}), ...(server ? { server } : {}),
109
109
  }));
110
110
  if (!r.ok) return { ok: false, command: 'office invite', why: r.message ?? r.why };
111
- return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
111
+ // An invite to a server (its door "invite") works whatever the game's launch state; a game's own, in a beta only.
112
+ return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: server || r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
112
113
  }
113
114
 
114
115
  export async function officeLaunch(root, game, state, { url, remixable, max } = {}) {
package/lib/scaffold.mjs CHANGED
@@ -27,6 +27,7 @@ import { STUDIO_VERSION, packageSpec } from './version.mjs';
27
27
  import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
28
28
  import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
29
29
  import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
30
+ import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
30
31
  import { themeFile } from './site.mjs';
31
32
 
32
33
  export const COMPAT_DATE = '2026-06-01';
@@ -264,6 +265,30 @@ studio's pinned copy, never a registry lookup of the bare name.
264
265
  - A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
265
266
  player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
266
267
 
268
+ ## Servers and AI seats
269
+
270
+ - **A server** is a named, lasting pool of rooms for one game, with its own policy and door. Strangers are matched
271
+ only inside one server. Every game's \`pub-N\` rooms are its \`public\` server (Quick play), so old links keep
272
+ working. A server's page is \`/<id>/s/<server>/\`; its rooms are \`s-<server>-<n>\`; \`/<id>/servers/\` lists them.
273
+ - **Policies:** \`open\` (anyone; an AI with an agent pass may sit, always marked AI), \`humans-only\` (no AI of any
274
+ kind; the game's bots are off unless \`--bots fill\`), \`hybrid\` (N seats in every room are AI companions),
275
+ \`beginner\` (accounts under 30 days, AI guides, quick lines only, optionally \`--kids\`: handles only, the AI's
276
+ level at most 3). **Doors:** \`open\`, \`accounts\` (a passkey account) or \`invite\` (\`office invite\` with
277
+ \`--server\`). AI is ALWAYS marked AI: every agent's name ends in " · AI", and the relay, not the game, enforces it.
278
+ - \`npx --no-install homie-studio servers\` lists them; \`servers new <id> "<Name>" --policy hybrid --ai 2\` (or
279
+ \`--policy beginner --guides 2 [--kids]\`, \`--policy humans-only\`) makes one at once; \`servers set <id> <server>
280
+ --level-max 3 …\` changes one; \`servers close <id> <server>\`. A change that narrows who may come in (humans-only,
281
+ a stricter door) and closing one only ASK, with the owner's one-tap link, like \`office kick\`.
282
+ - **The skill dial:** every room has a level, 1 Rookie, 2 Steady, 3 Fair, 4 Strong, 5 Maxed, each \`{ reactionMs,
283
+ aimNoise, aggression, positioning }\`. The party sets it by voting on a card in the play page (the middle vote
284
+ wins, capped by the server's ceiling). Make a game's bots honour it: \`net.skillOf(slot)\` in their step (the
285
+ snippet is in \`NETPLAY.md\` section 17; \`BotBrain\` from the port kit reads it with a rebuild), and declare
286
+ \`caps: ['skill', 'agents']\` in \`createNetplay\` (a game on \`createRoom\` has \`agents\` already). A build
287
+ from before 0.16.0 still plays on every server; the office says it predates servers until it is rebuilt.
288
+ - **Agent passes:** \`npx --no-install homie-studio agents pass <id> --label Claude [--server <server>]\` gives an
289
+ AI its way into a seat (shown once; \`agents revoke <pass>\` ends it). It sits with \`POST /<id>/api/agent\`
290
+ (Bearer pass), only in a room with people in it, and never on a humans-only server.
291
+
267
292
  ## Continuing a build from the Claude app
268
293
 
269
294
  A Claude Code session started from the Claude app's card gets one short line, like
@@ -481,6 +506,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
481
506
  [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
482
507
  [`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
483
508
  [`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
509
+ [`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
484
510
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
485
511
  '.claude/skills/.gitkeep': '',
486
512
  };
@@ -559,14 +585,27 @@ export function ensurePlayersMigration(root) {
559
585
  return `site/migrations/${PLAYERS_MIGRATION_FILE}`;
560
586
  }
561
587
 
588
+ /**
589
+ * A studio made before 0.16.0 has no servers migration (worker/servers.mjs: servers, members, agent passes): add
590
+ * it, so the next `d1 migrations apply` makes its tables. It needs the office's (0005) before it: its last line
591
+ * gives office_invites a `server`. Returns the file it wrote, or null when it was there.
592
+ */
593
+ export function ensureServersMigration(root) {
594
+ const file = join(root, 'site', 'migrations', SERVERS_MIGRATION_FILE);
595
+ if (existsSync(file)) return null;
596
+ mkdirSync(dirname(file), { recursive: true });
597
+ writeFileSync(file, SERVERS_MIGRATION);
598
+ return `site/migrations/${SERVERS_MIGRATION_FILE}`;
599
+ }
600
+
562
601
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
563
602
  export function ensureMigrations(root) {
564
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root)].filter(Boolean);
603
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root)].filter(Boolean);
565
604
  }
566
605
 
567
606
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
568
607
  export function migrationWord(file) {
569
- return /players/.test(file) ? 'player accounts and cloud saves' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
608
+ return /players/.test(file) ? 'player accounts and cloud saves' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
570
609
  }
571
610
 
572
611
  /**
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `homie-studio servers` and `homie-studio agents` — a studio's servers and agent seats from the studio folder
3
+ * (worker/servers.mjs and worker/agents.mjs say what they are; NETPLAY.md section 17 the contract).
4
+ *
5
+ * homie-studio servers [list] [--game <id>] every server of every game: policy, door, live rooms, AI, members
6
+ * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner
7
+ * [--ai <n>] [--guides <n>] [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
8
+ * [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--seats <n>] [--beginner-days <n>]
9
+ * [--listed on|off] [--blurb "<one line>"] [--id <id>]
10
+ * homie-studio servers set <game> <server> [the same flags]
11
+ * homie-studio servers close <game> <server> [--reopen]
12
+ * homie-studio servers level <game> <room> <1-5> the owner sets one room's AI level now
13
+ * homie-studio servers member <game> <server> <player> --role member|mentor|mod | --remove
14
+ * homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--role party|guide|player] [--days 7]
15
+ * homie-studio agents passes [<game>] every pass (never a secret)
16
+ * homie-studio agents revoke <pass id>
17
+ * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key
18
+ *
19
+ * The proof of ownership is the studio's own Cloudflare login, as for the office: a ten-minute office key is minted
20
+ * and dropped around each call. Making a server, a widening change, a pass and a room's level happen at once. A
21
+ * change that narrows who may come in, closing a server, removing a member, and the first time AI guides may talk are
22
+ * only ASKED for: the answer is a one-time link that opens the ask in the owner's own browser (one tap does it).
23
+ */
24
+ import { askedFor, withKey } from './office.mjs';
25
+
26
+ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
+ const SERVER = /^[a-z0-9][a-z0-9-]{1,19}$/;
28
+ const LEVELS = ['Rookie', 'Steady', 'Fair', 'Strong', 'Maxed'];
29
+
30
+ /** The server fields the flags give (only the ones given), as the office API takes them. */
31
+ export function serverFields(flags) {
32
+ const f = {};
33
+ const get = (k) => (flags.has(k) ? flags.get(k) : undefined);
34
+ const n = (k, as) => { const v = get(k); if (v !== undefined && v !== true) f[as] = Number(v); };
35
+ if (get('policy') !== undefined) f.policy = String(get('policy'));
36
+ n('ai', 'aiSeats'); n('ai-seats', 'aiSeats'); n('guides', 'guides'); n('level', 'level'); n('level-max', 'levelMax'); n('rooms', 'rooms');
37
+ n('beginner-days', 'beginnerDays');
38
+ if (get('beginner-level') !== undefined) f.beginnerLevel = get('beginner-level') === 'none' ? null : Number(get('beginner-level'));
39
+ if (get('seats') !== undefined) f.seats = get('seats') === 'game' ? null : Number(get('seats'));
40
+ if (get('kids') !== undefined) f.kids = get('kids') === true || get('kids') === 'on';
41
+ if (get('listed') !== undefined) f.listed = !(get('listed') === 'off' || get('listed') === 'no');
42
+ for (const k of ['door', 'speech', 'bots', 'blurb', 'id', 'name']) if (get(k) !== undefined && get(k) !== true) f[k] = String(get(k));
43
+ return f;
44
+ }
45
+
46
+ const levelWord = (n) => `${LEVELS[n - 1] ?? 'Fair'} (${n})`;
47
+
48
+ export async function serversList(root, { url, game } = {}) {
49
+ const r = await withKey(root, url, (call) => call(`/_studio/api/servers${game ? `?game=${encodeURIComponent(game)}` : ''}`));
50
+ if (!r.ok) return { ok: false, command: 'servers', why: r.message ?? r.why ?? 'the studio did not answer' };
51
+ return { ok: true, command: 'servers', games: r.games ?? [], fillSpot: r.fillSpot ?? null };
52
+ }
53
+
54
+ export async function serversNew(root, game, name, flags, { url } = {}) {
55
+ if (!GAME.test(String(game ?? '')) || !String(name ?? '').trim()) return { ok: false, command: 'servers new', why: 'usage: homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>] [--kids]' };
56
+ const body = { game, name, ...serverFields(flags) };
57
+ if (!body.policy) return { ok: false, command: 'servers new', why: 'say the policy: --policy open, humans-only, hybrid (with --ai <n>) or beginner (with --guides <n>)' };
58
+ const r = await withKey(root, url, (call) => call('/_studio/api/servers', body));
59
+ if (!r.ok) return { ok: false, command: 'servers new', why: r.message ?? r.why };
60
+ return { ok: true, command: 'servers new', game, server: r.server, notes: r.notes ?? [], message: `${r.server?.name} is open: ${r.server?.page}` };
61
+ }
62
+
63
+ export async function serversSet(root, game, server, flags, { url } = {}) {
64
+ if (!GAME.test(String(game ?? '')) || !(SERVER.test(String(server ?? '')) || server === 'public')) return { ok: false, command: 'servers set', why: 'usage: homie-studio servers set <game> <server> [--policy …] [--level-max 3] [--door accounts] …' };
65
+ const fields = serverFields(flags);
66
+ delete fields.id;
67
+ if (!Object.keys(fields).length) return { ok: false, command: 'servers set', why: 'say what to change: --name, --policy, --ai, --guides, --kids, --door, --level, --level-max, --speech, --bots, --rooms, --seats, --listed' };
68
+ const r = await withKey(root, url, (call) => call('/_studio/api/servers/set', { game, server, ...fields }));
69
+ return askedFor(root, url, r, 'servers set');
70
+ }
71
+
72
+ export async function serversClose(root, game, server, { url, reopen } = {}) {
73
+ if (!GAME.test(String(game ?? '')) || !(SERVER.test(String(server ?? '')) || server === 'public')) return { ok: false, command: 'servers close', why: 'usage: homie-studio servers close <game> <server> [--reopen]' };
74
+ const r = await withKey(root, url, (call) => call('/_studio/api/servers/close', { game, server, reopen: Boolean(reopen) }));
75
+ return askedFor(root, url, r, 'servers close');
76
+ }
77
+
78
+ export async function serversLevel(root, game, room, level, { url } = {}) {
79
+ const n = Math.floor(Number(level));
80
+ if (!GAME.test(String(game ?? '')) || !room || !(n >= 1 && n <= 5)) return { ok: false, command: 'servers level', why: 'usage: homie-studio servers level <game> <room> <1-5> (1 Rookie, 2 Steady, 3 Fair, 4 Strong, 5 Maxed)' };
81
+ const r = await withKey(root, url, (call) => call('/_studio/api/room-level', { game, room, level: n }));
82
+ return r.ok ? { ok: true, command: 'servers level', game, room, level: r.level, message: `The AI in ${room} plays at ${levelWord(r.level)} now.` } : { ok: false, command: 'servers level', why: r.message ?? r.why };
83
+ }
84
+
85
+ export async function serversMember(root, game, server, player, { url, role, remove } = {}) {
86
+ if (!GAME.test(String(game ?? '')) || !SERVER.test(String(server ?? '')) || !player) return { ok: false, command: 'servers member', why: 'usage: homie-studio servers member <game> <server> <player id> --role member|mentor|mod | --remove' };
87
+ const r = await withKey(root, url, (call) => call('/_studio/api/servers/member', { game, server, player, ...(remove ? { remove: true } : { role: role ?? 'member' }) }));
88
+ return askedFor(root, url, r, 'servers member');
89
+ }
90
+
91
+ export async function agentsPass(root, game, { url, label, server, hands, role, days } = {}) {
92
+ if (!label || label === true) return { ok: false, command: 'agents pass', why: 'usage: homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--days 7]' };
93
+ const g = game && game !== 'any' ? game : null;
94
+ if (g && !GAME.test(g)) return { ok: false, command: 'agents pass', why: 'the game is one of this studio\'s game ids, or "any"' };
95
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/pass', { action: 'create', game: g, label, server: server ?? null, hands: hands ?? 'self', role: role ?? 'party', days: days === undefined ? 7 : days === 'never' ? null : Number(days) }));
96
+ if (!r.ok) return { ok: false, command: 'agents pass', why: r.message ?? r.why };
97
+ return {
98
+ ok: true, command: 'agents pass', pass: r.pass, secret: r.secret,
99
+ use: `${r.use} Keep the pass as a secret: never in a file of this repository, a chat or an issue.`,
100
+ };
101
+ }
102
+
103
+ export async function agentsPasses(root, game, { url } = {}) {
104
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/pass', { action: 'list', game: game ?? null }));
105
+ return r.ok ? { ok: true, command: 'agents passes', passes: r.passes ?? [] } : { ok: false, command: 'agents passes', why: r.message ?? r.why };
106
+ }
107
+
108
+ export async function agentsRevoke(root, id, { url } = {}) {
109
+ if (!/^[a-f0-9]{10}$/.test(String(id ?? ''))) return { ok: false, command: 'agents revoke', why: 'usage: homie-studio agents revoke <pass id> (agents passes lists them)' };
110
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/pass', { action: 'revoke', id }));
111
+ return r.ok ? { ok: true, command: 'agents revoke', revoked: id, left: r.left ?? 0, message: `Pass ${id} revoked${r.left ? `: its AI left ${r.left} room(s)` : ''}.` } : { ok: false, command: 'agents revoke', why: r.message ?? r.why };
112
+ }
113
+
114
+ export async function agentsBrain(root, game, server, mode, { url } = {}) {
115
+ if (!GAME.test(String(game ?? '')) || !server || !['off', 'script', 'workers-ai', 'owner-key'].includes(mode)) return { ok: false, command: 'agents brain', why: 'usage: homie-studio agents brain <game> <server> off|script|workers-ai|owner-key' };
116
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/brain', { game, server, mode }));
117
+ return askedFor(root, url, r, 'agents brain');
118
+ }
119
+
120
+ /** One line per server for a person (the CLI's print). */
121
+ export function serversLines(r) {
122
+ const lines = [];
123
+ for (const g of r.games ?? []) {
124
+ lines.push(`${g.name} (${g.id})${g.build?.predates ? `: its build predates servers (netplay rev ${g.build.netplayRev ?? '5 or older'}); rebuild for AI seats and the dial` : ''}`);
125
+ for (const s of g.servers ?? []) {
126
+ const live = s.live ?? { rooms: 0, players: 0, ai: 0 };
127
+ lines.push(` ${s.name} [${s.id}]: ${s.badge}${s.kids ? ', kids' : ''}, bots ${s.bots}, ${levelWord(s.level)} (ceiling ${LEVELS[s.levelMax - 1]}), door ${s.door}${s.state !== 'open' ? `, ${s.state.toUpperCase()}` : ''} · ${live.rooms ? `${live.rooms} room(s), ${live.players} playing, ${live.ai} AI` : 'no live rooms'}${s.members ? ` · ${s.members} members` : ''}`);
128
+ }
129
+ for (const p of (g.passes ?? []).filter((x) => x.live)) lines.push(` pass ${p.id}: ${p.name} (${p.role}, hands ${p.hands}${p.server ? `, ${p.server} only` : ''})`);
130
+ }
131
+ if (r.fillSpot && !r.fillSpot.available) lines.push(`(${r.fillSpot.note})`);
132
+ return lines;
133
+ }
@@ -434,6 +434,34 @@
434
434
  "## Beta": "9d02638207a26e73"
435
435
  }
436
436
  }
437
+ },
438
+ "0.16.0": {
439
+ "files": {
440
+ "CLAUDE.md": "ddf2b2a0376202f6",
441
+ "README.md": "684b3cca8001673c",
442
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
443
+ "games/README.md": "5ad32fe80c61f117",
444
+ "music/README.md": "b8a828de9311bd28",
445
+ "videos/README.md": "70614388cbb203c7",
446
+ "posts/README.md": "37841aae476bd540",
447
+ "site/README.md": "ca131cd0472464fb",
448
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
449
+ },
450
+ "sections": {
451
+ "AGENTS.md": {
452
+ "(intro)": "214aa46eb3a895d5",
453
+ "## Layout": "f14d641159480e6c",
454
+ "## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
455
+ "## The site": "ab922bb6bde23293",
456
+ "## Making games": "a8a893eeada33bf5",
457
+ "## The Game Codex and progress": "0738ef719cef3a4c",
458
+ "## Running live games (the back office)": "f0566f07cff04113",
459
+ "## Servers and AI seats": "961ac5e8ccc2a548",
460
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
461
+ "## Rules": "969318425c99f5a5",
462
+ "## Beta": "9d02638207a26e73"
463
+ }
464
+ }
437
465
  }
438
466
  }
439
467
  }