@homie-rocks/studio 0.51.0 → 0.51.2
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/CHANGELOG.md +32 -0
- package/README.md +15 -4
- package/bin/homie-studio.mjs +7 -7
- package/guides/app/SKILL.md +14 -0
- package/guides/game/RULES.md +26 -0
- package/guides/office/SKILL.md +9 -0
- package/guides/studio-setup/SKILL.md +9 -1
- package/lib/app-preset-history.json +840 -0
- package/lib/build.mjs +4 -2
- package/lib/cloudflare.mjs +19 -10
- package/lib/dev.mjs +1 -1
- package/lib/directory.mjs +22 -5
- package/lib/giving-preset.mjs +9 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/office.mjs +2 -1
- package/lib/scaffold.mjs +7 -4
- package/lib/shop.mjs +2 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade-app-presets.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/mcp/ui/build.js +3 -2
- package/package.json +1 -1
- package/rules/correction-path.ts +35 -0
- package/rules/map-index.ts +4 -4
- package/rules/view.ts +196 -28
- package/site/SITE.md +13 -0
- package/tools/TOOLS.md +9 -0
- package/worker/arrival.mjs +2 -1
- package/worker/build-id.mjs +1 -1
- package/worker/directory-choice.mjs +9 -0
- package/worker/index.mjs +18 -9
- package/worker/mcp.mjs +2 -2
- package/worker/pages.mjs +3 -2
- package/worker/records-page.mjs +1 -1
- package/worker/signin-target.mjs +8 -0
- package/worker/site.mjs +2 -0
- package/worker/stats-page.mjs +6 -3
- package/worker/version.mjs +1 -1
package/lib/build.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
2
|
+
import { domainOrigin } from './studio.mjs';
|
|
1
3
|
import { appProblems } from '../worker/app-format.mjs';
|
|
2
4
|
/**
|
|
3
5
|
* `homie-studio build` — every game in games/ bundled into site/dist, plus the
|
|
@@ -707,8 +709,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
707
709
|
...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
|
|
708
710
|
// The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
|
|
709
711
|
// HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
|
|
710
|
-
directory:
|
|
711
|
-
|
|
712
|
+
directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
|
|
713
|
+
canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
|
|
712
714
|
// A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
|
|
713
715
|
...(studio.template === true ? { template: true } : {}),
|
|
714
716
|
// studio.json audience is a studio label; shop policies are optional.
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
1
2
|
import { roomLayout } from '../worker/gate.mjs';
|
|
2
3
|
import {workerEntry} from './worker-build.mjs';
|
|
3
4
|
import { seatCount } from '../worker/seats.mjs';
|
|
@@ -51,6 +52,11 @@ import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
|
51
52
|
|
|
52
53
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
53
54
|
|
|
55
|
+
export function experienceAddresses(root, id, url) {
|
|
56
|
+
const app = listGames(root).find(g => g.id === id)?.kind === 'app';
|
|
57
|
+
return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
export function wranglerBin(root) {
|
|
55
61
|
const local = join(root, 'node_modules', '.bin', 'wrangler');
|
|
56
62
|
return existsSync(local) ? local : null;
|
|
@@ -65,6 +71,8 @@ export function runner(root, env) {
|
|
|
65
71
|
const bin = wranglerBin(root);
|
|
66
72
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
67
73
|
return (args, { cwd = workerDir(root), input } = {}) => {
|
|
74
|
+
const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
|
|
75
|
+
if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
|
|
68
76
|
const res = spawnSync(bin, args, {
|
|
69
77
|
cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
70
78
|
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
@@ -211,11 +219,13 @@ export function deployPlan(root) {
|
|
|
211
219
|
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
212
220
|
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
213
221
|
],
|
|
214
|
-
cost:
|
|
222
|
+
cost: null,
|
|
215
223
|
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
216
224
|
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
217
225
|
directory: {
|
|
218
|
-
site: studio.homie?.directory
|
|
226
|
+
site: directoryUrl(studio.homie?.directory),
|
|
227
|
+
requested: Boolean(directoryUrl(studio.homie?.directory)),
|
|
228
|
+
choice: 'A deploy claims a listing only when homie.directory is true or a directory URL. Existing claims stay honoured; studios that never asked are not listed.',
|
|
219
229
|
stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
|
|
220
230
|
},
|
|
221
231
|
never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
|
|
@@ -302,8 +312,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
302
312
|
const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
|
|
303
313
|
announced.push(
|
|
304
314
|
`First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
|
|
305
|
-
|
|
306
|
-
`The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
|
|
315
|
+
deployPlan(root).directory.choice,
|
|
307
316
|
);
|
|
308
317
|
for (const line of announced) log(line);
|
|
309
318
|
}
|
|
@@ -425,7 +434,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
425
434
|
if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
|
|
426
435
|
step(`closed ${synced.expired} obsolete part Checkouts`);
|
|
427
436
|
}
|
|
428
|
-
const directory = homie
|
|
437
|
+
const directory = directoryUrl(homie ?? studio.homie?.directory);
|
|
429
438
|
recordDeploy(root, builtNow);
|
|
430
439
|
let claim = null;
|
|
431
440
|
if (url) {
|
|
@@ -441,10 +450,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
441
450
|
} else if (read.state === 'unreachable') {
|
|
442
451
|
// This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
|
|
443
452
|
// verdict on the site that was just deployed.
|
|
444
|
-
const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'}
|
|
453
|
+
const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} Listing follows the studio’s directory choice.`;
|
|
445
454
|
notes.push(said);
|
|
446
455
|
step(said, { preflight: read.preflight ?? 'network' });
|
|
447
|
-
} else step('the directory claim is not there yet
|
|
456
|
+
} else step(directory ? 'the requested directory claim is not there yet' : 'no new listing requested; existing claims are honoured');
|
|
448
457
|
}
|
|
449
458
|
|
|
450
459
|
// The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
|
|
@@ -454,7 +463,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
454
463
|
let reread = null;
|
|
455
464
|
if (url && delta.withdrawn.length) {
|
|
456
465
|
const which = delta.withdrawn.join(', ');
|
|
457
|
-
const dir =
|
|
466
|
+
const dir = directory ?? readLocal(root).listed?.directory;
|
|
458
467
|
if (listedHere(root, { site: url, directory: dir })) {
|
|
459
468
|
const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
|
|
460
469
|
reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
|
|
@@ -474,7 +483,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
474
483
|
return {
|
|
475
484
|
ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
476
485
|
...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
|
|
477
|
-
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null,
|
|
486
|
+
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
|
|
478
487
|
...(delta.removed.length ? { removed: delta.removed } : {}),
|
|
479
488
|
...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
|
|
480
489
|
...(zone ? { zone: zoneView(zone) } : {}),
|
|
@@ -643,7 +652,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
643
652
|
ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
644
653
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
645
654
|
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
646
|
-
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null,
|
|
655
|
+
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
|
647
656
|
claim: Boolean(claim),
|
|
648
657
|
};
|
|
649
658
|
}
|
package/lib/dev.mjs
CHANGED
|
@@ -197,7 +197,7 @@ export function devConfig(root, remoteAi) {
|
|
|
197
197
|
const routed = ROUTE_KEYS.some((k) => k in json);
|
|
198
198
|
const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
|
|
199
199
|
const entry=workerEntry(root);
|
|
200
|
-
|
|
200
|
+
// Always retain the exact local binding configuration for CLI commands after a deploy.
|
|
201
201
|
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
202
202
|
delete json.$schema;
|
|
203
203
|
json.main = entry ?? abs(json.main);
|
package/lib/directory.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* claim the directory gave it (`homie-studio deploy` stores it in D1), so
|
|
5
5
|
* nobody can list games under someone else's site.
|
|
6
6
|
*/
|
|
7
|
+
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
7
8
|
import { request } from './net.mjs';
|
|
8
|
-
import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
|
|
9
|
+
import { domainOrigin, listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
|
|
9
10
|
import { licenceProblems, readManifest } from './asset-manifest.mjs';
|
|
10
11
|
import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
|
|
11
12
|
|
|
@@ -103,8 +104,8 @@ function licenceRefusals(root) {
|
|
|
103
104
|
*/
|
|
104
105
|
export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
|
|
105
106
|
const studio = readStudio(root);
|
|
106
|
-
const directory = (homie
|
|
107
|
-
const url = site || siteUrl(root, studio);
|
|
107
|
+
const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
|
|
108
|
+
const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
|
|
108
109
|
const refused = licenceRefusals(root);
|
|
109
110
|
const parts = partsForListing(root);
|
|
110
111
|
const local = publishesSoFar(root);
|
|
@@ -140,8 +141,8 @@ export async function publishBefore(root, { homie, site, log = () => {}, fetchFn
|
|
|
140
141
|
|
|
141
142
|
export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
|
|
142
143
|
const studio = readStudio(root);
|
|
143
|
-
const directory = (homie
|
|
144
|
-
const url = site || siteUrl(root, studio);
|
|
144
|
+
const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
|
|
145
|
+
const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
|
|
145
146
|
// Licences first: a public game may ship only assets with a licence record that allows it.
|
|
146
147
|
const refused = licenceRefusals(root);
|
|
147
148
|
if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
|
|
@@ -149,6 +150,22 @@ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {
|
|
|
149
150
|
// Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
|
|
150
151
|
// cannot be combined, in the same lines the deploy plan gave (lib/parts-build.mjs). Said before anything is sent.
|
|
151
152
|
// It does not stop the listing: the person decides, having been told.
|
|
153
|
+
// An explicit publish is itself the choice to list. Seed the same D1 claim the
|
|
154
|
+
// Worker uses, so a studio deployed without opt-in does not need another deploy.
|
|
155
|
+
if (!directoryUrl(studio.homie?.directory)) {
|
|
156
|
+
let origin;
|
|
157
|
+
try { origin = new URL(url).origin; } catch { return {ok:false,command:'publish',why:'Invalid studio address'}; }
|
|
158
|
+
const claimed = await request(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {}, {timeout:15_000,...(fetchFn?{fetchFn}:{})});
|
|
159
|
+
const token = claimed.body?.claim;
|
|
160
|
+
if (!claimed.ok || !/^[a-f0-9]{16,128}$/.test(token ?? '')) return {ok:false,command:'publish',why:claimed.why ?? 'The directory did not return a claim; nothing was listed.'};
|
|
161
|
+
const {runner} = await import('./cloudflare.mjs');
|
|
162
|
+
const {loopback} = await import('./stats.mjs');
|
|
163
|
+
const local = loopback(origin);
|
|
164
|
+
const w = runner(root, !local && studio.cloudflare?.accountId ? {CLOUDFLARE_ACCOUNT_ID:studio.cloudflare.accountId} : {});
|
|
165
|
+
const saved = w(['d1','execute',studio.cloudflare.d1,local?'--local':'--remote','--command',`INSERT OR REPLACE INTO meta (key,value) VALUES ('homie_claim:${origin.replaceAll("'", "''")}','${token}');`]);
|
|
166
|
+
if(saved.code!==0)return {ok:false,command:'publish',why:'Could not store the directory claim in the studio database: '+saved.out};
|
|
167
|
+
writeStudio(root,{...studio,homie:{...studio.homie,directory}});
|
|
168
|
+
}
|
|
152
169
|
const parts = partsForListing(root);
|
|
153
170
|
for (const line of partsPlanLines(parts, { at: 'publish' })) log(line);
|
|
154
171
|
const before = publishesSoFar(root);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Defaults shared by shop init and the upgrade of an untouched earlier preset. */
|
|
2
|
+
export function givingItems(amounts = [1000, 2000, 2500, 5000]) {
|
|
3
|
+
return amounts.flatMap(amount => [false, true].map(monthly => ({
|
|
4
|
+
id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation',
|
|
5
|
+
name: monthly ? 'Monthly gift' : 'One-time gift', price: amount,
|
|
6
|
+
...(monthly ? { interval: 'month' } : {}), gives: [],
|
|
7
|
+
blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.',
|
|
8
|
+
})));
|
|
9
|
+
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1171,7 +1171,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1171
1171
|
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
1172
1172
|
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
1173
1173
|
const p = r.result;
|
|
1174
|
-
return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}
|
|
1174
|
+
return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), ...(p.cost ? [`Cost: ${p.cost}`] : []), `Sign-in: ${p.login}`, ...(p.directory?.choice ? [p.directory.choice] : []), ...(p.directory?.site ? [`The directory stores: ${p.directory.stores}`] : []), ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1175
1175
|
}
|
|
1176
1176
|
const studio = readStudio(root);
|
|
1177
1177
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
|
package/lib/office.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { signinTarget } from '../worker/signin-target.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
|
|
3
4
|
*
|
|
@@ -83,7 +84,7 @@ export async function officeShow(root, { url } = {}) {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export function officeLink(root, { url, to } = {}) {
|
|
86
|
-
const target = to
|
|
87
|
+
const target = signinTarget(to, '/_studio/office');
|
|
87
88
|
const r = signinTo(root, url, target);
|
|
88
89
|
if (!r.ok) return { ok: false, command: 'office link', why: r.why };
|
|
89
90
|
return {
|
package/lib/scaffold.mjs
CHANGED
|
@@ -245,8 +245,8 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
245
245
|
- **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
|
|
246
246
|
every push to \`main\` it runs \`npm run build\` and \`npm run deploy\`, which in Workers Builds only applies
|
|
247
247
|
the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
|
|
248
|
-
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms.
|
|
249
|
-
|
|
248
|
+
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. A live site requests a directory claim only with
|
|
249
|
+
studio.json \`homie.directory: true\` (homie.rocks) or a directory URL. Existing claims stay honoured.
|
|
250
250
|
- \`npx --no-install homie-studio storage add\` — only when the studio has songs or videos: an R2
|
|
251
251
|
bucket on the studio's own Cloudflare account. Cloudflare asks for a payment method on the account
|
|
252
252
|
before R2 works, so this is a separate step the person agrees to; nothing else needs it. R2 has no
|
|
@@ -1085,14 +1085,17 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
1085
1085
|
if (real) {
|
|
1086
1086
|
if (!statSync(real).isDirectory()) throw new Error(`${real} is a file. Nothing was written.`);
|
|
1087
1087
|
const entries = readdirSync(real).filter((n) => n !== '.DS_Store' && n !== '.git');
|
|
1088
|
-
|
|
1088
|
+
let resume = false;
|
|
1089
|
+
try { const existing = JSON.parse(readFileSync(join(real, 'studio.json'), 'utf8')); resume = existing.name === String(name).trim() && existing.slug === slugify(askedSlug || name) && Boolean(existing.homie?.studio); } catch {}
|
|
1090
|
+
if (entries.length && !resume) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
|
|
1089
1091
|
}
|
|
1090
1092
|
const slug = slugify(askedSlug || name);
|
|
1091
|
-
const files = studioFiles({ name: String(name).trim(), slug, homie: homie
|
|
1093
|
+
const files = studioFiles({ name: String(name).trim(), slug, homie: homie ?? null, template, kind });
|
|
1092
1094
|
mkdirSync(dir, { recursive: true });
|
|
1093
1095
|
const wrote = [];
|
|
1094
1096
|
for (const [rel, content] of Object.entries(files)) {
|
|
1095
1097
|
const path = join(dir, rel);
|
|
1098
|
+
if (existsSync(path)) continue;
|
|
1096
1099
|
mkdirSync(dirname(path), { recursive: true });
|
|
1097
1100
|
writeFileSync(path, content);
|
|
1098
1101
|
wrote.push(rel);
|
package/lib/shop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {givingItems} from './giving-preset.mjs';
|
|
1
2
|
import { paidReleases } from './parts-upload.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
|
|
@@ -115,7 +116,7 @@ export function shopInit(root, { supporter = false, giving = false, currency = n
|
|
|
115
116
|
const shop = {
|
|
116
117
|
...(giving ? { giving: { receiptText: '' } } : {}),
|
|
117
118
|
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
|
|
118
|
-
items: giving ?
|
|
119
|
+
items: giving ? givingItems() : supporter ? [supporterItem({ price: Number(price) })] : [],
|
|
119
120
|
};
|
|
120
121
|
const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
|
|
121
122
|
if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
|
|
@@ -3534,6 +3534,96 @@
|
|
|
3534
3534
|
}
|
|
3535
3535
|
}
|
|
3536
3536
|
}
|
|
3537
|
+
},
|
|
3538
|
+
"0.51.1": {
|
|
3539
|
+
"files": {
|
|
3540
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3541
|
+
"README.md": "684b3cca8001673c",
|
|
3542
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3543
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3544
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3545
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3546
|
+
"posts/README.md": "37841aae476bd540",
|
|
3547
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3548
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3549
|
+
},
|
|
3550
|
+
"sections": {
|
|
3551
|
+
"AGENTS.md": {
|
|
3552
|
+
"(intro)": "394cbbe934e80722",
|
|
3553
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3554
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3555
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3556
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
3557
|
+
"5222e08ca55e326e",
|
|
3558
|
+
"c20d90de21cde0c7"
|
|
3559
|
+
],
|
|
3560
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3561
|
+
"## Making games": "bb715272fd1a615b",
|
|
3562
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3563
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3564
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3565
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3566
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3567
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3568
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3569
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3570
|
+
"## Beta": "9d02638207a26e73"
|
|
3571
|
+
}
|
|
3572
|
+
},
|
|
3573
|
+
"named": {
|
|
3574
|
+
"Homie Arcade": {
|
|
3575
|
+
"sections": {
|
|
3576
|
+
"AGENTS.md": {
|
|
3577
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
3578
|
+
"611b437f5b3303f0",
|
|
3579
|
+
"8364e05cebefcba4"
|
|
3580
|
+
]
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
},
|
|
3586
|
+
"0.51.2": {
|
|
3587
|
+
"files": {
|
|
3588
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3589
|
+
"README.md": "684b3cca8001673c",
|
|
3590
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3591
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3592
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3593
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3594
|
+
"posts/README.md": "37841aae476bd540",
|
|
3595
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3596
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3597
|
+
},
|
|
3598
|
+
"sections": {
|
|
3599
|
+
"AGENTS.md": {
|
|
3600
|
+
"(intro)": "394cbbe934e80722",
|
|
3601
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3602
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3603
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3604
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3605
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3606
|
+
"## Making games": "bb715272fd1a615b",
|
|
3607
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3608
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3609
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3610
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3611
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3612
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3613
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3614
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3615
|
+
"## Beta": "9d02638207a26e73"
|
|
3616
|
+
}
|
|
3617
|
+
},
|
|
3618
|
+
"named": {
|
|
3619
|
+
"Homie Arcade": {
|
|
3620
|
+
"sections": {
|
|
3621
|
+
"AGENTS.md": {
|
|
3622
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3537
3627
|
}
|
|
3538
3628
|
}
|
|
3539
3629
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {givingItems} from './giving-preset.mjs';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {readFileSync,existsSync,readdirSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
const history=JSON.parse(readFileSync(new URL('./app-preset-history.json',import.meta.url),'utf8'));
|
|
7
|
+
const presets=fileURLToPath(new URL('../app-starters/',import.meta.url));
|
|
8
|
+
const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
9
|
+
const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
|
|
10
|
+
// A three-way merge: only values still equal to the old preset take the new default.
|
|
11
|
+
function merge(mine,old,next) {
|
|
12
|
+
if(equal(mine,old))return next;
|
|
13
|
+
if(!object(mine)||!object(old)||!object(next))return mine;
|
|
14
|
+
const result={...mine};
|
|
15
|
+
for(const key of new Set([...Object.keys(old),...Object.keys(next)])) {
|
|
16
|
+
const value=merge(mine[key],old[key],next[key]);
|
|
17
|
+
if(value===undefined)delete result[key];else result[key]=value;
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
export function appPresetChanges(root) {
|
|
22
|
+
const changes=[];
|
|
23
|
+
const shopFile=join(root,'shop.json');
|
|
24
|
+
if(existsSync(shopFile)) {
|
|
25
|
+
const from=readFileSync(shopFile,'utf8');let shop;try{shop=JSON.parse(from);}catch{}
|
|
26
|
+
if(shop?.giving && equal(shop.items,givingItems([1000,2500,5000]))) {
|
|
27
|
+
changes.push({file:'shop.json',kind:'update-file',what:'refresh untouched giving choices; keep currency and receipt wording',from,text:JSON.stringify({...shop,items:givingItems()},null,2)+'\n'});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const apps=join(root,'apps');if(!existsSync(apps))return changes;
|
|
31
|
+
for(const entry of readdirSync(apps,{withFileTypes:true})) {
|
|
32
|
+
if(!entry.isDirectory())continue;
|
|
33
|
+
const rel=`apps/${entry.name}`,file=join(root,rel,'app.json');if(!existsSync(file))continue;
|
|
34
|
+
const text=readFileSync(file,'utf8');let mine;try{mine=JSON.parse(text);}catch{continue;}
|
|
35
|
+
const kind=mine.starter?.kind,old=history[kind];if(!old)continue;
|
|
36
|
+
const next=JSON.parse(readFileSync(join(presets,kind,'app.json'),'utf8'));
|
|
37
|
+
const merged=merge(mine,old.meta,next);merged.id=mine.id;merged.name=mine.name;
|
|
38
|
+
if(!equal(mine,merged))changes.push({file:rel+'/app.json',kind:'update-file',what:'refresh unchanged app preset defaults; keep studio edits',from:text,text:JSON.stringify(merged,null,2)+'\n'});
|
|
39
|
+
for(const [path,hash] of Object.entries(old.files)) {
|
|
40
|
+
if(path==='app.json'||!existsSync(join(root,rel,path))||!existsSync(join(presets,kind,path)))continue;
|
|
41
|
+
const from=readFileSync(join(root,rel,path),'utf8');
|
|
42
|
+
const normalized=from.replaceAll(`game: '${mine.id}'`,"game: 'welcome'");
|
|
43
|
+
if(createHash('sha256').update(normalized).digest('hex')!==hash)continue;
|
|
44
|
+
const want=readFileSync(join(presets,kind,path),'utf8').replaceAll("game: 'welcome'",`game: '${mine.id}'`);
|
|
45
|
+
if(from!==want)changes.push({file:rel+'/'+path,kind:'update-file',what:'refresh untouched app preset file',from,text:want});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return changes;
|
|
49
|
+
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {appPresetChanges} from './upgrade-app-presets.mjs';
|
|
1
2
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
|
|
@@ -174,7 +175,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
174
175
|
const who = { name, slug };
|
|
175
176
|
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
176
177
|
const { files: oldFiles, secs: oldSecs } = known(history, who);
|
|
177
|
-
const changes =
|
|
178
|
+
const changes = appPresetChanges(root);
|
|
178
179
|
const kept = [];
|
|
179
180
|
|
|
180
181
|
// package.json: the pin, and the scripts the template's studios have.
|
package/mcp/ui/build.js
CHANGED
|
@@ -70,9 +70,10 @@
|
|
|
70
70
|
C.add(C.el('div', 'cb'), C.el('b', '', x.kind + ' ' + x.name), C.el('span', '', x.state + (x.plan ? ' · ' + x.plan : '')))));
|
|
71
71
|
});
|
|
72
72
|
card.appendChild(ul);
|
|
73
|
-
if (p.cost) card.appendChild(C.el('p', 'note', p.cost));
|
|
73
|
+
if (p.cost) card.appendChild(C.el('p', 'note billing', p.cost));
|
|
74
74
|
card.appendChild(C.el('p', 'fine', 'Cloudflare asks you to approve once in your browser if this computer is not signed in (a free account works).'));
|
|
75
|
-
if (p.directory && p.directory.
|
|
75
|
+
if (p.directory && p.directory.choice) card.appendChild(C.el('p', 'directory-choice', p.directory.choice));
|
|
76
|
+
if (p.directory && p.directory.site && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
|
|
76
77
|
if (p.never) card.appendChild(C.el('p', 'fine', p.never));
|
|
77
78
|
return card;
|
|
78
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.2",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Vec3 } from './math.ts';
|
|
2
|
+
|
|
3
|
+
/** A bounded, incremental presentation route. Never participates in simulation.
|
|
4
|
+
* Each edge is accepted by the same body sweep used to draw the runner. Search
|
|
5
|
+
* is spread over frames so an unusual correction cannot monopolize a phone. */
|
|
6
|
+
export function correctionPath(start: Vec3, goal: Vec3, spacing: number) {
|
|
7
|
+
type Node = { x: number; y: number; pos: Vec3; cost: number; estimate: number; parent?: Node };
|
|
8
|
+
const distance = (a: Vec3, b: Vec3) => Math.hypot(a.x-b.x,a.y-b.y);
|
|
9
|
+
const first: Node = {x:0,y:0,pos:start,cost:0,estimate:distance(start,goal)};
|
|
10
|
+
const open = [first], costs = new Map<string,number>([['0,0',0]]);
|
|
11
|
+
let route: Vec3[] | null = null, exhausted = false;
|
|
12
|
+
return {
|
|
13
|
+
goal,
|
|
14
|
+
advance(clear: (from: Vec3,to: Vec3)=>boolean): readonly Vec3[] | null {
|
|
15
|
+
if (route || exhausted) return route;
|
|
16
|
+
for (let budget=0;budget<24 && open.length;budget++) {
|
|
17
|
+
let best=0;for(let i=1;i<open.length;i++)if(open[i].estimate<open[best].estimate)best=i;
|
|
18
|
+
const node=open.splice(best,1)[0];
|
|
19
|
+
if(clear(node.pos,goal)) {
|
|
20
|
+
route=[goal];let n:Node|undefined=node;while(n?.parent){route.unshift(n.pos);n=n.parent;}return route;
|
|
21
|
+
}
|
|
22
|
+
for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]) {
|
|
23
|
+
const x=node.x+dx,y=node.y+dy,key=`${x},${y}`,cost=node.cost+Math.hypot(dx,dy)*spacing;
|
|
24
|
+
if(cost>=(costs.get(key)??Infinity))continue;
|
|
25
|
+
const pos={x:start.x+x*spacing,y:start.y+y*spacing,z:0};
|
|
26
|
+
if(!clear(node.pos,pos))continue;
|
|
27
|
+
costs.set(key,cost);open.push({x,y,pos,cost,estimate:cost+distance(pos,goal),parent:node});
|
|
28
|
+
}
|
|
29
|
+
if(costs.size>4096){exhausted=true;return null;}
|
|
30
|
+
}
|
|
31
|
+
if(!open.length)exhausted=true;
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
package/rules/map-index.ts
CHANGED
|
@@ -61,15 +61,15 @@ function nearbyFlatMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:num
|
|
|
61
61
|
const out:any={bounds:map.bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
|
|
62
62
|
function touches(n:{min:Vec3;max:Vec3}):boolean {
|
|
63
63
|
charge(12);let near=0,far=1;
|
|
64
|
-
for(
|
|
64
|
+
for(let axis=0;axis<(dims===2?2:3);axis++){const k=axes[axis],lo=n.min[k]-(k==='z'?height:radius),hi=n.max[k]+(k==='z'?0:radius);
|
|
65
65
|
if(d[k]===0){if(p[k]<lo-1e-7||p[k]>hi+1e-7)return false;}
|
|
66
|
-
else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b)
|
|
66
|
+
else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b){const swap=a;a=b;b=swap;}near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-7)return false;}
|
|
67
67
|
}return true;
|
|
68
68
|
}
|
|
69
69
|
const found:Entry[]=[];
|
|
70
|
-
function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e
|
|
70
|
+
function visit(n:Node){if(!touches(n))return;if(n.entries){for(let i=0;i<n.entries.length;i++){const e=n.entries[i];if(touches(e))found.push(e);}}else{visit(n.left!);visit(n.right!);}}
|
|
71
71
|
visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
|
|
72
|
-
found.sort((a,b)=>a.order-b.order);for(const e
|
|
72
|
+
found.sort((a,b)=>a.order-b.order);for(let i=0;i<found.length;i++){const e=found[i];out[e.key].push(e.value);}
|
|
73
73
|
if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const raw of map[key]??[]){
|
|
74
74
|
const v:any=raw;let min:Vec3,max:Vec3;
|
|
75
75
|
if(key==='boxes'){min=v.min;max=v.max;}
|