@homie-rocks/studio 0.40.0 → 0.43.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/CHANGELOG.md +54 -1
- package/README.md +32 -9
- package/app-starters/welcome/app.json +8 -1
- package/app-starters/welcome/map/main.json +1 -0
- package/app-starters/welcome/src/move.ts +3 -0
- package/app-starters/welcome/src/rules.ts +21 -0
- package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
- package/bin/homie-studio.mjs +11 -3
- package/functions/FUNCTIONS.md +99 -0
- package/functions/functions.ts +13 -0
- package/lib/build.mjs +32 -12
- package/lib/cloudflare.mjs +9 -9
- package/lib/codex.mjs +1 -1
- package/lib/dev.mjs +2 -1
- package/lib/functions-build.mjs +36 -0
- package/lib/functions-cli.mjs +23 -0
- package/lib/mcp-tools.mjs +10 -7
- package/lib/mcp.mjs +2 -1
- package/lib/port-check.mjs +7 -4
- package/lib/port.mjs +2 -2
- package/lib/purchase-links.mjs +8 -0
- package/lib/rules-build.mjs +6 -6
- package/lib/rules-guard.mjs +9 -3
- package/lib/scaffold.mjs +58 -15
- package/lib/shop.mjs +1 -1
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +148 -1
- package/lib/tools-build.mjs +2 -0
- package/lib/trailer.mjs +7 -3
- package/lib/typecheck.mjs +10 -8
- package/lib/upgrade.mjs +2 -0
- package/lib/worker-build.mjs +8 -20
- package/lib/worker-config.mjs +28 -0
- package/netplay/NETPLAY.md +8 -0
- package/package.json +7 -2
- package/rules/host.ts +2 -2
- package/rules/view.ts +1 -0
- package/shop/SHOP.md +4 -3
- package/starters/coin-dash/src/move.ts +2 -0
- package/starters/coin-dash/src/rules.ts +2 -0
- package/starters/coin-dash/src/view.ts +2 -0
- package/starters/ember-vale/src/move.ts +2 -0
- package/starters/ember-vale/src/rules.ts +2 -0
- package/starters/ember-vale/src/view.ts +2 -0
- package/starters/gem-rush/src/move.ts +2 -0
- package/starters/gem-rush/src/rules.ts +2 -0
- package/starters/gem-rush/src/view.ts +2 -0
- package/starters/gem-rush-3d/src/move.ts +2 -0
- package/starters/gem-rush-3d/src/rules.ts +2 -0
- package/starters/gem-rush-3d/src/view.ts +2 -0
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/starters/hero-rush-3d/src/rules.ts +2 -0
- package/starters/hero-rush-3d/src/view.ts +2 -0
- package/tools/TOOLS.md +42 -2
- package/tools/tools.ts +3 -0
- package/worker/customer-mcp.mjs +77 -0
- package/worker/customer-resources.mjs +53 -0
- package/worker/customer-shop.mjs +54 -0
- package/worker/extensions.mjs +1 -0
- package/worker/functions-schema.mjs +55 -0
- package/worker/functions.mjs +93 -0
- package/worker/index.mjs +37 -2
- package/worker/machine-payments.mjs +1 -1
- package/worker/mcp-store.mjs +3 -2
- package/worker/mcp-tools.mjs +16 -4
- package/worker/mcp.mjs +31 -15
- package/worker/office-page.mjs +1 -1
- package/worker/players.mjs +5 -2
- package/worker/purchase-delivery.mjs +1 -0
- package/worker/purchase-hosted-readiness.mjs +0 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-machine.mjs +1 -0
- package/worker/purchase-office.mjs +1 -1
- package/worker/purchase-owner.mjs +0 -2
- package/worker/purchase-reconciliation.mjs +1 -0
- package/worker/purchase-routes.mjs +11 -17
- package/worker/purchase-store.mjs +1 -0
- package/worker/selling/runtime.mjs +4 -1
- package/worker/service-run.mjs +25 -0
- package/worker/service-terms.mjs +2 -0
- package/worker/shop-rules.mjs +9 -8
- package/worker/shop.mjs +22 -9
- package/worker/tool-webhook.mjs +1 -0
- package/worker/version.mjs +1 -1
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { refreshWorkerConfig } from './worker-config.mjs';
|
|
1
2
|
import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
|
|
@@ -41,7 +42,7 @@ import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File,
|
|
|
41
42
|
import { whyFailed } from './net.mjs';
|
|
42
43
|
import { repoOf } from './repo.mjs';
|
|
43
44
|
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
44
|
-
import { ensureLocalIgnored, ensureMigrations, migrationWord
|
|
45
|
+
import { ensureLocalIgnored, ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
45
46
|
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
46
47
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
47
48
|
|
|
@@ -240,7 +241,6 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
240
241
|
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
241
242
|
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
242
243
|
let routes = keptRoutes(root, studio);
|
|
243
|
-
const triggers = readConfig(root)?.triggers;
|
|
244
244
|
const who = whoami(root);
|
|
245
245
|
if (!who) {
|
|
246
246
|
if (cf.auth === 'stripe-projects') {
|
|
@@ -277,6 +277,9 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
277
277
|
if (zone) {
|
|
278
278
|
if (ownRoute && zone.state === 'foreign') {
|
|
279
279
|
routes = [...(routes ?? []), zone.route];
|
|
280
|
+
const customFile=join(root,'wrangler.custom.json');
|
|
281
|
+
const custom=existsSync(customFile)?JSON.parse(readFileSync(customFile,'utf8')):{};
|
|
282
|
+
writeFileSync(customFile,JSON.stringify({...custom,routes},null,2)+'\n');
|
|
280
283
|
step(`added the studio's own exact-host route to "routes" in wrangler.jsonc: ${zone.line}. It covers ${zone.host} only, and every deploy keeps it; the other site's route was not touched.`, { route: zone.route });
|
|
281
284
|
const config = readConfig(root) ?? {};
|
|
282
285
|
zone = await zoneCheck(root, { studio, accountId, fetchFn, config: { ...config, routes: [...routesOf(config), zone.route] } });
|
|
@@ -368,17 +371,14 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
368
371
|
const r2 = storage;
|
|
369
372
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
370
373
|
|
|
371
|
-
|
|
374
|
+
refreshWorkerConfig(root, {d1Id: db.uuid});
|
|
372
375
|
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
373
376
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
374
377
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
375
378
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
376
379
|
step('D1 migrations applied');
|
|
377
|
-
|
|
378
|
-
if (
|
|
379
|
-
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
380
|
-
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
381
|
-
}
|
|
380
|
+
const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
|
|
381
|
+
if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
|
|
382
382
|
|
|
383
383
|
const started = Date.now();
|
|
384
384
|
let dep = w(['deploy', ...repoVar(root)]);
|
|
@@ -680,7 +680,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
680
680
|
writeStudio(root, next);
|
|
681
681
|
if (next.cloudflare.d1Id) {
|
|
682
682
|
// The studio's own routes stay through this rewrite too (lib/routes.mjs).
|
|
683
|
-
|
|
683
|
+
refreshWorkerConfig(root);
|
|
684
684
|
}
|
|
685
685
|
log(`created R2 ${bucket}`);
|
|
686
686
|
return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
|
package/lib/codex.mjs
CHANGED
|
@@ -723,7 +723,7 @@ ${game.planned ? `- [ ] The plan (this codex)
|
|
|
723
723
|
-
|
|
724
724
|
`;
|
|
725
725
|
writeFileSync(file, text);
|
|
726
|
-
return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the
|
|
726
|
+
return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the request and sensible defaults (interview only when asked), then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`after recording those defaults: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
|
|
727
727
|
}
|
|
728
728
|
|
|
729
729
|
/** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
|
package/lib/dev.mjs
CHANGED
|
@@ -459,8 +459,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
459
459
|
return;
|
|
460
460
|
}
|
|
461
461
|
};
|
|
462
|
+
const { functionFiles } = await import('./functions-build.mjs');
|
|
462
463
|
const { toolFiles } = await import('./tools-build.mjs');
|
|
463
|
-
const toolsStamp = () => toolFiles(root).map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
|
|
464
|
+
const toolsStamp = () => [...toolFiles(root),...functionFiles(root)].map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
|
|
464
465
|
let lastTools = toolsStamp();
|
|
465
466
|
const toolsLook = async () => {
|
|
466
467
|
if (rebuilding || ending) return;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {pathToFileURL} from 'node:url';
|
|
4
|
+
import {createRequire} from 'node:module';
|
|
5
|
+
const require=createRequire(import.meta.url);
|
|
6
|
+
export function functionFiles(root) { const dir=join(root,'functions');return existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]; }
|
|
7
|
+
export async function buildFunctions(root,esbuild) {
|
|
8
|
+
const files=functionFiles(root),dir=join(root,'site/src/functions');mkdirSync(dir,{recursive:true});
|
|
9
|
+
if(files.length) {
|
|
10
|
+
const ts=require('typescript'),options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true};
|
|
11
|
+
const errors=ts.getPreEmitDiagnostics(ts.createProgram(files,options));
|
|
12
|
+
if(errors.length)throw new Error(ts.formatDiagnosticsWithColorAndContext(errors,{getCurrentDirectory:()=>root,getCanonicalFileName:f=>f,getNewLine:()=> '\n'}));
|
|
13
|
+
}
|
|
14
|
+
const contents=files.map((f,i)=>`import f${i} from ${JSON.stringify(f)};`).join('\n')+`\nexport default [${files.map((_,i)=>'f'+i).join(',')}];`;
|
|
15
|
+
const built=await esbuild.build({stdin:{contents,resolveDir:root},bundle:true,format:'esm',platform:'neutral',write:false,logLevel:'silent'});
|
|
16
|
+
const file=join(dir,'index.mjs');writeFileSync(file,built.outputFiles[0].text);
|
|
17
|
+
const definitions=(await import(pathToFileURL(file).href+'?build='+Date.now())).default;
|
|
18
|
+
const names=new Set();
|
|
19
|
+
for(const fn of definitions) {
|
|
20
|
+
if(!/^[a-z][a-z0-9-]*$/.test(fn.name)||names.has(fn.name)||typeof fn.handler!=='function'||typeof fn.event!=='string'||!fn.event.trim())throw new Error('Function needs a unique name, event and handler');
|
|
21
|
+
if(fn.schedule && (typeof fn.schedule!=='string'||fn.schedule.trim().split(/\s+/).length!==5))throw new Error('Function schedule needs a Cloudflare cron expression');
|
|
22
|
+
names.add(fn.name);
|
|
23
|
+
}
|
|
24
|
+
const worker=join(root,'site/src/worker.mjs');
|
|
25
|
+
if(existsSync(worker)) {let text=readFileSync(worker,'utf8');if(!text.includes('useFunctions(')){text+="\nimport { useFunctions } from '@homie-rocks/studio/worker';\nuseFunctions(async () => (await import('./functions/index.mjs')).default);\n";writeFileSync(worker,text);}}
|
|
26
|
+
writeFileSync(join(dir,'declarations.json'),JSON.stringify(definitions.map(({name,event,schedule,replay})=>({name,event,schedule,replay})),null,2)+'\n');
|
|
27
|
+
(await import('./worker-config.mjs')).refreshWorkerConfig(root);
|
|
28
|
+
return definitions;
|
|
29
|
+
}
|
|
30
|
+
/** Install subscriptions before the new Worker can produce its first event. */
|
|
31
|
+
export function functionDeploymentSQL(root) {
|
|
32
|
+
const file=join(root,'site/src/functions/declarations.json');
|
|
33
|
+
const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
|
|
34
|
+
const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
|
|
35
|
+
return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),`DELETE FROM function_subscriptions WHERE name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {existsSync,mkdirSync,writeFileSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {connectToolClient} from './tools-cli.mjs';
|
|
4
|
+
export async function functionCommand(root,sub,positional,flags,{log=()=>{}}={}) {
|
|
5
|
+
if(sub==='new') {
|
|
6
|
+
const name=positional[2],event=flags.get('event')??'order.paid';
|
|
7
|
+
if(!/^[a-z][a-z0-9-]*$/.test(name??''))throw new Error('Use a lowercase function name');
|
|
8
|
+
const file=join(root,'functions',name+'.ts');if(existsSync(file))throw new Error('Function already exists');
|
|
9
|
+
mkdirSync(join(root,'functions'),{recursive:true});
|
|
10
|
+
writeFileSync(file,`import { defineFunction } from '@homie-rocks/studio/functions';\nexport default defineFunction({\n name: ${JSON.stringify(name)}, event: ${JSON.stringify(event)},\n async handler(event, context) {\n // Deduplicate external side effects with event.id (for example an API idempotency key).\n await context.database.put(event.id, event.data);\n },\n});\n`);
|
|
11
|
+
return {ok:true,command:'function new',file,next:'Edit your function, then run homie-studio dev.'};
|
|
12
|
+
}
|
|
13
|
+
if(sub==='replay') {
|
|
14
|
+
if(!flags.has('replay'))throw new Error('Pass --replay to explicitly request retained history');
|
|
15
|
+
const client=await connectToolClient(root,new URL(flags.get('url')??'http://localhost:8787'),{log});
|
|
16
|
+
try {const result=await client.callTool({name:'studio_function_replay',arguments:{name:positional[2]}});return {ok:!result.isError,command:'function replay',result};}finally{await client.close();}
|
|
17
|
+
}
|
|
18
|
+
if(sub!=='fire')throw new Error('Use function new <name> --event <event>, or function fire <event> --input <JSON>');
|
|
19
|
+
const url=new URL(flags.get('url')??'http://localhost:8787');
|
|
20
|
+
if(!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Fire is local testing only');
|
|
21
|
+
const client=await connectToolClient(root,url,{log});
|
|
22
|
+
try {const result=await client.callTool({name:'studio_function_fire',arguments:{event:positional[2],data:JSON.parse(flags.get('input')??'{}'),...(flags.has('id')?{id:flags.get('id')}: {})}});return {ok:!result.isError,command:'function fire',result};}finally{await client.close();}
|
|
23
|
+
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -925,7 +925,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
925
925
|
},
|
|
926
926
|
{
|
|
927
927
|
name: 'game_make', title: 'Make a multiplayer game',
|
|
928
|
-
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena:
|
|
928
|
+
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: coin-dash (the smallest rules-plus-view example), gem-rush (an arena: the server runs rules, browsers predict movement and render, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
|
|
929
929
|
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
|
|
930
930
|
annotations: { title: 'Make a game', ...RW },
|
|
931
931
|
run: async (a) => {
|
|
@@ -948,7 +948,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
948
948
|
}
|
|
949
949
|
const m = r.result?.models;
|
|
950
950
|
const got = m ? ` Its models: ${m.fetched} from the starter library${m.missing.length ? `; ${m.missing.length} could not be fetched (${m.why ?? m.missing[0].why}), and it draws stand-ins for those` : ''}.` : '';
|
|
951
|
-
return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install}
|
|
951
|
+
return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Read studio_guide topic game, then file RULES.md. Change games/${a.id}/src/rules.ts (declared state and decisions), src/move.ts (server movement and local prediction), src/view.ts (pictures and input), and map/main.json (shared geometry). Read each file once and make its changes in ONE file_edit with an edits list (never a call per change). Keep room.host server. Choose defaults yourself. Build, repair every named diagnostic, preview_run and check; then play the requested mechanic in two browsers. A green check is not a playtest.`, { kind: 'game', ...r.result });
|
|
952
952
|
},
|
|
953
953
|
},
|
|
954
954
|
{
|
|
@@ -992,7 +992,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
992
992
|
const guide = a.interview === true && ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
993
993
|
return ok([
|
|
994
994
|
made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
|
|
995
|
-
'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
|
|
995
|
+
'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. New games are rules plus view with room.host server; read the game guide and RULES.md. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
|
|
996
996
|
'', PARTS_FIRST,
|
|
997
997
|
...(guide ? ['', 'Optional reference for a person who explicitly requests an interview:', guide] : []),
|
|
998
998
|
].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
|
|
@@ -1018,7 +1018,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1018
1018
|
},
|
|
1019
1019
|
{
|
|
1020
1020
|
name: 'build', title: 'Build the site',
|
|
1021
|
-
description: 'Bundle every game
|
|
1021
|
+
description: 'Bundle every game or app and the site into site/dist. Rules builds always run strict types, the guard and deterministic generated play, including save/restore and prediction checks. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
|
|
1022
1022
|
inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
|
|
1023
1023
|
annotations: { title: 'Build', ...RW },
|
|
1024
1024
|
run: async (a) => {
|
|
@@ -1097,8 +1097,9 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1097
1097
|
const url = a.url ? String(a.url) : await devUrl(ctx, root);
|
|
1098
1098
|
const shots = join('.checks', `${game}-look`);
|
|
1099
1099
|
return startRun(ctx, root, { kind: 'playtest', game, title: `${games.find((g) => g.id === game)?.name ?? game}: playtest`, steps: [
|
|
1100
|
+
...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
|
|
1100
1101
|
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
1101
|
-
{ label: 'pictures on a computer and a phone', soft: true, args: ['look', `/${game}/`, `/${game}
|
|
1102
|
+
{ label: 'pictures on a computer and a phone', soft: true, args: ['look', `/${game}/`, `/${game}/${games.find((g) => g.id === game)?.kind === 'app' ? 'open' : 'play'}`, '--url', url, '--only', 'computer,phone', '--shots', shots],
|
|
1102
1103
|
// The first screen of each page first (a whole page is a tall picture): what a person sees on arrival.
|
|
1103
1104
|
after: (j, run) => { run.look = (j.result?.rows ?? []).flatMap((r) => [r.fold, r.shot]).filter(Boolean).map((f) => relative(root, resolve(root, f))).slice(0, 8); } },
|
|
1104
1105
|
] });
|
|
@@ -1456,11 +1457,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1456
1457
|
if (!topics.includes(a.topic)) return fail(`guides: ${topics.join(', ')}`);
|
|
1457
1458
|
const dir = join(ctx.skillsDir, a.topic);
|
|
1458
1459
|
if (a.file) {
|
|
1459
|
-
const
|
|
1460
|
+
const name = basename(String(a.file));
|
|
1461
|
+
const direct = join(dir, name);
|
|
1462
|
+
const f = name.endsWith('.md') && existsSync(direct) ? direct : join(dir, 'references', name);
|
|
1460
1463
|
if (!existsSync(f)) return fail(`no reference ${a.file} in ${a.topic}`);
|
|
1461
1464
|
return ok(readFileSync(f, 'utf8'));
|
|
1462
1465
|
}
|
|
1463
|
-
const refs = (() => { try { return readdirSync(join(dir, 'references')); } catch { return []; } })();
|
|
1466
|
+
const refs = [...readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'SKILL.md'), ...(() => { try { return readdirSync(join(dir, 'references')); } catch { return []; } })()];
|
|
1464
1467
|
return ok(`${DESKTOP_NOTE}${ctx.desktop ? `\n\n${DESKTOP_APPROVALS}` : ''}\n\n${readFileSync(join(dir, 'SKILL.md'), 'utf8')}${refs.length ? `\n\nReferences (file): ${refs.join(', ')}` : ''}`);
|
|
1465
1468
|
},
|
|
1466
1469
|
});
|
package/lib/mcp.mjs
CHANGED
|
@@ -35,7 +35,8 @@ export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios a
|
|
|
35
35
|
|
|
36
36
|
Carry the person's request through to a working result without a lesson or an interview. Use their name or business name, otherwise "My Studio". Setup, dependencies, a plan, building and checking are AI work, not approval gates. Choose engineering and design defaults from the request; record them in CODEX.md so the person can change them later by asking. Phone and computer controls, a small first version, local sound and CC0 art are the defaults. No paid media unless requested with a budget. A business or charity may need an app or a site, not a game: do not make them play a demo or choose a genre.
|
|
37
37
|
Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
|
|
38
|
-
|
|
38
|
+
New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists and ordinary old games keep working. Repair the full build diagnostics and prove the requested behavior in two browsers. Current rooms have at most 32 seats; larger rooms and private per-seat state are not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
|
|
39
|
+
Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
|
|
39
40
|
For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
|
|
40
41
|
For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
|
|
41
42
|
|
package/lib/port-check.mjs
CHANGED
|
@@ -95,7 +95,7 @@ const steerable = (r) => finite(r) && !r[8];
|
|
|
95
95
|
export function judgeHold(rows, dir, a, b, size, view) {
|
|
96
96
|
const seg = rows.filter((r) => r[0] >= a && r[0] <= b + 40 && steerable(r));
|
|
97
97
|
const busyFrames = rows.filter((r) => r[0] >= a && r[0] <= b + 40 && finite(r) && r[8]).length;
|
|
98
|
-
if (seg.length < 8) return { ok: false, dir, busyFrames, why: busyFrames ? `the body was busy (knocked back, stunned, respawning) for ${busyFrames} frames of the hold` : `too few samples (${seg.length}): no body on the probe?` };
|
|
98
|
+
if (seg.length < 8) return { ok: false, dir, busyFrames, why: busyFrames ? `the body was busy (knocked back, stunned, respawning, or an intentional phase freeze) for ${busyFrames} frames of the hold; retry the actual controls during an active, steerable phase. Keep the mechanic and report this sample as blocked, not a controls pass` : `too few samples (${seg.length}): no body on the probe?` };
|
|
99
99
|
const u = DIRV[dir]; const r0 = seg[0];
|
|
100
100
|
const side = view === 'side';
|
|
101
101
|
const tiIdx = Math.max(0, seg.findIndex((r) => r[0] >= a + 400));
|
|
@@ -284,7 +284,7 @@ async function webkit(pw) {
|
|
|
284
284
|
const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
|
|
285
285
|
async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
|
|
286
286
|
const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, results: s.results ?? [], facts: s.facts ? { counts: s.facts.counts } : null } : null; }), 8000, null);
|
|
287
|
-
const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
|
|
287
|
+
const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.movement = n?.probe?.movement?.() ?? null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
|
|
288
288
|
const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
|
|
289
289
|
const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
|
|
290
290
|
|
|
@@ -411,6 +411,7 @@ const UI_COVER = `(() => {
|
|
|
411
411
|
|
|
412
412
|
async function ownerTests(h, how, log) {
|
|
413
413
|
const i = await info(h);
|
|
414
|
+
if (i?.movement === 'stationary') return { ok: null, applicable: false, why: 'Rules declare all player anchors with maxSpeed 0: directional movement is not applicable. Exercise the actual buttons, taps or word/turn actions in two browsers; this is not a controls pass.' };
|
|
414
415
|
const view = i?.v ?? 'top';
|
|
415
416
|
const size = Number(i?.size) || 1;
|
|
416
417
|
const keys = i?.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
|
|
@@ -565,7 +566,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
565
566
|
await shot(h, 'desk-playing');
|
|
566
567
|
const audio = after?.audio ?? null;
|
|
567
568
|
row('owner-desk', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
|
|
568
|
-
if (
|
|
569
|
+
if (res.applicable === false) row('audio', null, { why: 'No directional input was sent to this stationary game. Test sound with its actual controls.' });
|
|
570
|
+
else if (audio) {
|
|
569
571
|
const ctxs = audio.contexts ?? [];
|
|
570
572
|
row('audio', ctxs.length ? ctxs.every((s) => s === 'running') : null, { contexts: ctxs, refusedMedia: audio.refusedMedia, why: ctxs.length ? (ctxs.every((s) => s === 'running') ? undefined : 'an AudioContext is still suspended after the first keys') : 'no Web Audio in this game (media elements only, or silent)' });
|
|
571
573
|
}
|
|
@@ -591,7 +593,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
591
593
|
const after = await info(h);
|
|
592
594
|
await shot(h, 'phone-playing');
|
|
593
595
|
row('owner-phone', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
|
|
594
|
-
|
|
596
|
+
const stationaryDom = res.applicable === false && await inFrame(h, "!document.querySelector('canvas')", false);
|
|
597
|
+
row('ui-cover', stationaryDom ? null : cover ? cover.cover <= 0.12 && !cover.opaqueCentre.length : null, { ...(cover ?? {}), why: stationaryDom ? 'The stationary DOM interface is its play surface. Inspect its actual buttons and layout; a movement-world overlay limit is not applicable.' : cover && (cover.cover > 0.12 || cover.opaqueCentre.length) ? `UI covers ${Math.round(cover.cover * 100)}% of a phone${cover.opaqueCentre.length ? ' and something opaque sits in the middle' : ''}` : undefined });
|
|
595
598
|
}
|
|
596
599
|
collect(h); await close(h);
|
|
597
600
|
} catch (e) {
|
package/lib/port.mjs
CHANGED
|
@@ -269,8 +269,8 @@ export function importPort(root, folder, id, { name, mode } = {}) {
|
|
|
269
269
|
return {
|
|
270
270
|
ok: true, command: 'port import', id, dir: dest, from: src, mode: buildMode, files: copied.length, edits, plan: { grade: plan.grade, movement: plan.recommend.movement, view: plan.recommend.view },
|
|
271
271
|
next: [
|
|
272
|
-
`Write games/${id}/PORT.md: the grade and why,
|
|
273
|
-
|
|
272
|
+
`Write games/${id}/PORT.md: the grade and why, server truth, shared movement, the browser view, bots, touch and camera.`,
|
|
273
|
+
'Read the port skill and game RULES.md. Preserve the imported game as the baseline, then write server rules, shared movement and a view with openRoom. Use room.host server and the bundled view entry. The imported browser-hosted format remains available for local/offline/private use or maintaining an existing port.',
|
|
274
274
|
`npm run dev (in the background), then: npx --no-install homie-studio port check ${id} --url http://127.0.0.1:8787`,
|
|
275
275
|
],
|
|
276
276
|
};
|
package/lib/purchase-links.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {pathToFileURL} from 'node:url';
|
|
2
|
+
import {serviceTerms} from '../worker/service-terms.mjs';
|
|
1
3
|
/** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
|
|
2
4
|
import { createHash } from 'node:crypto';
|
|
3
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -13,6 +15,12 @@ export async function syncPurchaseLinks({ root, api, slug, site, mode, list, til
|
|
|
13
15
|
const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
|
|
14
16
|
return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
|
|
15
17
|
}));
|
|
18
|
+
const {toolFiles,buildTools}=await import('./tools-build.mjs');
|
|
19
|
+
if(toolFiles(root).length){
|
|
20
|
+
await buildTools(root,await import('esbuild'));
|
|
21
|
+
const definitions=(await import(pathToFileURL(join(root,'site/src/tools/index.mjs')).href+'?links='+Date.now())).default;
|
|
22
|
+
offers.push(...definitions.filter(t=>t.price).map(t=>({kind:'service',resource:t.name,version:'1.0.0',name:t.description,sale:serviceTerms(t.price)})));
|
|
23
|
+
}
|
|
16
24
|
const configured = offers.flatMap(o => {
|
|
17
25
|
const quantities = settings[o.resource]?.quantities ?? [1];
|
|
18
26
|
if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
|
package/lib/rules-build.mjs
CHANGED
|
@@ -53,9 +53,9 @@ function readMap(g) {
|
|
|
53
53
|
const dir = join(g.dir, 'map');
|
|
54
54
|
const names = existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.json')).sort() : [];
|
|
55
55
|
const name = names.includes('main.json') ? 'main.json' : names[0];
|
|
56
|
-
if (!name) throw new Error(
|
|
56
|
+
if (!name) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/map/ needs a map: map/main.json with "bounds", and the "boxes", "circles" and named "spots" the rules use`);
|
|
57
57
|
const raw = readJson(join(dir, name));
|
|
58
|
-
if (!raw || typeof raw !== 'object') throw new Error(
|
|
58
|
+
if (!raw || typeof raw !== 'object') throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/map/${name} is not JSON`);
|
|
59
59
|
return { ...raw, name: name.replace(/\.json$/, '') };
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -142,7 +142,7 @@ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cp
|
|
|
142
142
|
*/
|
|
143
143
|
export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
|
|
144
144
|
const guarded = await guardRules(esbuild, root, g.dir);
|
|
145
|
-
if (!guarded.ok) throw new Error(
|
|
145
|
+
if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
|
|
146
146
|
const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
|
|
147
147
|
const map = readMap(g);
|
|
148
148
|
const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
|
|
@@ -151,12 +151,12 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
151
151
|
// The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
|
|
152
152
|
await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
|
|
153
153
|
const checked = await runRulesWorker(bundle, data, { allowance: longCheck ? LONG_ALLOWANCE : ALLOWANCE });
|
|
154
|
-
for (const p of checked.problems) log(`warning: games/${g.id}
|
|
154
|
+
for (const p of checked.problems) log(`warning: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/${g.kind === 'app' ? 'app' : 'game'}.json: ${p}`);
|
|
155
155
|
for (const line of checked.information) log(`info: ${line}`);
|
|
156
156
|
const { settings, schema, publicTune, rounds, stateHash } = checked;
|
|
157
157
|
const trial = RULES_CAPACITY_TRIAL;
|
|
158
158
|
log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
|
|
159
|
-
if (settings.tickHz > trial.tickHz) log(`info: games/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
159
|
+
if (settings.tickHz > trial.tickHz) log(`info: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
160
160
|
const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
|
|
161
161
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
162
162
|
const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
@@ -168,7 +168,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
168
168
|
...(t !== 'snap' && rate > rateCap ? [`${t} ${rate}/s (cap ${rateCap}/s; over by ${rate - rateCap}/s)`] : [])];
|
|
169
169
|
});
|
|
170
170
|
if (exceeded.length) {
|
|
171
|
-
const message =
|
|
171
|
+
const message = `${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: browser rules smoke run measured snapshot ${stats.snapshotBytes} B (cap ${snapshotCap} B), checkpoint ${stats.checkpointBytes} B (cap ${checkpointCap} B). Exceeded: ${exceeded.join(', ')}. Reduce these frames or use room.host: server; offline play has no relay caps.`;
|
|
172
172
|
if (settings.host === 'browser') throw new Error(message);
|
|
173
173
|
log(`warning: ${message}`);
|
|
174
174
|
}
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import babelTraverse from '@babel/traverse';
|
|
|
42
42
|
import * as babelGenerator from '@babel/generator';
|
|
43
43
|
import * as t from '@babel/types';
|
|
44
44
|
import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
|
|
45
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
45
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
46
46
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
47
47
|
|
|
48
48
|
const traverse = babelTraverse.default ?? babelTraverse;
|
|
@@ -757,12 +757,18 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
|
|
|
757
757
|
|
|
758
758
|
/** An esbuild plugin that serves a game's guarded files in place of its sources (`stub`: files a bundle must not hold at all). */
|
|
759
759
|
export function guardedPlugin(files, { stub = [] } = {}) {
|
|
760
|
+
// esbuild resolves filesystem aliases (including macOS /tmp -> /private/tmp).
|
|
761
|
+
// Match those paths to the exact sources already checked and rewritten.
|
|
762
|
+
const canonical = (file) => existsSync(file) ? realpathSync(file) : file;
|
|
763
|
+
const guarded = new Map([...files].map(([file, code]) => [canonical(file), code]));
|
|
764
|
+
const omitted = new Set(stub.map(canonical));
|
|
760
765
|
return {
|
|
761
766
|
name: 'homie-rules-guard',
|
|
762
767
|
setup(b) {
|
|
763
768
|
b.onLoad({ filter: /\.(?:ts|js|mjs)$/ }, (a) => {
|
|
764
|
-
|
|
765
|
-
|
|
769
|
+
const file = canonical(a.path);
|
|
770
|
+
if (omitted.has(file)) return { contents: 'export default {};\n', loader: 'js' };
|
|
771
|
+
const code = guarded.get(file);
|
|
766
772
|
return typeof code === 'string' ? { contents: code, loader: 'js' } : null;
|
|
767
773
|
});
|
|
768
774
|
},
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
|
|
1
2
|
import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
|
|
2
3
|
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
3
4
|
import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
|
|
@@ -83,7 +84,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
83
84
|
// later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
|
|
84
85
|
no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
|
|
85
86
|
compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
|
|
86
|
-
...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] },
|
|
87
|
+
...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
|
|
87
88
|
workers_dev: true,
|
|
88
89
|
preview_urls: true,
|
|
89
90
|
// The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
|
|
@@ -125,7 +126,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
125
126
|
|
|
126
127
|
| Path | What it is |
|
|
127
128
|
| --- | --- |
|
|
128
|
-
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/
|
|
129
|
+
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/rules.ts\`, \`src/move.ts\`, \`src/view.ts\` and shared \`map/\` data; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
|
|
129
130
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
130
131
|
| \`games/<id>/assets/\` | Every model and texture a game ships, with where it came from and its licence (\`manifest.json\`), and \`RIGHTS.md\` in plain words; \`games/<id>/codex/decisions.json\` (private, like the codex) is its look as decisions, and \`style.json\` the palette, fonts, light and camera the game draws with. \`art/<slug>/\` holds art jobs (concepts, receipts; \`raw/\` is git-ignored). |
|
|
131
132
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
@@ -154,7 +155,7 @@ An app is ONE screen that morphs with context: camera moves, panels unfold, its
|
|
|
154
155
|
|
|
155
156
|
Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
|
|
156
157
|
|
|
157
|
-
Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
|
|
158
|
+
Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Customers can browse and buy through this same endpoint, and tools may declare a price using the studio's existing payments. Add event handlers with \`homie-studio function new <name> --event order.paid\`; read \`node_modules/@homie-rocks/studio/functions/FUNCTIONS.md\`. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
|
|
158
159
|
|
|
159
160
|
Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
|
|
160
161
|
|
|
@@ -170,7 +171,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
170
171
|
game: copy a starter only when the person asks for one, or once their game is planned.
|
|
171
172
|
- \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
|
|
172
173
|
how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
|
|
173
|
-
owner tests (real touch, a late joiner,
|
|
174
|
+
owner tests (real touch, a late joiner, one browser leaving, two browsers finishing a round). The Homie
|
|
174
175
|
plugin's \`port\` skill does the whole job.
|
|
175
176
|
- \`npm run build\` — bundle every game into \`site/dist\`. It builds in a folder of its own and puts the result
|
|
176
177
|
in place only when all of it is there: a game that does not build fails the command (a non-zero exit) and
|
|
@@ -196,7 +197,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
196
197
|
- \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
|
|
197
198
|
land in the same room and finish a round. Run it before you say a game works.
|
|
198
199
|
- \`npx --no-install homie-studio perf <id> --url <site>\` — how fast a game runs, on a computer and an emulated
|
|
199
|
-
phone, with two browsers in a room (
|
|
200
|
+
phone, with two browsers in a room (two replicas when the server hosts): frame times, the game's JavaScript and the main thread
|
|
200
201
|
per frame, time to playable, what it downloads, the heap, netplay messages a second (files under \`.perf/\`). The
|
|
201
202
|
Homie plugin's \`perf\` skill runs the whole loop: one change at a time, kept only when it is better beyond the noise
|
|
202
203
|
and \`check\` still passes, and a report in \`perf/<id>/\`.
|
|
@@ -278,11 +279,37 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
278
279
|
|
|
279
280
|
## Making games
|
|
280
281
|
|
|
281
|
-
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
282
|
+
- **New games are rules plus view, hosted by the server.** Read the Homie game skill and its
|
|
283
|
+
RULES.md before writing code (MCP: studio_guide game, then studio_guide game file RULES.md).
|
|
284
|
+
Start with coin-dash, gem-rush, ember-vale, gem-rush-3d or hero-rush-3d; all five follow this contract.
|
|
285
|
+
In games/<id>/src/rules.ts, defineRules declares entities, fields, handlers, events, rounds and companions.
|
|
286
|
+
The view calls openRoom and sends input/commands; it draws, plays sound and handles controls. It never
|
|
287
|
+
decides a hit, score, spawn, turn or result. Keep game.json room.host set to server, up to 32 seats.
|
|
288
|
+
- **Movement feels immediate through prediction.** Put the shared defineMove callback in src/move.ts;
|
|
289
|
+
it runs authoritatively on the server and immediately for your own view, then reconciles. Other bodies
|
|
290
|
+
interpolate. Use the starter's sweep/slide and room.me/room.each poses; no second browser physics loop,
|
|
291
|
+
and no owner-movement workaround. Discrete actions wait for the server's decision.
|
|
292
|
+
- **Truth is declared and deterministic.** Entity handlers write their own fields and send next-tick events;
|
|
293
|
+
room handlers write shared fields. Use world.tick/dt/ticks/after/random and world.math (ctx.math in move),
|
|
294
|
+
never clocks, Math.random, hidden module state, async/network calls or browser APIs in rules.
|
|
295
|
+
Read RULES.md for the bounded query and execution budget, declared commands/effects/asks, and message fixes.
|
|
296
|
+
- **Level data is shared.** map/main.json describes collision and bounds in metres. A 3D rules body's z is
|
|
297
|
+
height at its feet; Three.js draws x,z,y. Floors, walls and platforms must agree in rules and view.
|
|
298
|
+
Public tunables describe movement. Bots and companions use the same inputs and declared goals, with a
|
|
299
|
+
scripted floor that plays without AI. Asks declare bounded state, questions and a deterministic floor.
|
|
300
|
+
- **Choose sensible defaults yourself.** Do not ask the person for a hosting mode, movement mode, tick rate
|
|
301
|
+
or serialization scheme. Preserve the requested mechanic. Turn-based games and live apps may use manual
|
|
302
|
+
rounds. Shared live app state uses these same rooms; durable records and authentication use the app APIs.
|
|
303
|
+
A build-only request ends with a working local result; publish only within the owner's requested scope.
|
|
304
|
+
- **Check changes honestly.** Build first, repair each source-located guard/type/play-check error using RULES.md,
|
|
305
|
+
then check with two browsers and play the requested mechanic with network delay. Info is not a failure.
|
|
306
|
+
Never remove rules, weaken checks, forge a probe or switch hosting to get past a message.
|
|
307
|
+
Rules hashes and room saves are automatic; character saves are player-owned, untrusted input. Room state
|
|
308
|
+
is replicated, not secret. Bigger rooms and private state are not delivered by milestone 1.
|
|
309
|
+
- **Existing games:** change rules truth in rules and presentation in view. Browser hosting is for offline,
|
|
310
|
+
local development and private friends games. Untouched older createNetplay/createRoom games still work;
|
|
311
|
+
when asked to rewrite one, read the game skill's REWRITE.md and preserve its art, view and feel. There is
|
|
312
|
+
no automatic converter. NETPLAY.md section 29 is the rules contract; earlier sections describe older APIs.
|
|
286
313
|
- Phones and computers: touch controls on phones only, keys on computers; keep the
|
|
287
314
|
centre of the screen clear during play.
|
|
288
315
|
- The play page's small room button (Invite, Big screen, the room code) sits top right, with the Chat pill
|
|
@@ -432,7 +459,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
432
459
|
\`--policy beginner --guides 2 [--kids]\`, \`--policy humans-only\`) makes one at once; \`servers set <id> <server>
|
|
433
460
|
--level-max 3 …\` changes one; \`servers close <id> <server>\`. A change that narrows who may come in (humans-only,
|
|
434
461
|
a stricter door) and closing one only ASK, with the owner's one-tap link, like \`office kick\`.
|
|
435
|
-
- **
|
|
462
|
+
- **Rules companions:** use world.level, world.guideLevel, world.guideSeats and world.kids; guide.view,
|
|
463
|
+
guide.floor, think and goalDone follow the rules contract. The old BotBrain/useAgents/decide recipes below
|
|
464
|
+
apply only to unchanged browser-hosted games. Read RULES.md for a new game.
|
|
465
|
+
- **The skill dial (older browser-hosted games):** every room has a level, 1 Rookie, 2 Steady, 3 Fair, 4 Strong, 5 Maxed, each \`{ reactionMs,
|
|
436
466
|
aimNoise, aggression, positioning }\`. The party sets it by voting on a card in the play page (the middle vote
|
|
437
467
|
wins, capped by the server's ceiling). Make a game's bots honour it: \`net.skillOf(slot)\` in their step (the
|
|
438
468
|
snippet is in \`NETPLAY.md\` section 17; \`BotBrain\` from the port kit reads it with a rebuild), and declare
|
|
@@ -763,6 +793,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
763
793
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
764
794
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
765
795
|
[`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
|
|
796
|
+
[`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
|
|
797
|
+
[`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
|
|
766
798
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
767
799
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
768
800
|
'.claude/skills/.gitkeep': '',
|
|
@@ -934,13 +966,23 @@ function ensurePartsStateMigration(root) {
|
|
|
934
966
|
return `site/migrations/${PURCHASE_STATE_FILE}`;
|
|
935
967
|
}
|
|
936
968
|
|
|
969
|
+
function ensureFunctionCursors(root) {
|
|
970
|
+
const file=join(root,'site','migrations',FUNCTIONS_CURSOR_MIGRATION_FILE);
|
|
971
|
+
if(existsSync(file))return null;
|
|
972
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_CURSOR_MIGRATION);return `site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`;
|
|
973
|
+
}
|
|
974
|
+
function ensureFunctionsMigration(root) {
|
|
975
|
+
const file=join(root,'site','migrations',FUNCTIONS_MIGRATION_FILE);
|
|
976
|
+
if(existsSync(file))return null;
|
|
977
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_MIGRATION);return `site/migrations/${FUNCTIONS_MIGRATION_FILE}`;
|
|
978
|
+
}
|
|
937
979
|
function ensureMcpMigration(root) {
|
|
938
980
|
const file=join(root,'site','migrations',MCP_MIGRATION_FILE);
|
|
939
981
|
if(existsSync(file))return null;
|
|
940
982
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
|
|
941
983
|
}
|
|
942
984
|
export function ensureMigrations(root) {
|
|
943
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root)].filter(Boolean);
|
|
985
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root)].filter(Boolean);
|
|
944
986
|
}
|
|
945
987
|
|
|
946
988
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
@@ -1005,10 +1047,11 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
1005
1047
|
const q = JSON.stringify(realpathSync(dir));
|
|
1006
1048
|
return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
|
|
1007
1049
|
`cd ${q}${install ? '' : ' && npm install'}`,
|
|
1008
|
-
'
|
|
1009
|
-
'
|
|
1050
|
+
'read the game skill and RULES.md; choose sensible defaults and make the requested game as server rules plus view',
|
|
1051
|
+
'optional live example, without copying a game here: npx --no-install homie-studio demo',
|
|
1052
|
+
'only when the person asks for a game: start from a rules starter and implement the requested mechanic: npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
1010
1053
|
'npm run dev (the home page, "first game coming soon", at http://127.0.0.1:8787/; with a game: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
1011
|
-
'npm run deploy (then
|
|
1054
|
+
'when the owner asks to publish: npm run deploy (then studio_publish, or: npx --no-install homie-studio publish)',
|
|
1012
1055
|
], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
|
|
1013
1056
|
}
|
|
1014
1057
|
|
package/lib/shop.mjs
CHANGED
|
@@ -287,7 +287,7 @@ export async function shopConnectManual(root, { fromClipboard = false, clipboard
|
|
|
287
287
|
const shopNow = readShop(root);
|
|
288
288
|
const till = managed === true ? 'stripe-managed' : managed === false ? 'stripe' : shopNow.shop?.till ?? 'stripe';
|
|
289
289
|
const nonce = randomBytes(16).toString('hex');
|
|
290
|
-
const paidParts = paidReleases(root).length > 0;
|
|
290
|
+
const paidParts = paidReleases(root).length > 0 || (shopNow.shop?.items?.length??0)>0 || (await import('./tools-build.mjs')).toolFiles(root).length>0;
|
|
291
291
|
const page = connectPage({ paidParts, nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
|
|
292
292
|
return await new Promise((done) => {
|
|
293
293
|
let finished = false;
|