@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 +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 +28 -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 +43 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +84 -19
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +206 -15
- 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/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.
|
|
113
|
-
npx -y @homie-rocks/studio@0.
|
|
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 `
|
|
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.
|
|
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
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/lib/servers.mjs
ADDED
|
@@ -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
|
}
|