@homie-rocks/studio 0.14.4 → 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 +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- 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
|
|
|
@@ -80,7 +81,7 @@ its D1; nothing is stored by hand. studio.json `homie.directory: false` keeps a
|
|
|
80
81
|
|
|
81
82
|
Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
|
|
82
83
|
featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
|
|
83
|
-
joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
|
|
84
|
+
joinable and watchable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
|
|
84
85
|
page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
|
|
85
86
|
pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
|
|
86
87
|
live rooms, credits (the original and its licence for a port; "Remix of <game> by <studio>", linked, for a
|
|
@@ -97,13 +98,20 @@ game's landing still. Every HTML answer is `no-transform`, and the site's pages
|
|
|
97
98
|
site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
|
|
98
99
|
`not-a-homie`, at the site's root as under the game.
|
|
99
100
|
|
|
101
|
+
Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<room>` (a Watch button sits beside
|
|
102
|
+
Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
|
|
103
|
+
with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
|
|
104
|
+
player's camera and HUD from `net.viewSeat` (Gem Rush and Ember Vale do; a game that does not is watched as its
|
|
105
|
+
overview), and game.json `"watch": "overview"` or `false` keeps hidden hands hidden. Private and invite-only games
|
|
106
|
+
are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
|
|
107
|
+
|
|
100
108
|
## Upgrading a studio
|
|
101
109
|
|
|
102
110
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
103
111
|
|
|
104
112
|
```sh
|
|
105
|
-
npx -y @homie-rocks/studio@0.
|
|
106
|
-
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
|
|
107
115
|
npm install && npm run build
|
|
108
116
|
```
|
|
109
117
|
|
|
@@ -225,16 +233,45 @@ look, announce and invite at once; a kick, a mute, a closed room or a launch cha
|
|
|
225
233
|
**asked** for, and the owner confirms it with one tap in their own browser. No key can
|
|
226
234
|
confirm. The Worker signs each control with the studio's own secret and the room verifies
|
|
227
235
|
it before it acts (`netplay/NETPLAY.md` section 15). Everything is in the studio's own
|
|
228
|
-
Worker and D1 (migration `
|
|
236
|
+
Worker and D1 (migration `0005_studio_office.sql`); homie.rocks stores none of it.
|
|
229
237
|
|
|
230
238
|
With player accounts (0.12.0), a signed-in player's play page names their account in its room ticket (an invited
|
|
231
239
|
player's names the invite and the account), so the office shows them by name and a kick holds the account on every
|
|
232
240
|
device; the owner's own passkey account (`homie-studio players owner`) counts as the owner.
|
|
233
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
|
+
|
|
234
271
|
## Setup status
|
|
235
272
|
|
|
236
273
|
```sh
|
|
237
|
-
npx -y @homie-rocks/studio@0.
|
|
274
|
+
npx -y @homie-rocks/studio@0.16.0 setup status --connector yes # before a studio exists
|
|
238
275
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
239
276
|
```
|
|
240
277
|
|
|
@@ -411,7 +448,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
411
448
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
412
449
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
413
450
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
414
|
-
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
451
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
415
452
|
| `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
|
|
416
453
|
| `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
|
|
417
454
|
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
|
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,
|
|
@@ -230,6 +270,14 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
230
270
|
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
231
271
|
// game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
|
|
232
272
|
...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
|
|
273
|
+
// game.json `"watch"` (NETPLAY.md section 16): watchers see any player's view (the default), only the whole room
|
|
274
|
+
// ("overview": hidden hands or roles), or nothing (false: no watch door).
|
|
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 } } : {}),
|
|
233
281
|
// Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
|
|
234
282
|
license: licenseOf(g.license),
|
|
235
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';
|
|
@@ -169,7 +170,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
169
170
|
(\`node_modules/@homie-rocks/studio/site/SITE.md\` says all of it):
|
|
170
171
|
|
|
171
172
|
- **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
|
|
172
|
-
Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
|
|
173
|
+
Rooms (every public room playing now, joinable and watchable) and Posts. A section with nothing in it has no tab, and its
|
|
173
174
|
page is not found.
|
|
174
175
|
- **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
|
|
175
176
|
(games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
|
|
@@ -200,6 +201,14 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
200
201
|
scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
|
|
201
202
|
device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
|
|
202
203
|
a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
|
|
204
|
+
- **Watch any player.** Every live room can be watched at \`/<id>/watch?room=<room>\` (each room on the Rooms page
|
|
205
|
+
and the landing has a Watch button): the game itself, drawn by the watcher's own browser, which never takes a
|
|
206
|
+
seat, with a strip of the players to switch between (a tap, keys 1-9, A for Auto, O for the whole room). Point the
|
|
207
|
+
camera and the HUD at \`net.viewSeat\` (your own seat when playing; the followed player when watching; null: the
|
|
208
|
+
overview camera; \`room.viewBody()\` in a port), and call \`net.spotlight(seat)\` on a hit, a kill or a goal so
|
|
209
|
+
Auto cuts to it (\`NETPLAY.md\` section 16). A game that never reads \`viewSeat\` is watched as its overview. A
|
|
210
|
+
game with hidden hands or roles says game.json \`"watch": "overview"\` (the whole room only) or \`false\` (no
|
|
211
|
+
watch door); a private or invite-only game is watched only by those it lets in.
|
|
203
212
|
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
204
213
|
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
205
214
|
- **Remixing.** A public game shares its source at \`/games/<id>/source.json\`, with who made it (the studio, the
|
|
@@ -256,6 +265,30 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
256
265
|
- A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
|
|
257
266
|
player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
|
|
258
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
|
+
|
|
259
292
|
## Continuing a build from the Claude app
|
|
260
293
|
|
|
261
294
|
A Claude Code session started from the Claude app's card gets one short line, like
|
|
@@ -473,6 +506,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
473
506
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
474
507
|
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
475
508
|
[`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
|
|
509
|
+
[`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
|
|
476
510
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
477
511
|
'.claude/skills/.gitkeep': '',
|
|
478
512
|
};
|
|
@@ -551,14 +585,27 @@ export function ensurePlayersMigration(root) {
|
|
|
551
585
|
return `site/migrations/${PLAYERS_MIGRATION_FILE}`;
|
|
552
586
|
}
|
|
553
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
|
+
|
|
554
601
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
555
602
|
export function ensureMigrations(root) {
|
|
556
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root)].filter(Boolean);
|
|
603
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root)].filter(Boolean);
|
|
557
604
|
}
|
|
558
605
|
|
|
559
606
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
560
607
|
export function migrationWord(file) {
|
|
561
|
-
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';
|
|
562
609
|
}
|
|
563
610
|
|
|
564
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
|
+
}
|
|
@@ -407,6 +407,61 @@
|
|
|
407
407
|
"## Beta": "9d02638207a26e73"
|
|
408
408
|
}
|
|
409
409
|
}
|
|
410
|
+
},
|
|
411
|
+
"0.15.0": {
|
|
412
|
+
"files": {
|
|
413
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
414
|
+
"README.md": "684b3cca8001673c",
|
|
415
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
416
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
417
|
+
"music/README.md": "b8a828de9311bd28",
|
|
418
|
+
"videos/README.md": "70614388cbb203c7",
|
|
419
|
+
"posts/README.md": "37841aae476bd540",
|
|
420
|
+
"site/README.md": "ca131cd0472464fb",
|
|
421
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
422
|
+
},
|
|
423
|
+
"sections": {
|
|
424
|
+
"AGENTS.md": {
|
|
425
|
+
"(intro)": "214aa46eb3a895d5",
|
|
426
|
+
"## Layout": "f14d641159480e6c",
|
|
427
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
428
|
+
"## The site": "ab922bb6bde23293",
|
|
429
|
+
"## Making games": "a8a893eeada33bf5",
|
|
430
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
431
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
432
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
433
|
+
"## Rules": "969318425c99f5a5",
|
|
434
|
+
"## Beta": "9d02638207a26e73"
|
|
435
|
+
}
|
|
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
|
+
}
|
|
410
465
|
}
|
|
411
466
|
}
|
|
412
467
|
}
|