@homie-rocks/studio 0.16.1 → 0.18.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 +27 -15
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +123 -16
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +32 -5
- package/lib/cloudflare.mjs +163 -9
- package/lib/mcp-tools.mjs +56 -0
- package/lib/media.mjs +185 -16
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +29 -10
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +8 -2
- package/site/SITE.md +6 -0
- package/starters/ember-vale/agents.json +34 -0
- package/starters/ember-vale/index.html +8 -0
- package/starters/ember-vale/src/main.ts +252 -5
- package/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +195 -21
- package/worker/office-page.mjs +13 -0
- package/worker/office.mjs +62 -8
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
package/bin/homie-studio.mjs
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* homie-studio game remix <source.json url> --id <new id>
|
|
18
18
|
* homie-studio games
|
|
19
19
|
* homie-studio build [<id>]
|
|
20
|
-
* homie-studio dev [--port 8787]
|
|
20
|
+
* homie-studio dev [--port 8787] [--remote-ai] (--stop: stop exactly this studio's dev server, nothing else;
|
|
21
|
+
* AI guides think scripted here unless --remote-ai: real Workers AI, billed)
|
|
21
22
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
22
23
|
* homie-studio chrome [install] [--fresh] (which Chrome the checks use; on Linux, `install` fetches Chrome for Testing;
|
|
23
24
|
* --fresh fetches it even when the machine has a Chrome)
|
|
@@ -31,8 +32,14 @@
|
|
|
31
32
|
* migrations and deploys: the Worker and database are the Deploy button's.
|
|
32
33
|
* homie-studio publish
|
|
33
34
|
* homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
|
|
34
|
-
* homie-studio media
|
|
35
|
-
* homie-studio media
|
|
35
|
+
* homie-studio media list (every song and video page, where each file is served from, what moves to R2)
|
|
36
|
+
* homie-studio media move [<file>...] [--dry-run] [--verify]
|
|
37
|
+
* (with storage: the big files of published songs and videos into R2, each
|
|
38
|
+
* read back and checked by SHA-256 before the site stops carrying it; the
|
|
39
|
+
* address stays the same and the file stays in this folder. With no file:
|
|
40
|
+
* everything over studio.json media.r2Over, 1 MiB unless set, or left out of
|
|
41
|
+
* git. Every deploy does it too. --verify re-checks what R2 holds)
|
|
42
|
+
* homie-studio media put <file> [--as <key>] (a loose file at /media/<key>; for a song or video, media move)
|
|
36
43
|
* homie-studio status
|
|
37
44
|
* homie-studio upgrade [--apply] [--diff]
|
|
38
45
|
* (what this version's template adds to an existing studio: AGENTS.md
|
|
@@ -71,9 +78,14 @@
|
|
|
71
78
|
* homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--days 7]
|
|
72
79
|
* (an AI's way into a seat, always marked AI; the pass is shown once)
|
|
73
80
|
* homie-studio agents passes [<game>] homie-studio agents revoke <pass id>
|
|
74
|
-
* homie-studio agents brain <game> <server> off|script|workers-ai|owner-key
|
|
81
|
+
* homie-studio agents brain <game> <server> off|script|workers-ai|owner-key [--budget <n>]
|
|
75
82
|
* (a narrowing change, closing a server, removing a member and the first
|
|
76
|
-
* time AI guides may talk are ASKED for, like office kick
|
|
83
|
+
* time AI guides may talk are ASKED for, like office kick; --budget is
|
|
84
|
+
* Workers AI neurons a day, or dollars a day for the owner's key)
|
|
85
|
+
* homie-studio agents brain key [--remove] (the owner's AI key, typed into a page on this computer only)
|
|
86
|
+
* homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
|
|
87
|
+
* (an AI guide's seat from this terminal: then lines of `do <goal> {args}`,
|
|
88
|
+
* `say <line> {args}`, `look`, `stand`)
|
|
77
89
|
*
|
|
78
90
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
79
91
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
@@ -130,14 +142,14 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
|
|
|
130
142
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
131
143
|
import { build } from '../lib/build.mjs';
|
|
132
144
|
import { check } from '../lib/check.mjs';
|
|
133
|
-
import { ciDeploy, deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
145
|
+
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
134
146
|
import { setupAttach } from '../lib/setup.mjs';
|
|
135
147
|
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
136
148
|
import { publish } from '../lib/directory.mjs';
|
|
137
149
|
import { look } from '../lib/look.mjs';
|
|
138
150
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
139
151
|
import { portCheck } from '../lib/port-check.mjs';
|
|
140
|
-
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
152
|
+
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
141
153
|
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
142
154
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
143
155
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
@@ -145,7 +157,8 @@ import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
|
145
157
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
146
158
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
147
159
|
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';
|
|
160
|
+
import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
|
|
161
|
+
import { AgentSeat } from '../lib/agent-seat.mjs';
|
|
149
162
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
150
163
|
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
151
164
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
@@ -157,7 +170,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
|
157
170
|
|
|
158
171
|
const argv = process.argv.slice(2);
|
|
159
172
|
const flags = new Map();
|
|
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'];
|
|
173
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify'];
|
|
161
174
|
const positional = [];
|
|
162
175
|
for (let i = 0; i < argv.length; i++) {
|
|
163
176
|
const a = argv[i];
|
|
@@ -203,10 +216,31 @@ function print(result) {
|
|
|
203
216
|
case 'media list':
|
|
204
217
|
for (const kind of ['music', 'videos']) {
|
|
205
218
|
lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
|
|
206
|
-
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
|
|
219
|
+
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from === 'r2' ? 'R2' : f.from}`).join(', ')})`);
|
|
207
220
|
for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
221
|
+
for (const m of result[kind].notes ?? []) lines.push(` note: ${m.item} ${m.file}: ${m.why}`);
|
|
208
222
|
}
|
|
223
|
+
if (result.moves.length) {
|
|
224
|
+
lines.push('', `Big media${result.r2 ? ' for R2' : ''} (over ${result.over === null ? '(off)' : lineOf(result.over)}, or left out of git):`);
|
|
225
|
+
for (const m of result.moves) lines.push(` ${m.path} ${m.bytes ? sizeOf(m.bytes) : ''} ${m.state === 'too-big' ? m.why : m.reason ?? ''}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.storage) lines.push('', result.storage);
|
|
228
|
+
if (result.r2) lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media once these are in. ${result.cost}`);
|
|
229
|
+
break;
|
|
230
|
+
case 'media move': {
|
|
231
|
+
const verb = result.dryRun ? 'Would move' : 'Moved';
|
|
232
|
+
if (result.ok === false && result.needs === 'storage') { lines.push(result.why); break; }
|
|
233
|
+
if (result.off) { lines.push(result.why); break; }
|
|
234
|
+
const todo = result.rows.filter((r) => r.state === 'move');
|
|
235
|
+
if (result.dryRun) lines.push(todo.length ? `${verb} ${todo.length} file(s) to R2 ${result.bucket}:` : 'Nothing to move: every big file is in R2 already, or none is published.', ...todo.map((r) => ` ${r.path} ${sizeOf(r.bytes)} (${r.reason})`));
|
|
236
|
+
else lines.push(result.moved.length ? `${verb} ${result.moved.length} file(s) to R2 ${result.bucket}, each read back and checked by SHA-256:` : 'Nothing moved.', ...result.moved.map((m) => ` ${m.path} ${sizeOf(m.bytes)} -> served at ${m.url} (the same address)`));
|
|
237
|
+
for (const f of result.failed ?? []) lines.push(` NOT moved: ${f.path}: ${f.why}`);
|
|
238
|
+
for (const v of result.verified ?? []) lines.push(` ${v.ok ? 'verified' : 'MISMATCH'}: ${v.path}${v.why ? `: ${v.why}` : ''}`);
|
|
239
|
+
for (const r of result.rows.filter((x) => x.state === 'too-big' || x.state === 'unlisted' || x.state === 'missing')) lines.push(` ${r.state}: ${r.path}: ${r.why}`);
|
|
240
|
+
if (result.next) lines.push('', result.next);
|
|
241
|
+
lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
|
|
209
242
|
break;
|
|
243
|
+
}
|
|
210
244
|
case 'deploy':
|
|
211
245
|
if (result.ci) {
|
|
212
246
|
lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}`));
|
|
@@ -214,7 +248,8 @@ function print(result) {
|
|
|
214
248
|
}
|
|
215
249
|
lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
|
|
216
250
|
...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
|
|
217
|
-
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
251
|
+
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
252
|
+
...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
|
|
218
253
|
result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the site claims itself when the directory first reads it; publish does).');
|
|
219
254
|
break;
|
|
220
255
|
case 'deploy plan':
|
|
@@ -302,8 +337,12 @@ function print(result) {
|
|
|
302
337
|
break;
|
|
303
338
|
case 'servers level':
|
|
304
339
|
case 'agents revoke':
|
|
340
|
+
case 'agents brain key':
|
|
305
341
|
lines.push(result.message);
|
|
306
342
|
break;
|
|
343
|
+
case 'agents sit':
|
|
344
|
+
lines.push(`Left ${result.room}.`);
|
|
345
|
+
break;
|
|
307
346
|
case 'agents pass':
|
|
308
347
|
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
348
|
break;
|
|
@@ -399,6 +438,12 @@ function print(result) {
|
|
|
399
438
|
if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
|
|
400
439
|
}
|
|
401
440
|
if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
|
|
441
|
+
if (result.media) {
|
|
442
|
+
lines.push('', `Big media (songs and videos over ${result.media.over === null ? '(off)' : lineOf(result.media.over)}, or left out of git):`);
|
|
443
|
+
for (const r of result.media.inR2) lines.push(` = ${r.path} in R2`);
|
|
444
|
+
for (const r of result.media.big) lines.push(` > ${r.path} ${r.bytes ? sizeOf(r.bytes) : ''} ${r.state === 'too-big' ? r.why : r.reason ?? ''}`);
|
|
445
|
+
if (result.media.next) lines.push(` ${result.media.next}`);
|
|
446
|
+
}
|
|
402
447
|
if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
403
448
|
break;
|
|
404
449
|
}
|
|
@@ -536,15 +581,27 @@ async function main() {
|
|
|
536
581
|
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
582
|
if (sub === 'passes') return agentsPasses(root, positional[2], { url });
|
|
538
583
|
if (sub === 'revoke') return agentsRevoke(root, positional[2], { url });
|
|
539
|
-
if (sub === 'brain') return
|
|
540
|
-
|
|
584
|
+
if (sub === 'brain' && positional[2] === 'key') return agentsBrainKey(root, { remove: flags.has('remove'), log });
|
|
585
|
+
if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url, budget: flags.get('budget') });
|
|
586
|
+
if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label') });
|
|
587
|
+
return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit)` };
|
|
541
588
|
}
|
|
542
589
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
590
|
+
if (cmd === 'media' && sub === 'move') return mediaMove(root, { paths: positional.slice(2), dryRun: flags.has('dry-run') || flags.has('plan'), verify: flags.has('verify'), log });
|
|
543
591
|
if (cmd === 'media' && sub === 'list') {
|
|
544
592
|
const studio = readStudio(root);
|
|
545
593
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
546
|
-
|
|
547
|
-
|
|
594
|
+
// What a deploy would serve (deploy: true), so "from r2" means the site no longer carries the file.
|
|
595
|
+
const view = (kind) => { const r = resolveMedia(root, kind, { r2, deploy: true }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped, notes: r.notes }; };
|
|
596
|
+
const over = r2OverOf(studio);
|
|
597
|
+
const plan = mediaPlan(root, { over });
|
|
598
|
+
const moves = plan.filter((x) => x.state === 'move' || x.state === 'too-big').map(({ abs, held, ...x }) => x);
|
|
599
|
+
const inR2 = plan.filter((x) => x.state === 'r2' || x.state === 'move').reduce((n, x) => n + (x.bytes ?? 0), 0);
|
|
600
|
+
return {
|
|
601
|
+
ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos'), over, moves, inR2,
|
|
602
|
+
storage: r2 ? (moves.length ? 'the next deploy (or media move now) puts these in R2, checked by SHA-256; their addresses stay the same' : 'every big file is in R2') : (moves.length ? 'no storage: these stay on the site (files up to 25 MiB each), and a deploy from another computer or Workers Builds leaves out any file git does not keep. homie-studio storage add (a payment method on the Cloudflare account, asked first) fixes both' : null),
|
|
603
|
+
cost: R2_COST,
|
|
604
|
+
};
|
|
548
605
|
}
|
|
549
606
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
550
607
|
}
|
|
@@ -714,7 +771,9 @@ async function dev(root) {
|
|
|
714
771
|
m.on('close', done);
|
|
715
772
|
});
|
|
716
773
|
log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
|
|
717
|
-
const
|
|
774
|
+
const ai = devConfig(root, flags.has('remote-ai'));
|
|
775
|
+
if (ai.note) log(ai.note);
|
|
776
|
+
const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
|
|
718
777
|
mkdirSync(dirname(devFile(root)), { recursive: true });
|
|
719
778
|
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
720
779
|
log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
|
|
@@ -725,6 +784,54 @@ async function dev(root) {
|
|
|
725
784
|
return { ok: true, command: 'dev', stopped: true };
|
|
726
785
|
}
|
|
727
786
|
|
|
787
|
+
/**
|
|
788
|
+
* AI guides under `dev` (0.17.0): they think scripted, for free, unless --remote-ai (real Workers AI on the signed-in
|
|
789
|
+
* account, billed like the live site). A config whose AI binding is not what was asked for runs from a copy in
|
|
790
|
+
* .wrangler/ (absolute paths, the same local state), so wrangler.jsonc itself is never changed by dev.
|
|
791
|
+
*/
|
|
792
|
+
function devConfig(root, remoteAi) {
|
|
793
|
+
const file = join(workerDir(root), 'wrangler.jsonc');
|
|
794
|
+
let json;
|
|
795
|
+
try { json = JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return { args: [] }; }
|
|
796
|
+
if (Boolean(json.ai) === remoteAi) return { args: [], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null };
|
|
797
|
+
const base = workerDir(root);
|
|
798
|
+
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
799
|
+
delete json.$schema;
|
|
800
|
+
json.main = abs(json.main);
|
|
801
|
+
if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
|
|
802
|
+
json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
|
|
803
|
+
if (remoteAi) json.ai = { binding: 'AI', remote: true }; else delete json.ai;
|
|
804
|
+
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
805
|
+
mkdirSync(dirname(copy), { recursive: true });
|
|
806
|
+
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
807
|
+
return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'AI guides think scripted here (no Workers AI under dev; --remote-ai for the real one).' };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
|
|
811
|
+
async function agentsSit(root, game, { url, server, pass, label }) {
|
|
812
|
+
const site = url ?? siteUrl(root);
|
|
813
|
+
const seat = new AgentSeat({ site, game, server: server ?? null, pass: pass ?? null, label: label ?? 'Claude', root });
|
|
814
|
+
const r = await seat.sit();
|
|
815
|
+
if (!r.ok) return { ok: false, command: 'agents sit', why: r.why };
|
|
816
|
+
log(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). Type: look | do <goal> {"arg":…} | say <line> {"arg":…} with a goal | stand`);
|
|
817
|
+
log(JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1));
|
|
818
|
+
const { createInterface } = await import('node:readline');
|
|
819
|
+
const rl = createInterface({ input: process.stdin });
|
|
820
|
+
let goal = null;
|
|
821
|
+
for await (const line of rl) {
|
|
822
|
+
const [word, id, ...rest] = line.trim().split(/\s+/);
|
|
823
|
+
let args = {};
|
|
824
|
+
try { args = rest.length ? JSON.parse(rest.join(' ')) : {}; } catch { log('arguments are JSON, e.g. {"place":"camp"}'); continue; }
|
|
825
|
+
if (word === 'look') log(JSON.stringify(seat.look(), null, 1));
|
|
826
|
+
else if (word === 'do') { goal = { goal: id, args }; const d = await seat.do(goal); log(d.ok ? `done: ${JSON.stringify(d.sent)}` : `refused: ${d.why}`); }
|
|
827
|
+
else if (word === 'say') { const d = await seat.do({ ...(goal ?? { goal: Object.keys(seat.vocab.goals)[0], args: {} }), say: id, sayArgs: args }); log(d.ok ? `said: ${d.text}` : `refused: ${d.why}`); }
|
|
828
|
+
else if (word === 'stand') break;
|
|
829
|
+
}
|
|
830
|
+
rl.close();
|
|
831
|
+
await seat.stand();
|
|
832
|
+
return { ok: true, command: 'agents sit', stood: true, room: r.room };
|
|
833
|
+
}
|
|
834
|
+
|
|
728
835
|
/** A big file into the studio's R2, and its key on the music/ or videos/ manifest entry that names it. */
|
|
729
836
|
function mediaPut(root, file, as) {
|
|
730
837
|
if (!file || !existsSync(file) || !statSync(file).isFile()) return { ok: false, command: 'media put', why: 'usage: homie-studio media put <file> [--as <key>]' };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN AI IN A SEAT, FROM THIS COMPUTER (@homie-rocks/studio 0.17.0, NETPLAY.md section 18): the local MCP's agent_sit,
|
|
3
|
+
* agent_look, agent_do and agent_stand, and `homie-studio agents sit`. The person's own AI (Claude in the desktop app,
|
|
4
|
+
* Claude Code, any MCP client) takes a guide's seat with hands `host`: the host's game moves the body every frame at the
|
|
5
|
+
* party's dial, while this holds the socket between the AI's turns (about 30 s a decision), keeps the latest view the
|
|
6
|
+
* host showed it and the asks made of it, and sends its goals and lines: only the game's own vocabulary (agents.json),
|
|
7
|
+
* which the relay checks again. It always plays as "<label> · AI".
|
|
8
|
+
*
|
|
9
|
+
* The pass: one the owner gave (`hap_…`), or, from a studio folder, a one-day guide pass minted with the owner's office
|
|
10
|
+
* key and revoked when the AI stands. The secret stays in this process; it is never printed or returned.
|
|
11
|
+
*/
|
|
12
|
+
import { parseDecision, renderLine, vocabularyOf } from '../worker/brain.mjs';
|
|
13
|
+
import { withKey } from './office.mjs';
|
|
14
|
+
|
|
15
|
+
const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
16
|
+
const SERVER = /^[a-z0-9][a-z0-9-]{1,19}$/;
|
|
17
|
+
const PASS = /^hap_[a-f0-9]{10}_[A-Za-z0-9_-]{40}$/;
|
|
18
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
export class AgentSeat {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} o
|
|
23
|
+
* @param {string} o.site the studio's site (https://…, or http://127.0.0.1:8787 for `homie-studio dev`)
|
|
24
|
+
* @param {string} o.game
|
|
25
|
+
* @param {string} [o.server] a server id (else the pass's, else public)
|
|
26
|
+
* @param {string} [o.pass] an agent pass (hap_…); else one is minted from `root` with the office key
|
|
27
|
+
* @param {string} [o.root] the studio folder (for a pass of its own)
|
|
28
|
+
*/
|
|
29
|
+
constructor({ site, game, server = null, pass = null, label = 'Claude', root = null, WebSocketImpl = globalThis.WebSocket, fetchImpl = globalThis.fetch }) {
|
|
30
|
+
this.site = String(site ?? '').replace(/\/+$/, '');
|
|
31
|
+
this.game = game;
|
|
32
|
+
this.server = server;
|
|
33
|
+
this.pass = pass;
|
|
34
|
+
this.label = label;
|
|
35
|
+
this.root = root;
|
|
36
|
+
this.WS = WebSocketImpl;
|
|
37
|
+
this.fetch = fetchImpl;
|
|
38
|
+
this.minted = null;
|
|
39
|
+
this.ws = null;
|
|
40
|
+
this.seat = null;
|
|
41
|
+
this.name = null;
|
|
42
|
+
this.room = null;
|
|
43
|
+
this.policy = null;
|
|
44
|
+
this.view = null;
|
|
45
|
+
this.viewAt = 0;
|
|
46
|
+
this.asks = [];
|
|
47
|
+
this.party = [];
|
|
48
|
+
this.lastDo = null;
|
|
49
|
+
this.doAt = 0;
|
|
50
|
+
this.sayAt = 0;
|
|
51
|
+
this.closed = null;
|
|
52
|
+
this.errors = [];
|
|
53
|
+
this.vocab = null;
|
|
54
|
+
this.ping = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async sit() {
|
|
58
|
+
if (!GAME.test(String(this.game ?? ''))) return { ok: false, why: 'game is the game\'s id' };
|
|
59
|
+
if (this.server !== null && !SERVER.test(String(this.server)) && this.server !== 'public') return { ok: false, why: 'server is a server id' };
|
|
60
|
+
if (!this.site) return { ok: false, why: 'no site: run the studio here (preview_run / homie-studio dev) or deploy it, or give url' };
|
|
61
|
+
if (!this.WS) return { ok: false, why: 'this Node has no WebSocket (Node 22 or newer)' };
|
|
62
|
+
// The vocabulary: what this AI may choose and say, from the built game.
|
|
63
|
+
try {
|
|
64
|
+
const res = await this.fetch(`${this.site}/games/${this.game}/agents.json`, { signal: AbortSignal.timeout(10_000) });
|
|
65
|
+
if (res.ok) this.vocab = vocabularyOf(await res.json()).vocab;
|
|
66
|
+
} catch { this.vocab = null; }
|
|
67
|
+
if (!this.vocab) return { ok: false, why: `${this.game} has no agents.json (the guides' vocabulary): an AI with no game client has nothing to choose from` };
|
|
68
|
+
if (!this.pass) {
|
|
69
|
+
if (!this.root) return { ok: false, why: 'give pass (an agent pass, hap_…), or run this from the studio folder' };
|
|
70
|
+
const r = await withKey(this.root, this.site, (call) => call('/_studio/api/agents/pass', { action: 'create', game: this.game, label: this.label, role: 'guide', hands: 'host', days: 1, server: this.server && this.server !== 'public' ? this.server : null }));
|
|
71
|
+
if (!r.ok || !PASS.test(String(r.secret ?? ''))) return { ok: false, why: `could not make a pass: ${r.message ?? r.why ?? 'the office did not answer'}` };
|
|
72
|
+
this.pass = r.secret;
|
|
73
|
+
this.minted = r.pass?.id ?? null;
|
|
74
|
+
}
|
|
75
|
+
let sat;
|
|
76
|
+
try {
|
|
77
|
+
const res = await this.fetch(`${this.site}/${this.game}/api/agent`, {
|
|
78
|
+
method: 'POST', headers: { authorization: `Bearer ${this.pass}`, 'content-type': 'application/json', 'user-agent': 'homie-studio-agent' },
|
|
79
|
+
body: JSON.stringify(this.server ? { server: this.server } : {}), signal: AbortSignal.timeout(15_000),
|
|
80
|
+
});
|
|
81
|
+
sat = await res.json().catch(() => ({ ok: false, message: `the site answered ${res.status}` }));
|
|
82
|
+
} catch (error) { sat = { ok: false, message: error.message }; }
|
|
83
|
+
if (!sat.ok) { await this.revoke(); return { ok: false, why: sat.message ?? sat.error ?? 'the seat was refused', error: sat.error ?? null }; }
|
|
84
|
+
if (sat.hands !== 'host') { await this.revoke(); return { ok: false, why: 'this pass is for an AI that runs the game itself (hands self); agent_sit needs hands host' }; }
|
|
85
|
+
this.room = sat.room;
|
|
86
|
+
this.server = sat.server;
|
|
87
|
+
const ws = new this.WS(sat.ws);
|
|
88
|
+
this.ws = ws;
|
|
89
|
+
const welcome = await new Promise((done) => {
|
|
90
|
+
const timer = setTimeout(() => done({ t: 'timeout' }), 12_000);
|
|
91
|
+
ws.onopen = () => ws.send(JSON.stringify({ ...sat.hello, device: 'desk' }));
|
|
92
|
+
ws.onmessage = (e) => {
|
|
93
|
+
let m; try { m = JSON.parse(String(e.data)); } catch { return; }
|
|
94
|
+
if (m.t === 'welcome' || (m.t === 'error' && !this.seat)) { clearTimeout(timer); done(m); }
|
|
95
|
+
this.onFrame(m);
|
|
96
|
+
};
|
|
97
|
+
ws.onclose = (e) => { this.closed = this.closed ?? (e?.reason || 'closed'); clearTimeout(timer); done({ t: 'closed' }); };
|
|
98
|
+
ws.onerror = () => {};
|
|
99
|
+
});
|
|
100
|
+
if (welcome.t !== 'welcome') { this.close(); await this.revoke(); return { ok: false, why: welcome.message ?? `no seat (${welcome.code ?? welcome.t})`, error: welcome.code ?? welcome.t }; }
|
|
101
|
+
this.ping = setInterval(() => { try { ws.send(JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ } }, 2000);
|
|
102
|
+
// The host shows a guide the game every 2 s: wait for the first look (up to 6 s).
|
|
103
|
+
for (let i = 0; i < 30 && !this.view && !this.closed; i += 1) await wait(200);
|
|
104
|
+
return { ok: true, seat: this.seat, name: this.name, room: this.room, server: this.server, ...this.look() };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
onFrame(m) {
|
|
108
|
+
switch (m.t) {
|
|
109
|
+
case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; return;
|
|
110
|
+
case 'policy': this.policy = m.policy ?? this.policy; return;
|
|
111
|
+
case 'error': this.errors = [...this.errors, { code: m.code, message: m.message, at: Date.now() }].slice(-6); if (['agents-alone', 'agents-off', 'kicked', 'room-closed', 'agent-yield'].includes(m.code)) this.closed = m.code; return;
|
|
112
|
+
case 'ev': {
|
|
113
|
+
const k = String(m.k ?? '');
|
|
114
|
+
const d = m.d && typeof m.d === 'object' ? m.d : {};
|
|
115
|
+
if (k === 'agent:view') { this.view = d; this.viewAt = Date.now(); return; }
|
|
116
|
+
const ask = /^ask:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
|
|
117
|
+
if (ask && d.seat === this.seat) { this.asks = [...this.asks, { k: ask, args: d.args ?? {}, from: m.from, at: Date.now() }].slice(-6); return; }
|
|
118
|
+
const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
|
|
119
|
+
if (line && m.from !== this.seat) this.party = [...this.party, { seat: m.from, line, args: d.args ?? {}, at: Date.now() }].slice(-6);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
default: return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the AI needs to decide: the view, the asks made of it, the party's lines, its choices (ids only). */
|
|
127
|
+
look() {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const v = this.vocab;
|
|
130
|
+
const describe = (entries) => Object.fromEntries(Object.entries(entries ?? {}).map(([id, e]) => [id, { ...(e.about ? { about: e.about } : { text: e.text }), args: Object.fromEntries(Object.entries(e.args ?? {}).map(([k, s]) => [k, s.kind === 'player' ? 'player (a seat number)' : s.kind === 'list' ? s.values : `view.${s.key}`])) }]));
|
|
131
|
+
return {
|
|
132
|
+
seat: this.seat, name: this.name, room: this.room, closed: this.closed,
|
|
133
|
+
talking: Boolean(this.policy && ['workers-ai', 'owner-key'].includes(this.policy.brain) && this.policy.speech !== 'off'),
|
|
134
|
+
view: this.view, viewAgeMs: this.viewAt ? now - this.viewAt : null,
|
|
135
|
+
asks: this.asks.filter((a) => now - a.at < 60_000).map((a) => ({ ask: a.k, args: a.args, seat: a.from, text: v ? renderLine(v, a.k, a.args, { kind: 'asks', nameOf: (s) => `seat ${s}` }) : null, secondsAgo: Math.round((now - a.at) / 1000) })),
|
|
136
|
+
party: this.party.filter((p) => now - p.at < 60_000).map((p) => ({ seat: p.seat, line: p.line, args: p.args })),
|
|
137
|
+
lastDo: this.lastDo,
|
|
138
|
+
choices: v ? { goals: describe(v.goals), lines: describe(v.lines) } : null,
|
|
139
|
+
errors: this.errors.slice(-3),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One decision: a goal (and at most one line) of the vocabulary, checked here first and again by the relay. */
|
|
144
|
+
async do({ goal, args = {}, say = null, sayArgs = {} } = {}) {
|
|
145
|
+
if (!this.ws || this.closed) return { ok: false, why: this.closed ? `the seat closed (${this.closed}); agent_sit again` : 'not seated: agent_sit first' };
|
|
146
|
+
const p = parseDecision({ goal, args, say: say || null, sayArgs: say ? sayArgs : {} }, this.vocab, { view: this.view });
|
|
147
|
+
if (!p.ok) return { ok: false, why: `${p.why}. Choose a goal and line from look().choices, with values the view offers.` };
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (now - this.doAt < 3000) await wait(3000 - (now - this.doAt));
|
|
150
|
+
if (!this.talking() && p.decision.say) return { ok: false, why: 'this server\'s AI may not talk (its owner has not turned AI talk on); choose a goal with say null' };
|
|
151
|
+
if (p.decision.say && Date.now() - this.sayAt < 8000) return { ok: false, why: 'a guide says at most one line every 8 seconds; send the goal alone, or wait' };
|
|
152
|
+
this.ws.send(JSON.stringify({ t: 'ev', k: 'agent:do', d: { goal: p.decision.goal, args: p.decision.args } }));
|
|
153
|
+
this.doAt = Date.now();
|
|
154
|
+
if (p.decision.say) { this.ws.send(JSON.stringify({ t: 'ev', k: `say:${p.decision.say}`, d: { args: p.decision.sayArgs } })); this.sayAt = Date.now(); }
|
|
155
|
+
this.lastDo = { goal: p.decision.goal, args: p.decision.args, say: p.decision.say, at: new Date().toISOString() };
|
|
156
|
+
this.asks = [];
|
|
157
|
+
await wait(300);
|
|
158
|
+
const refused = this.errors.find((e) => e.at >= this.doAt - 50);
|
|
159
|
+
return refused ? { ok: false, why: `the room said ${refused.code}: ${refused.message}` } : { ok: true, sent: this.lastDo, text: p.decision.say ? renderLine(this.vocab, p.decision.say, p.decision.sayArgs, { me: this.name, nameOf: (s) => `seat ${s}`, view: this.view }) : null };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
talking() { return Boolean(this.policy && ['workers-ai', 'owner-key'].includes(this.policy.brain) && this.policy.speech !== 'off'); }
|
|
163
|
+
|
|
164
|
+
close() {
|
|
165
|
+
if (this.ping) clearInterval(this.ping);
|
|
166
|
+
this.ping = null;
|
|
167
|
+
if (this.ws) { try { this.ws.send(JSON.stringify({ t: 'bye' })); } catch { /* closed */ } try { this.ws.close(1000, 'bye'); } catch { /* closed */ } }
|
|
168
|
+
this.ws = null;
|
|
169
|
+
this.closed = this.closed ?? 'stood';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A pass this seat minted ends with it. */
|
|
173
|
+
async revoke() {
|
|
174
|
+
if (!this.minted || !this.root) return;
|
|
175
|
+
const id = this.minted;
|
|
176
|
+
this.minted = null;
|
|
177
|
+
await withKey(this.root, this.site, (call) => call('/_studio/api/agents/pass', { action: 'revoke', id })).catch(() => {});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async stand() {
|
|
181
|
+
this.close();
|
|
182
|
+
await this.revoke();
|
|
183
|
+
return { ok: true, stood: true, room: this.room };
|
|
184
|
+
}
|
|
185
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
|
|
6
6
|
* site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
|
|
7
7
|
* site/dist/games/<id>/... everything in games/<id>/public/
|
|
8
|
+
* site/dist/games/<id>/agents.json the AI guides' vocabulary (games/<id>/agents.json, checked; NETPLAY.md
|
|
9
|
+
* section 18): the only goals and lines an AI in its rooms has
|
|
8
10
|
* site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
|
|
9
11
|
* game.json files, the music/ and videos/ manifests (media/MEDIA.md),
|
|
10
12
|
* posts/*.md and the studio's site/ folder (site/SITE.md)
|
|
@@ -13,7 +15,11 @@
|
|
|
13
15
|
* site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
|
|
14
16
|
* the studio's owner (worker/stats-page.mjs), never listed or indexed
|
|
15
17
|
* site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
|
|
16
|
-
* site/dist/music/..., site/dist/videos/... media files the site serves itself
|
|
18
|
+
* site/dist/music/..., site/dist/videos/... media files the site serves itself. A file the studio's R2 has (media
|
|
19
|
+
* move: the same bytes, by SHA-256) is left out of a deploy's build and
|
|
20
|
+
* served from R2 at the same address; a local build still copies it when it
|
|
21
|
+
* fits, for `dev` (whose R2 is empty). Workers Builds (WORKERS_CI=1) builds
|
|
22
|
+
* as a deploy.
|
|
17
23
|
*
|
|
18
24
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
19
25
|
* (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
|
|
@@ -35,6 +41,7 @@ import { SEAT_MAX } from '../worker/seats.mjs';
|
|
|
35
41
|
import { STUDIO_VERSION } from './version.mjs';
|
|
36
42
|
import { licenseOf, remixRow } from '../worker/license.mjs';
|
|
37
43
|
import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
|
|
44
|
+
import { vocabularyOf } from '../worker/brain.mjs';
|
|
38
45
|
|
|
39
46
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
40
47
|
/** Never copied into a static game's served folder. */
|
|
@@ -152,6 +159,22 @@ export function netplayRevOf(out) {
|
|
|
152
159
|
return rev;
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
/**
|
|
163
|
+
* games/<id>/agents.json, checked (worker/brain.mjs vocabularyOf: argument types, text over 120 characters, a goal
|
|
164
|
+
* against a player, an ask naming a goal or line that is not there) and served beside the game, where the room's Table
|
|
165
|
+
* reads it. A game with none has guides that play but never talk. Returns whether it has one.
|
|
166
|
+
*/
|
|
167
|
+
export function vocabFor(g, out) {
|
|
168
|
+
const file = join(g.dir, 'agents.json');
|
|
169
|
+
if (!existsSync(file)) { rmSync(join(out, 'agents.json'), { force: true }); return false; }
|
|
170
|
+
let raw;
|
|
171
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`games/${g.id}/agents.json is not JSON: ${error.message}`); }
|
|
172
|
+
const v = vocabularyOf(raw);
|
|
173
|
+
if (!v.ok) throw new Error(`games/${g.id}/agents.json: ${v.problems.slice(0, 6).join('; ')}`);
|
|
174
|
+
writeFileSync(join(out, 'agents.json'), `${JSON.stringify(raw)}\n`);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
/** game.json "servers": the seeds a game ships with (worker/servers.mjs; a D1 row of the same id wins). */
|
|
156
179
|
function serverSeeds(g, log) {
|
|
157
180
|
if (!Array.isArray(g.servers)) return [];
|
|
@@ -182,7 +205,7 @@ export function seatsFor(g, net = netplayOf(g)) {
|
|
|
182
205
|
return { min: Math.min(max, minNamed ?? 1), max, asked };
|
|
183
206
|
}
|
|
184
207
|
|
|
185
|
-
export async function build(root, { only = null, log = () => {} } = {}) {
|
|
208
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1' } = {}) {
|
|
186
209
|
const require = createRequire(join(root, 'package.json'));
|
|
187
210
|
let esbuild;
|
|
188
211
|
try { esbuild = require('esbuild'); } catch { esbuild = await import('esbuild'); }
|
|
@@ -236,6 +259,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
236
259
|
}
|
|
237
260
|
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
238
261
|
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
262
|
+
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
263
|
+
vocabFor(g, out);
|
|
239
264
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
240
265
|
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
|
|
241
266
|
const main = join(out, 'assets', 'main.js');
|
|
@@ -249,9 +274,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
249
274
|
// Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
|
|
250
275
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
251
276
|
let media = null;
|
|
252
|
-
if (!only) media = buildMedia(root, dist, { r2, log });
|
|
277
|
+
if (!only) media = buildMedia(root, dist, { r2, deploy, log });
|
|
253
278
|
else {
|
|
254
|
-
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
|
|
279
|
+
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [], notes: [] }; } catch { media = { songs: [], videos: [], skipped: [], notes: [] }; }
|
|
255
280
|
}
|
|
256
281
|
// The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
|
|
257
282
|
const theme = readTheme(root, { log });
|
|
@@ -277,6 +302,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
277
302
|
// predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
|
|
278
303
|
...(seeds.length ? { servers: seeds } : {}),
|
|
279
304
|
netplayRev: netplayRevOf(join(dist, 'games', g.id)),
|
|
305
|
+
// 0.17.0: the game has a vocabulary for its AI guides (agents.json), so they can talk once the owner says so.
|
|
306
|
+
...(existsSync(join(dist, 'games', g.id, 'agents.json')) ? { vocab: true } : {}),
|
|
280
307
|
...(g.agents && typeof g.agents === 'object' && (g.agents.vote === 'game' || g.agents.vote === false) ? { agents: { vote: g.agents.vote } } : {}),
|
|
281
308
|
// Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
|
|
282
309
|
license: licenseOf(g.license),
|
|
@@ -322,7 +349,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
322
349
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
323
350
|
return {
|
|
324
351
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
325
|
-
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
352
|
+
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
|
|
326
353
|
posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
327
354
|
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
|
|
328
355
|
};
|