@homie-rocks/studio 0.51.1 → 0.51.3
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 +45 -0
- package/README.md +15 -4
- package/app-starters/gathering/app.json +2 -44
- package/app-starters/gathering/index.html +1 -1
- package/app-starters/gathering/src/view.ts +6 -19
- package/apps/APPS.md +55 -1
- package/bin/homie-studio.mjs +12 -8
- package/guides/app/SKILL.md +22 -2
- package/guides/office/SKILL.md +9 -0
- package/guides/shop/SKILL.md +8 -0
- package/guides/studio-setup/SKILL.md +9 -1
- package/lib/app-needs.mjs +17 -2
- package/lib/app-preset-history.json +840 -0
- package/lib/app-ready.mjs +1 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +2 -2
- package/lib/build.mjs +4 -2
- package/lib/check.mjs +2 -0
- package/lib/cloudflare.mjs +19 -10
- package/lib/decisions.mjs +9 -5
- package/lib/dev.mjs +4 -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-links.mjs +4 -2
- package/lib/shop-local.mjs +31 -0
- package/lib/shop.mjs +2 -1
- package/lib/style-presets.mjs +1 -0
- package/lib/template-history.json +92 -2
- 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/shop/SHOP.md +6 -0
- package/site/SITE.md +13 -0
- package/tools/TOOLS.md +9 -0
- package/worker/app-records.mjs +3 -3
- package/worker/arrival.mjs +7 -5
- package/worker/build-id.mjs +1 -1
- package/worker/directory-choice.mjs +9 -0
- package/worker/index.mjs +25 -9
- package/worker/mcp.mjs +2 -2
- package/worker/pages.mjs +12 -5
- package/worker/records-page.mjs +1 -1
- package/worker/shop-page.mjs +2 -2
- package/worker/shop-subscriptions.mjs +42 -0
- package/worker/signin-target.mjs +8 -0
- package/worker/site.mjs +11 -7
- package/worker/stats-page.mjs +6 -3
- package/worker/version.mjs +1 -1
package/lib/app-ready.mjs
CHANGED
package/lib/art-cli.mjs
CHANGED
|
@@ -151,13 +151,13 @@ export async function styleCommand(root, sub, positional, flags, { log = () => {
|
|
|
151
151
|
} else if (verb === 'set') {
|
|
152
152
|
if (!args[1] || args[2] === undefined) return { ok: false, command: 'style set', why: `usage: homie-studio style set <id> <decision> <value> (decisions: ${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)` };
|
|
153
153
|
r = setDecision(root, id, args[1], args.slice(2).join(' '), { by: flags.get('by') === 'person' ? 'person' : 'ai', why: flags.get('why') ?? null, words: flags.get('words') ?? null, unlock: flags.has('unlock'), reason: flags.get('reason') ?? null, confirm: flags.has('confirm'), manifest: manifest() });
|
|
154
|
-
} else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest() });
|
|
154
|
+
} else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
|
|
155
155
|
else if (verb === 'lock') r = lockDecision(root, id, args[1] ?? 'style', { by: 'person', words: flags.get('words') ?? null });
|
|
156
156
|
else if (verb === 'unlock') r = unlockDecision(root, id, args[1], { reason: flags.get('reason') ?? null, by: 'person' });
|
|
157
157
|
else if (verb === 'board') { const { styleBoard } = await import('./style-board.mjs'); r = await styleBoard(root, id, { prompt: String(flags.get('prompt') ?? ''), log, library: !flags.has('no-library') }); }
|
|
158
158
|
else if (verb === 'pick') {
|
|
159
159
|
const mixes = Object.fromEntries(list(flags.get('mix')).map((x) => x.split('=')).filter(([k, v]) => k && v));
|
|
160
|
-
r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest() });
|
|
160
|
+
r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
|
|
161
161
|
} else if (verb === 'mood') { const { recordMood } = await import('./style-board.mjs'); r = await recordMood(root, id, args[1], resolve(String(flags.get('image') ?? '')), { usd: flags.get('usd') ?? null, receipt: flags.get('receipt') ?? null, model: flags.get('model') ?? null }); }
|
|
162
162
|
else if (verb === 'golden') {
|
|
163
163
|
if (args[1] === 'add') { const { addGolden } = await import('./style-board.mjs'); r = await addGolden(root, id, resolve(String(args[2] ?? flags.get('image') ?? '')), { from: flags.get('from') ?? null }); }
|
package/lib/art-tools.mjs
CHANGED
|
@@ -223,11 +223,11 @@ export function artToolDefs(ctx, h) {
|
|
|
223
223
|
const by = a.by === 'person' ? 'person' : 'ai';
|
|
224
224
|
let r; let action;
|
|
225
225
|
try {
|
|
226
|
-
if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest }); }
|
|
226
|
+
if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest, by }); }
|
|
227
227
|
else if (!a.decision) return fail('name the decision (decision: "style.palette"), or pick a direction (pick: "b")');
|
|
228
228
|
else if (a.lock) { action = 'lock'; r = lockDecision(root, game, a.lock === true ? a.decision : String(a.lock), { by: 'person', words: a.words ?? (by === 'person' ? 'tapped Lock' : null) }); }
|
|
229
229
|
else if (a.unlock && a.value === undefined && a.steer === undefined) { action = 'unlock'; r = unlockDecision(root, game, a.decision, { reason: a.reason ?? null, by: 'person' }); }
|
|
230
|
-
else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest }); }
|
|
230
|
+
else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest, by }); }
|
|
231
231
|
else if (a.value !== undefined) { action = 'set'; r = setDecision(root, game, a.decision, a.value, { by, why: null, words: a.words ?? null, unlock: a.unlock === true, reason: a.reason ?? null, confirm: a.confirm === true, manifest }); }
|
|
232
232
|
else return fail('say what to do: value, steer, lock, unlock, pick or mix');
|
|
233
233
|
} catch (error) { return fail(error.message); }
|
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/check.mjs
CHANGED
|
@@ -387,6 +387,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
387
387
|
for(const name of ['wall','phone','phone-two'])await openSurface(name);
|
|
388
388
|
const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
|
|
389
389
|
const actor = surfaces[1];
|
|
390
|
+
await appReady(actor);
|
|
390
391
|
await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
|
|
391
392
|
before = await waitInApp(actor,proof.observe,textOf);
|
|
392
393
|
await actor.frame.click(proof.action);
|
|
@@ -406,6 +407,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
406
407
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
407
408
|
}
|
|
408
409
|
for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
|
|
410
|
+
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
409
411
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
410
412
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
411
413
|
}
|
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/decisions.mjs
CHANGED
|
@@ -130,7 +130,7 @@ export const CATALOGUE = Object.freeze([
|
|
|
130
130
|
['style.render', 'style', 'How is the world drawn?', [], (ctx) => {
|
|
131
131
|
if (ctx.drawn?.render && RENDERS[ctx.drawn.render]) return { value: ctx.drawn.render, label: RENDERS[ctx.drawn.render].label, why: 'what the game draws now (its style.json)', options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
|
|
132
132
|
const hit = best(RENDERS, ctx);
|
|
133
|
-
const key = hit?.[0] ?? 'lowpoly-flat';
|
|
133
|
+
const key = hit?.[0] ?? (ctx.kind === 'app' ? 'illustrated' : 'lowpoly-flat');
|
|
134
134
|
return { value: key, label: RENDERS[key].label, why: hit ? `your words ${said(ctx, RENDERS[key].words)}: ${RENDERS[key].note}` : `nothing named a look: ${RENDERS[key].note}`, options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
|
|
135
135
|
}],
|
|
136
136
|
['style.palette', 'style', 'Which colours, and what is each one for?', ['style.render', 'style.light'], (ctx) => {
|
|
@@ -141,6 +141,10 @@ export const CATALOGUE = Object.freeze([
|
|
|
141
141
|
v.ramp = rampFrom(v);
|
|
142
142
|
return { value: v, label: 'The codex\'s own colours', why: 'the Game Codex already names them', options: paletteOptions(ctx) };
|
|
143
143
|
}
|
|
144
|
+
if(ctx.kind === 'app' && !best(PALETTES,ctx)) {
|
|
145
|
+
const value=paletteValue('custom',{bg:ctx.theme.bg??'#f6f3ec',ink:ctx.theme.fg??'#243d36',accent:ctx.theme.accent??'#226b59',accent2:'#b87935'});
|
|
146
|
+
value.ramp=rampFrom(value);return {value,label:'The place’s colours',why:'a readable starting palette for the app; refine it to the organisation',options:paletteOptions(ctx)};
|
|
147
|
+
}
|
|
144
148
|
const hit = best(PALETTES, ctx);
|
|
145
149
|
const key = hit?.[0] ?? ({ gather: 'meadow-morning', brawl: 'candy-pop', race: 'desert-noon', rpg: 'heroic-dawn', shooter: 'space-ink', platformer: 'meadow-morning', party: 'candy-pop', puzzle: 'candy-pop', sports: 'meadow-morning' })[ctx.genre] ?? 'meadow-morning';
|
|
146
150
|
return { value: paletteValue(key), label: paletteLabel({ name: key }), why: hit ? `your words ${said(ctx, PALETTES[key].words)}` : `a ${ctx.genre} game reads best in bright, separate colours`, options: paletteOptions(ctx) };
|
|
@@ -479,7 +483,7 @@ export function setDecision(root, id, did, input, { by = 'ai', why = null, words
|
|
|
479
483
|
* Steer: the person's words, applied where the vocabulary knows them ("warmer", "less saturated", "golden hour",
|
|
480
484
|
* "closer", "chunkier", "add outlines", a hex colour), and always recorded. The decision becomes steered.
|
|
481
485
|
*/
|
|
482
|
-
export function steerDecision(root, id, did, words, { manifest = null } = {}) {
|
|
486
|
+
export function steerDecision(root, id, did, words, { manifest = null, by = 'person' } = {}) {
|
|
483
487
|
const doc = readDecisions(root, id);
|
|
484
488
|
if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
|
|
485
489
|
const rec = doc.decisions[did];
|
|
@@ -489,7 +493,7 @@ export function steerDecision(root, id, did, words, { manifest = null } = {}) {
|
|
|
489
493
|
if (rec.state === 'locked') return { ok: false, command: 'style steer', id, decision: did, locked: true, why: `${did} is locked: unlock it (with a reason, after the blast radius) before steering it` };
|
|
490
494
|
const { value, applied } = applySteer(did, rec.value, w, doc);
|
|
491
495
|
const steer = [...(rec.steer ?? []), w].slice(-10);
|
|
492
|
-
change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : 'steered', why: `
|
|
496
|
+
change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : by === 'person' ? 'steered' : rec.state, why: `refined by ${by === 'person' ? 'the person' : 'the AI'}: "${w}"` }, { by, words: w });
|
|
493
497
|
if (did === 'style.palette' && doc.decisions['style.light'] && doc.decisions['style.light'].state !== 'locked') {
|
|
494
498
|
const L = doc.decisions['style.light'];
|
|
495
499
|
L.value = { ...L.value, ...lightColours(LIGHTS[L.value.time] ?? LIGHTS.morning, value) };
|
|
@@ -762,7 +766,7 @@ export function directionsFor(root, id, { prompt = '' } = {}) {
|
|
|
762
766
|
}
|
|
763
767
|
|
|
764
768
|
/** Pick a direction (all its style values) or Mix rows from several: { 'style.palette': 'b', … }. By the person. */
|
|
765
|
-
export function pickDirection(root, id, choice, { mixes = {}, manifest = null } = {}) {
|
|
769
|
+
export function pickDirection(root, id, choice, { mixes = {}, manifest = null, by: actor = 'person' } = {}) {
|
|
766
770
|
const doc = readDecisions(root, id);
|
|
767
771
|
if (!doc?.board?.directions?.length) throw new Error(`games/${id} has no style board yet: homie-studio style board ${id}`);
|
|
768
772
|
const by = Object.fromEntries(doc.board.directions.map((d) => [d.id, d]));
|
|
@@ -778,7 +782,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
|
|
|
778
782
|
const value = from.values[did];
|
|
779
783
|
if (value === undefined || JSON.stringify(rec.value) === JSON.stringify(value)) continue;
|
|
780
784
|
if (rec.state === 'locked') { refused.push(did); continue; }
|
|
781
|
-
change(rec, { value, label: labelOf(did, value), why:
|
|
785
|
+
change(rec, { value, label: labelOf(did, value), why: `${actor === 'person' ? 'the person' : 'the AI'} picked direction ${from.id.toUpperCase()} on the style board`, state: rec.state === 'pinned' ? 'pinned' : actor === 'person' ? 'steered' : rec.state }, { by: actor });
|
|
782
786
|
changed.push(did);
|
|
783
787
|
}
|
|
784
788
|
doc.board.chosen = choice ?? doc.board.chosen ?? null;
|
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);
|
|
@@ -209,6 +209,9 @@ export function devConfig(root, remoteAi) {
|
|
|
209
209
|
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
210
210
|
mkdirSync(dirname(copy), { recursive: true });
|
|
211
211
|
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
212
|
+
const localSecrets=join(root,'.dev.vars'), generatedSecrets=join(dirname(copy),'.dev.vars');
|
|
213
|
+
if(existsSync(localSecrets))writeFileSync(generatedSecrets,readFileSync(localSecrets),{mode:0o600});
|
|
214
|
+
else rmSync(generatedSecrets,{force:true});
|
|
212
215
|
const notes = [
|
|
213
216
|
...(routed ? [`Local dev leaves out the production route${stripped.length === 1 ? '' : 's'} in wrangler.jsonc (${stripped.join(', ') || 'route keys'}): rooms here use this computer's own address, never the live site's. wrangler.jsonc is unchanged.`] : []),
|
|
214
217
|
...(aiChanged ? [remoteAi ? aiNote : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.'] : aiNote ? [aiNote] : []),
|
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-links.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
|
|
|
6
6
|
import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
|
-
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [] }) {
|
|
9
|
+
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [], local = false }) {
|
|
10
10
|
const list = async (path, params = {}) => {
|
|
11
11
|
const all = []; let cursor;
|
|
12
12
|
do {
|
|
@@ -19,7 +19,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
19
19
|
} while (cursor);
|
|
20
20
|
return all;
|
|
21
21
|
};
|
|
22
|
-
|
|
22
|
+
if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
|
|
23
|
+
const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
|
|
23
24
|
if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
|
|
24
25
|
const names = JSON.parse(secrets.stdout ?? secrets.out);
|
|
25
26
|
const has = (name) => names.some((s) => s.name === name);
|
|
@@ -134,6 +135,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
134
135
|
portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
|
|
135
136
|
config.portal=portal.login_page?.url;
|
|
136
137
|
}
|
|
138
|
+
if(local)return {ok:true,command:'shop connect',local:true,mode,config};
|
|
137
139
|
const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
|
|
138
140
|
const purchaseSecrets = {};
|
|
139
141
|
if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Real Stripe test links, delivered by the owner's Stripe CLI listener. */
|
|
2
|
+
import {readFileSync,writeFileSync,existsSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {stripeProcess} from './stripe-connect.mjs';
|
|
6
|
+
import {syncPaymentLinks} from './shop-links.mjs';
|
|
7
|
+
import {checkShop} from '../worker/shop-rules.mjs';
|
|
8
|
+
import {formEncode,redactStripe} from '../worker/stripe.mjs';
|
|
9
|
+
export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={}) {
|
|
10
|
+
const origin=new URL(url??'http://localhost:8787');
|
|
11
|
+
if(origin.protocol!=='http:' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(origin.hostname))throw new Error('Local Stripe needs an HTTP localhost or Wi-Fi address');
|
|
12
|
+
if(!profile)throw new Error('Name the existing Stripe sandbox CLI profile with --profile');
|
|
13
|
+
const checked=checkShop(JSON.parse(readFileSync(join(root,'shop.json'),'utf8')));
|
|
14
|
+
if(!checked.ok)throw new Error('Fix shop.json before connecting Stripe');
|
|
15
|
+
const api=async(method,path,params={},idempotency)=>{
|
|
16
|
+
const args=['--project-name',profile,method.toLowerCase(),path];
|
|
17
|
+
for(const [k,v] of formEncode(params))args.push('-d',`${k}=${v}`);
|
|
18
|
+
if(idempotency)args.push('--idempotency',idempotency);
|
|
19
|
+
const r=await exec(args,{cwd:root});let body;try{body=JSON.parse(r.stdout);}catch{}
|
|
20
|
+
if(r.code!==0||body?.error||!body)throw new Error('Stripe test request failed: '+redactStripe(body?.error?.message??r.stderr??'no response').slice(0,500));
|
|
21
|
+
if(body.livemode===true||body.data?.some(o=>o.livemode===true))throw new Error('Local checkout refuses live Stripe objects');
|
|
22
|
+
return body;
|
|
23
|
+
};
|
|
24
|
+
const slug='local-'+createHash('sha256').update(root).digest('hex').slice(0,16);
|
|
25
|
+
const r=await syncPaymentLinks({api,shop:checked.shop,slug,site:origin.origin,mode:'test',local:true});
|
|
26
|
+
const file=join(root,'.dev.vars');let vars=existsSync(file)?readFileSync(file,'utf8'):'';
|
|
27
|
+
vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
|
|
28
|
+
vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
|
|
29
|
+
writeFileSync(file,vars,{mode:0o600});
|
|
30
|
+
return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`stripe --project-name ${profile} listen --all-snapshot --forward-to http://localhost:${origin.port||80}/api/shop/hook`,'Put the listener signing secret in root .dev.vars as STRIPE_WEBHOOK_SECRET, then start/restart homie-studio dev --lan. Keep the listener running.','Open the same Wi-Fi address on phones; complete Stripe test checkout and verify the paid order and app event.']};
|
|
31
|
+
}
|
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('; ') };
|
package/lib/style-presets.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
/** How the world is drawn. `family`: the starter library family that fits; `materials`: the material model. */
|
|
8
8
|
export const RENDERS = Object.freeze({
|
|
9
|
+
illustrated: { label: 'Illustrated place', words: /\b(illustrated|illustration|pictorial)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fraunces', body: 'Inter' }, vfx: 'soft', note: 'readable objects, clear typography and purposeful motion for the work of this place' },
|
|
9
10
|
'lowpoly-flat': { label: 'Flat low-poly', words: /\b(low[- ]?poly|cozy|cosy|cute|toy|minimal|clean|simple|wholesome|chill)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fredoka', body: 'Nunito' }, vfx: 'soft', note: 'faceted shapes in flat palette colours: the cheapest look on a phone, the most coherent, and the richest free library' },
|
|
10
11
|
toon: { label: 'Toon / cel', words: /\b(toon|cel|cartoon|anime|comic|neon|cyber|synth|arcade)\b/i, materials: 'toon', outline: true, family: 'kaykit', fonts: { display: 'Lilita One', body: 'Nunito' }, vfx: 'toon', note: 'banded light and ink outlines: reads at any size, loud on a TV' },
|
|
11
12
|
painted: { label: 'Stylised hand-painted', words: /\b(painterly|painted|storybook|watercolou?r|ghibli-like|fairy ?tale|whimsical|hand[- ]painted)\b/i, materials: 'hand-painted', outline: false, family: 'kaykit', fonts: { display: 'Fraunces', body: 'Lora' }, vfx: 'soft', note: 'soft painted albedo with the light baked gently in: warm and storybook' },
|
|
@@ -3553,7 +3553,10 @@
|
|
|
3553
3553
|
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3554
3554
|
"## Layout": "f3a280b8c96cd068",
|
|
3555
3555
|
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3556
|
-
"## Commands (all through the pinned CLI in node_modules)":
|
|
3556
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
3557
|
+
"5222e08ca55e326e",
|
|
3558
|
+
"c20d90de21cde0c7"
|
|
3559
|
+
],
|
|
3557
3560
|
"## The site": "0cb8dd9a6f8a0096",
|
|
3558
3561
|
"## Making games": "bb715272fd1a615b",
|
|
3559
3562
|
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
@@ -3571,7 +3574,94 @@
|
|
|
3571
3574
|
"Homie Arcade": {
|
|
3572
3575
|
"sections": {
|
|
3573
3576
|
"AGENTS.md": {
|
|
3574
|
-
"## Commands (all through the pinned CLI in node_modules)":
|
|
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
|
+
}
|
|
3627
|
+
},
|
|
3628
|
+
"0.51.3": {
|
|
3629
|
+
"files": {
|
|
3630
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3631
|
+
"README.md": "684b3cca8001673c",
|
|
3632
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3633
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3634
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3635
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3636
|
+
"posts/README.md": "37841aae476bd540",
|
|
3637
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3638
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3639
|
+
},
|
|
3640
|
+
"sections": {
|
|
3641
|
+
"AGENTS.md": {
|
|
3642
|
+
"(intro)": "394cbbe934e80722",
|
|
3643
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3644
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3645
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3646
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3647
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3648
|
+
"## Making games": "bb715272fd1a615b",
|
|
3649
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3650
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3651
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3652
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3653
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3654
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3655
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3656
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3657
|
+
"## Beta": "9d02638207a26e73"
|
|
3658
|
+
}
|
|
3659
|
+
},
|
|
3660
|
+
"named": {
|
|
3661
|
+
"Homie Arcade": {
|
|
3662
|
+
"sections": {
|
|
3663
|
+
"AGENTS.md": {
|
|
3664
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3575
3665
|
}
|
|
3576
3666
|
}
|
|
3577
3667
|
}
|