@homie-rocks/studio 0.51.6 → 0.51.8
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 +35 -0
- package/apps/APPS.md +26 -0
- package/bin/homie-studio.mjs +10 -5
- package/docs/toolkit-place-patterns.md +62 -0
- package/functions/FUNCTIONS.md +2 -2
- package/guides/app/SKILL.md +34 -0
- package/guides/app/examples/reserve.mjs +13 -0
- package/guides/functions/SKILL.md +2 -0
- package/guides/office/SKILL.md +27 -1
- package/guides/servers/SKILL.md +27 -1
- package/guides/shop/SKILL.md +38 -15
- package/guides/studio-setup/SKILL.md +4 -0
- package/lib/app-needs.mjs +8 -2
- package/lib/app-ready.mjs +31 -0
- package/lib/check.mjs +9 -6
- package/lib/cloudflare.mjs +4 -4
- package/lib/connector-token.mjs +30 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/shop-links.mjs +70 -32
- package/lib/shop-mcp.mjs +37 -10
- package/lib/stripe-connect.mjs +4 -4
- package/lib/template-history.json +84 -0
- package/lib/worker-config.mjs +3 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/collision.ts +4 -2
- package/rules/terrain.ts +2 -1
- package/rules/tiles.ts +8 -4
- package/shop/SHOP.md +38 -15
- package/worker/app-graphics.mjs +8 -0
- package/worker/app-shell.mjs +36 -15
- package/worker/build-id.mjs +1 -1
- package/worker/connector-token.mjs +11 -0
- package/worker/index.mjs +5 -2
- package/worker/join-area.mjs +35 -0
- package/worker/mcp-tools.mjs +12 -3
- package/worker/mcp.mjs +14 -6
- package/worker/pages.mjs +6 -3
- package/worker/records-page.mjs +3 -2
- package/worker/records-prefs.mjs +24 -0
- package/worker/shop.mjs +2 -2
- package/worker/version.mjs +1 -1
package/lib/cloudflare.mjs
CHANGED
|
@@ -435,7 +435,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
435
435
|
if (url) {
|
|
436
436
|
const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
|
|
437
437
|
claim = read.claim;
|
|
438
|
-
if (claim) step('the site
|
|
438
|
+
if (claim) step(listedHere(root,{site:url})?'the site is listed':'the site is claimed, not listed');
|
|
439
439
|
else if (read.state === 'other' && !isWorkersDev(url)) {
|
|
440
440
|
// The studio's own domain answered as something else: say which route is needed (lib/routes.mjs). The deploy
|
|
441
441
|
// itself went through; the zone's other routes are never touched.
|
|
@@ -486,7 +486,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
486
486
|
...(reread ? { reread } : {}),
|
|
487
487
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
488
488
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
489
|
-
claim: Boolean(claim), directory,
|
|
489
|
+
claim: Boolean(claim), listing: listedHere(root,{site:url}) ? 'listed' : claim ? 'claimed, not listed' : 'not listed', directory,
|
|
490
490
|
};
|
|
491
491
|
}
|
|
492
492
|
|
|
@@ -638,7 +638,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
638
638
|
let url = null;
|
|
639
639
|
try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
|
|
640
640
|
const claim = url ? await warmClaim(url) : null;
|
|
641
|
-
step(claim ? 'the site
|
|
641
|
+
step(claim ? (listedHere(root,{site:url})?'the site is listed':'the site is claimed, not listed') : 'no directory claim yet (the site claims itself when the directory first reads it)');
|
|
642
642
|
let games = [];
|
|
643
643
|
try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
|
|
644
644
|
const hashes = builtGames(root, games);
|
|
@@ -648,7 +648,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
648
648
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
649
649
|
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
650
650
|
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
|
651
|
-
claim: Boolean(claim),
|
|
651
|
+
claim: Boolean(claim), listing: listedHere(root,{site:url}) ? 'listed' : claim ? 'claimed, not listed' : 'not listed',
|
|
652
652
|
};
|
|
653
653
|
}
|
|
654
654
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {randomBytes,createHash} from 'node:crypto';
|
|
2
|
+
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join,resolve} from 'node:path';
|
|
5
|
+
import {runner,configNames} from './cloudflare.mjs';
|
|
6
|
+
import {siteUrl} from './studio.mjs';
|
|
7
|
+
/** D1 administration uses the owner's existing Wrangler login. No bearer enters stdout or argv. */
|
|
8
|
+
export async function connectorToken(root,{url,tools,out,revoke,days=30,execute}={}) {
|
|
9
|
+
const run=execute??(async file=>{const names=configNames(root);if(!names.d1)throw new Error('Studio database is not configured');const result=runner(root)(['d1','execute',names.d1,'--remote','--file',file]);if(result.code!==0)throw new Error('Connector token database operation failed; check the existing Wrangler login and studio database');});
|
|
10
|
+
const scratch=mkdtempSync(join(tmpdir(),'homie-connector-'));
|
|
11
|
+
const sql=async text=>{const file=join(scratch,'token.sql');writeFileSync(file,text,{mode:0o600});await run(file);};
|
|
12
|
+
const quote=value=>"'"+value.replaceAll("'","''")+"'";
|
|
13
|
+
try {
|
|
14
|
+
if(revoke){if(!/^[a-f0-9]{64}$/.test(revoke))throw new Error('Invalid connector id');await sql(`DELETE FROM mcp_kv WHERE key='connector:${revoke}';`);return {ok:true,command:'connector',revoked:revoke};}
|
|
15
|
+
if(!Array.isArray(tools)||!tools.length||tools.length>100||tools.some(t=>! /^[a-zA-Z0-9_-]{1,128}$/.test(t)))throw new Error('Provide explicit tool names with --tools (no wildcard)');
|
|
16
|
+
if(!out)throw new Error('Provide --out for the private connector configuration');
|
|
17
|
+
if(!Number.isInteger(days)||days<1||days>90)throw new Error('Expiry must be 1–90 days');
|
|
18
|
+
const origin=new URL(url??siteUrl(root)).origin;if(!origin.startsWith('https://'))throw new Error('Connector requires HTTPS');
|
|
19
|
+
const token='homie_owner_'+randomBytes(32).toString('hex');const id=createHash('sha256').update(token).digest('hex');
|
|
20
|
+
const expires=Math.floor(Date.now()/1000)+days*86400;
|
|
21
|
+
const value=JSON.stringify({origin,tools:[...new Set(tools)],expires});
|
|
22
|
+
// Reserve the destination before minting; never overwrite a credential or follow a symlink.
|
|
23
|
+
const path=resolve(root,out);writeFileSync(path,'',{flag:'wx',mode:0o600});
|
|
24
|
+
try{
|
|
25
|
+
await sql(`INSERT INTO mcp_kv(key,value,expires) VALUES('connector:${id}',${quote(value)},${expires});`);
|
|
26
|
+
writeFileSync(path,JSON.stringify({mcpServers:{studio:{url:origin+'/mcp/auth',headers:{Authorization:'Bearer '+token}}}},null,2)+'\n',{mode:0o600});
|
|
27
|
+
}catch(error){rmSync(path,{force:true});throw error;}
|
|
28
|
+
return {ok:true,command:'connector',id,file:path,expires,tools:[...new Set(tools)]};
|
|
29
|
+
}finally{rmSync(scratch,{recursive:true,force:true});}
|
|
30
|
+
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1178,7 +1178,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1178
1178
|
},
|
|
1179
1179
|
{
|
|
1180
1180
|
name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
|
|
1181
|
-
description: 'Default setup with Stripe official MCP. Supply public API observations as {path,params,body}, account ID, mode, and complete booking slots. Returns
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, retain write responses as observations and replan until verified. verify:true saves compact public config for deploy and stripe_webhook. Never pass webhook creation responses or secrets.',
|
|
1182
1182
|
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
|
|
1183
1183
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
1184
|
run:async a=>{
|
package/lib/shop-links.mjs
CHANGED
|
@@ -7,7 +7,11 @@ 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
9
|
export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
|
|
10
|
-
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false }) {
|
|
10
|
+
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false, knownLinks = [], knownPortal }) {
|
|
11
|
+
if(scoped) {
|
|
12
|
+
const transport=api;
|
|
13
|
+
api=(method,path,params={},idempotency)=>transport(method,path,method==='POST'&&path.startsWith('/v1/payment_links/')?{...params,expand:['line_items']}:params,idempotency);
|
|
14
|
+
}
|
|
11
15
|
const list = async (path, params = {}) => {
|
|
12
16
|
const all = []; let cursor;
|
|
13
17
|
do {
|
|
@@ -20,13 +24,32 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
20
24
|
} while (cursor);
|
|
21
25
|
return all;
|
|
22
26
|
};
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const parallel = async tasks => {
|
|
28
|
+
if(!scoped){const values=[];for(const task of tasks)values.push(await task());return values;}
|
|
29
|
+
const results = await Promise.allSettled(tasks.map(task=>task()));
|
|
30
|
+
const errors=results.filter(r=>r.status==='rejected').map(r=>r.reason);
|
|
31
|
+
const fatal=errors.find(e=>!e.next&&!e.operations);if(fatal)throw fatal;
|
|
32
|
+
if(errors.length)throw {operations:errors.flatMap(e=>e.operations??[e.next])};
|
|
33
|
+
return results.map(r=>r.value);
|
|
34
|
+
};
|
|
35
|
+
const search = async kind => {
|
|
36
|
+
const data=[];let page;
|
|
37
|
+
do {
|
|
38
|
+
const result=await api('GET',`/v1/${kind}/search`,{query:`metadata['homie']:'shop-links-v1' AND metadata['studio']:'${slug}' AND metadata['origin']:'${site}'`,limit:100,...(kind==='products'?{expand:['data.default_price']}:{}),...(page?{page}:{})});
|
|
39
|
+
if(!Array.isArray(result.data)||result.has_more&&!result.next_page||page&&result.next_page===page)throw new Error('Incomplete Stripe pagination');
|
|
40
|
+
data.push(...result.data);page=result.has_more?result.next_page:null;
|
|
41
|
+
}while(page);
|
|
42
|
+
return data;
|
|
43
|
+
};
|
|
44
|
+
const [products,prices,links]=await parallel([
|
|
45
|
+
()=>scoped?search('products'):list('/v1/products'),
|
|
46
|
+
()=>scoped?search('prices'):list('/v1/prices'),
|
|
47
|
+
()=>scoped?parallel([...new Set(knownLinks)].map(id=>()=>api('GET',`/v1/payment_links/${id}`,{expand:['line_items']}))):list('/v1/payment_links'),
|
|
48
|
+
]);
|
|
26
49
|
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
27
50
|
const verifyLink = async (link, params) => {
|
|
28
51
|
if (!verifyObjects) return;
|
|
29
|
-
const actual=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
|
|
52
|
+
const actual=link.line_items?.data?link:await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
|
|
30
53
|
const normalized={...actual,line_items:actual.line_items?.data?.map(line=>({...line,price:line.price?.id??line.price}))};
|
|
31
54
|
if(actual.id!==link.id || actual.active!==link.active || actual.url!==link.url || actual.line_items?.has_more===true || !matchesFields(actual.metadata,link.metadata) || !paymentLinkURL(actual.url) || !matchesFields(normalized,params)) throw new Error(`Stripe Payment Link ${link.id} differs from its planned fields. Restore its configuration through Stripe MCP before verifying.`);
|
|
32
55
|
};
|
|
@@ -34,21 +57,26 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
34
57
|
const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
|
|
35
58
|
const catalogPrices = new Map();
|
|
36
59
|
const keepPrices = new Set(), keepLinks = new Set(), keepProducts = new Set();
|
|
37
|
-
|
|
60
|
+
await parallel(shop.items.map(item=>async()=>{
|
|
38
61
|
const id = await productIdOf(slug, item.id);
|
|
39
62
|
keepProducts.add(id);
|
|
40
63
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
41
64
|
const fields = { name: item.name, description:item.blurb??'', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
|
|
42
|
-
let product = products.find((p) => p.id === id);
|
|
43
|
-
if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
|
|
44
|
-
else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== (fields.description ?? '') || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
|
|
45
65
|
// Omit an unset minimum: Stripe applies its settlement-currency floor itself.
|
|
46
66
|
// https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
|
|
47
67
|
const priceFields = { product: id, currency: shop.currency, ...(item.interval ? { recurring: { interval: item.interval } } : {}), tax_behavior: 'exclusive', ...(item.price === 'choose'
|
|
48
68
|
? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
|
|
49
69
|
: { unit_amount: item.price }) };
|
|
50
70
|
const revision = hash(priceFields);
|
|
71
|
+
const createFields={...fields};if(!createFields.description)delete createFields.description;
|
|
72
|
+
let product = products.find(p=>p.id===id);
|
|
73
|
+
if(!product) {
|
|
74
|
+
const {product:_,...default_price_data}=priceFields;
|
|
75
|
+
product=await api('POST','/v1/products',{id,...createFields,...(scoped?{default_price_data:{...default_price_data,metadata:{...metadata,revision}},expand:['default_price']}:{})},`product-${hash(id)}`);
|
|
76
|
+
} else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
|
|
77
|
+
|
|
51
78
|
let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
|
|
79
|
+
if(!price && product.default_price?.id && product.default_price.metadata?.revision===revision)price=product.default_price;
|
|
52
80
|
if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
|
|
53
81
|
if (verifyObjects && ((price.recurring?.interval_count??1)!==1 || Boolean(price.recurring)!==Boolean(item.interval) || Boolean(price.custom_unit_amount)!==(item.price==='choose'))) throw new Error(`Stripe Price ${price.id} has a different payment type from shop.json`);
|
|
54
82
|
if (verifyObjects && !matchesFields({...price, product:price.product?.id??price.product, ...(price.custom_unit_amount?{custom_unit_amount:{...price.custom_unit_amount,enabled:true}}:{})},priceFields)) throw new Error(`Stripe Price ${price.id} differs from shop.json; restore or archive it through Stripe MCP before planning again.`);
|
|
@@ -56,7 +84,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
56
84
|
keepPrices.add(price.id);
|
|
57
85
|
catalogPrices.set(item.id, price.id);
|
|
58
86
|
if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
|
|
59
|
-
if (item.price === 0)
|
|
87
|
+
if (item.price === 0) return; // Free orders never leave the studio.
|
|
60
88
|
const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
|
|
61
89
|
after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
|
|
62
90
|
...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
|
|
@@ -64,48 +92,54 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
64
92
|
};
|
|
65
93
|
const linkRevision = hash({ params, item, policy: shop.policy });
|
|
66
94
|
let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
|
|
67
|
-
if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
|
|
95
|
+
if (!link) link = await api('POST', '/v1/payment_links', { ...params, ...(scoped?{expand:['line_items']}:{}), metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
|
|
68
96
|
await verifyLink(link,params);
|
|
69
97
|
if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
|
|
70
98
|
if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
|
|
71
99
|
keepLinks.add(link.id);
|
|
72
100
|
config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, revision: linkRevision });
|
|
73
|
-
}
|
|
101
|
+
}));
|
|
74
102
|
// Menus use paid invoice snapshots: Checkout events omit their line items.
|
|
75
103
|
// Stripe allows 20 Payment Link lines; invoice snapshots include only the first
|
|
76
104
|
// 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
|
|
77
105
|
config.menus = [];
|
|
78
106
|
const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
|
|
79
107
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
108
|
+
const tasks=[];
|
|
80
109
|
const groups=Map.groupBy(menuItems,i=>i.game??'');
|
|
81
110
|
for(const group of (shop.basket ? [] : groups.values())) for (let offset = 0; offset < group.length; offset += 10) {
|
|
82
|
-
const
|
|
83
|
-
|
|
111
|
+
const groupOffset=offset;tasks.push(async()=>{
|
|
112
|
+
const items = group.slice(groupOffset, groupOffset + 10);
|
|
113
|
+
if (items.length < 2) return;
|
|
84
114
|
const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
|
|
85
115
|
invoice_creation:{enabled:true,invoice_data:{metadata}},
|
|
86
116
|
after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}},
|
|
87
117
|
automatic_tax:{enabled:shop.automaticTax}, payment_intent_data:{metadata} };
|
|
88
118
|
const revision=hash(params);
|
|
89
119
|
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
90
|
-
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
|
|
120
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,revision}},`menu-${revision}`);
|
|
91
121
|
await verifyLink(link,params);
|
|
92
122
|
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
93
123
|
keepLinks.add(link.id);
|
|
94
124
|
config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
95
|
-
}
|
|
125
|
+
});}
|
|
96
126
|
// One aggregate payment represents a frozen local basket, including one-item baskets.
|
|
97
127
|
// Runtime uses prefilled_amount, never a shared Price mutation: concurrent
|
|
98
128
|
// guests must receive their own saved totals.
|
|
99
|
-
if(shop.basket) for(const [game,items] of groups) {
|
|
129
|
+
if(shop.basket) for(const [game,items] of groups) {tasks.push(async()=>{
|
|
100
130
|
const id=await productIdOf(slug,`basket-${game||'shop'}`);
|
|
101
131
|
keepProducts.add(id);
|
|
102
132
|
const fields={name:shop.basket.name,description:shop.basket.description,metadata,active:true};
|
|
103
|
-
const product=products.find(p=>p.id===id);
|
|
104
|
-
if(!product)await api('POST','/v1/products',{id,...fields},`product-${hash(id)}`);
|
|
105
|
-
else if(product.name!==fields.name || product.description!==fields.description || !product.active)await api('POST',`/v1/products/${id}`,fields);
|
|
106
133
|
const priceFields={product:id,currency:shop.currency,custom_unit_amount:{enabled:true},tax_behavior:'inclusive'};
|
|
107
134
|
const priceRevision=hash(priceFields);
|
|
135
|
+
let product=products.find(p=>p.id===id);
|
|
136
|
+
if(!product) {
|
|
137
|
+
const {product:_,...default_price_data}=priceFields;
|
|
138
|
+
const createFields={...fields};if(!createFields.description)delete createFields.description;
|
|
139
|
+
product=await api('POST','/v1/products',{id,...createFields,...(scoped?{default_price_data:{...default_price_data,metadata:{...metadata,revision:priceRevision}},expand:['default_price']}:{})},`product-${hash(id)}`);
|
|
140
|
+
} else if(product.name!==fields.name || product.description!==fields.description || !product.active)await api('POST',`/v1/products/${id}`,fields);
|
|
108
141
|
let price=prices.find(p=>own(p)&&p.product===id&&p.metadata?.revision===priceRevision);
|
|
142
|
+
if(!price&&product.default_price?.metadata?.revision===priceRevision)price=product.default_price;
|
|
109
143
|
if(!price)price=await api('POST','/v1/prices',{...priceFields,metadata:{...metadata,revision:priceRevision}},`price-${hash({priceFields,metadata})}`);
|
|
110
144
|
if(!price.active)await api('POST',`/v1/prices/${price.id}`,{active:true});
|
|
111
145
|
keepPrices.add(price.id);
|
|
@@ -113,12 +147,13 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
113
147
|
after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
114
148
|
const revision=hash(params);
|
|
115
149
|
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
116
|
-
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`basket-${revision}`);
|
|
150
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,revision}},`basket-${revision}`);
|
|
117
151
|
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
152
|
+
await verifyLink(link,params);
|
|
118
153
|
if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
|
|
119
154
|
keepLinks.add(link.id);
|
|
120
155
|
config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
121
|
-
}
|
|
156
|
+
});}
|
|
122
157
|
config.slots=[];
|
|
123
158
|
const pause=async link=>{
|
|
124
159
|
if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
@@ -132,12 +167,13 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
132
167
|
const resource=JSON.stringify([slot.app,slot.slots,slot.slot]);
|
|
133
168
|
if(slotKeys.has(resource))throw new Error('Publish only one service Payment Link per capacity slot');
|
|
134
169
|
slotKeys.add(resource);
|
|
170
|
+
tasks.push(async()=>{
|
|
135
171
|
const item=shop.items.find(i=>i.id===slot.item);
|
|
136
172
|
if(!item || item.interval || item.price==='choose')throw new Error('Bookings require a fixed one-time service price');
|
|
137
|
-
if(!item.price)
|
|
173
|
+
if(!item.price)return;
|
|
138
174
|
const identity=hash({slug,site,app:slot.app,collection:slot.collection,slots:slot.slots,slot:slot.slot});
|
|
139
175
|
let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
|
|
140
|
-
if(!link && (!slot.capacity||slot.active===false))
|
|
176
|
+
if(!link && (!slot.capacity||slot.active===false))return;
|
|
141
177
|
if(link?.metadata?.price && link.metadata.price!==catalogPrices.get(item.id))throw new Error('Slot service price changed. Deactivate the old slot and publish a new slot ID after reviewing existing bookings.');
|
|
142
178
|
const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
|
|
143
179
|
const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
|
|
@@ -149,7 +185,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
149
185
|
const active=slot.active!==false && limit>count;
|
|
150
186
|
const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
|
|
151
187
|
restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
152
|
-
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
|
|
188
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
|
|
153
189
|
else {
|
|
154
190
|
// Pause first whenever publishing a changed budget, so old sessions cannot
|
|
155
191
|
// spend a previous capacity after staff moves or removes a slot.
|
|
@@ -159,17 +195,19 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
159
195
|
await verifyLink(link,params);
|
|
160
196
|
keepLinks.add(link.id);
|
|
161
197
|
config.slots.push({...slot,id:link.id,url:link.url,price:catalogPrices.get(item.id),revision:identity,active:link.active,count:link.restrictions?.completed_sessions?.count??0,limit,paused:slot.active===false});
|
|
198
|
+
});
|
|
162
199
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
for(const link of links.filter(p=>own(p)&&p.metadata?.slot&&!keepLinks.has(p.id)))await pause(link);
|
|
166
|
-
if(shop.items.some(i=>i.interval)) {
|
|
167
|
-
const portals=await list('/v1/billing_portal/configurations');
|
|
200
|
+
if(shop.items.some(i=>i.interval)) tasks.push(async()=>{
|
|
201
|
+
const portals=scoped?(knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:[]):await list('/v1/billing_portal/configurations');
|
|
168
202
|
let portal=portals.find(p=>own(p));
|
|
169
203
|
const params={features:{subscription_cancel:{enabled:true},payment_method_update:{enabled:true},invoice_history:{enabled:true}},login_page:{enabled:true},business_profile:{headline:'Manage your membership or giving'},default_return_url:`${site}/shop/`,metadata};
|
|
170
204
|
if (!portal || !matchesFields(portal, params)) portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params, portal?undefined:`portal-${hash({slug,site,params})}`);
|
|
171
|
-
config.portal=portal.login_page?.url;
|
|
172
|
-
}
|
|
205
|
+
config.portal=portal.login_page?.url;config.portalId=portal.id;
|
|
206
|
+
});
|
|
207
|
+
await parallel(tasks);
|
|
208
|
+
// Retired booking links must be closed before publishing a configuration which
|
|
209
|
+
// no longer names them; staff can then move into that local-only slot safely.
|
|
210
|
+
for(const link of links.filter(p=>own(p)&&p.metadata?.slot&&!keepLinks.has(p.id)))await pause(link);
|
|
173
211
|
const retire = [
|
|
174
212
|
...links.filter(p=>own(p)&&p.active&&!keepLinks.has(p.id)).map(p=>({method:'POST',path:`/v1/payment_links/${p.id}`,params:{active:false}})),
|
|
175
213
|
...prices.filter(p=>own(p)&&p.active&&!keepPrices.has(p.id)).map(p=>({method:'POST',path:`/v1/prices/${p.id}`,params:{active:false}})),
|
package/lib/shop-mcp.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import {canonicalJson} from '../worker/canonical-json.mjs';
|
|
2
|
+
/** Pure, batched Stripe plan. The owner's AI supplies public API reads; no Stripe transport here. */
|
|
2
3
|
import {createHash} from 'node:crypto';
|
|
3
4
|
import {existsSync, readFileSync, mkdirSync, writeFileSync} from 'node:fs';
|
|
4
5
|
import {join} from 'node:path';
|
|
@@ -7,7 +8,8 @@ import {readStudio, siteUrl} from './studio.mjs';
|
|
|
7
8
|
import {reconcilePaymentLinks} from './shop-links.mjs';
|
|
8
9
|
import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
|
|
9
10
|
const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
|
|
10
|
-
export const readKey = (path, params) =>
|
|
11
|
+
export const readKey = (path, params) => canonicalJson([path,params]);
|
|
12
|
+
const slotsOf=(root,slots)=>slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
|
|
11
13
|
export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
|
|
12
14
|
const checked=readShop(root);
|
|
13
15
|
if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
|
|
@@ -20,28 +22,41 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
20
22
|
const local=new URL(site).protocol==='http:';
|
|
21
23
|
if(local && (mode!=='test' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(new URL(site).hostname)))throw new Error('Local checkout requires a private HTTP address and test mode');
|
|
22
24
|
if(!local && new URL(site).protocol!=='https:')throw new Error('Use the studio HTTPS address');
|
|
23
|
-
const bookingSlots=
|
|
25
|
+
const bookingSlots=slotsOf(root,slots);
|
|
24
26
|
const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
|
|
25
27
|
// Reject secrets before persisting observations. Webhook creation is never part of this plan.
|
|
26
28
|
if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
|
|
27
29
|
const api=async(method,path,params={},idempotency)=>{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
let found=observations.findLast(o=>(o.method??'GET')===method && readKey(o.path,o.params??{})===readKey(path,params));
|
|
31
|
+
if(method==='GET'&&path.split('/').length>=4&&!path.endsWith('/search')) {
|
|
32
|
+
const written=observations.findLast(o=>o.method==='POST'&&o.body?.id===path.split('/').at(-1));
|
|
33
|
+
if(written&&observations.indexOf(written)>observations.indexOf(found)) {
|
|
34
|
+
const lines=written.body.line_items??found?.body?.line_items;
|
|
35
|
+
found={...written,body:{...written.body,...(lines?{line_items:Array.isArray(lines)?{data:lines.map(l=>({...l,price:typeof l.price==='string'?{id:l.price}:l.price}))}:lines}:{})}};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
|
|
31
39
|
const body=found.body;
|
|
32
40
|
if(!body || body.error || body.livemode !== undefined && body.livemode !== (mode==='live') || body.data?.some(o=>o.livemode !== undefined && o.livemode !== (mode==='live')))throw new Error('Stripe read has an error or wrong mode');
|
|
33
41
|
return body;
|
|
34
42
|
};
|
|
35
43
|
const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
|
|
36
44
|
webhook:{method:'POST',path:'/v1/webhook_endpoints',private:true,params:{url:`${site}/api/shop/hook`,enabled_events:SHOP_HOOK_EVENTS,api_version:LINKS_STRIPE_VERSION,metadata:{homie:'shop-v1'},description:'Studio shop'}}};
|
|
45
|
+
let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
|
|
46
|
+
const knownLinks=saved?.result?.site===site&&saved.result.mode===mode?[...(saved.result.config.items??[]),...(saved.result.config.menus??[]),...(saved.result.config.slots??[])].map(l=>l.id):[];
|
|
47
|
+
for(const observation of observations) {
|
|
48
|
+
const body=observation.body;
|
|
49
|
+
if(/^plink_[A-Za-z0-9]+$/.test(body?.id??'')&&body.metadata?.homie==='shop-links-v1'&&body.metadata.studio===slug&&body.metadata.origin===site)knownLinks.push(body.id);
|
|
50
|
+
}
|
|
37
51
|
try {
|
|
38
|
-
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true});
|
|
52
|
+
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,knownLinks,knownPortal:saved?.result?.site===site&&saved.result.mode===mode?saved.result.config.portalId:undefined});
|
|
39
53
|
if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
|
|
40
54
|
const result={...base,ok:true,verified:true,verifiedAt:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
|
|
41
55
|
return result;
|
|
42
56
|
} catch(e) {
|
|
43
|
-
if(!e.next)throw e;
|
|
44
|
-
|
|
57
|
+
if(!e.next&&!e.operations)throw e;
|
|
58
|
+
const operations=e.operations??[e.next];
|
|
59
|
+
return {...base,ok:!verify,verified:false,operations,next:operations[0],instructions:'Perform every independent operation in this batch through Stripe MCP. Append {method,path,params,body} for reads AND writes and replan to resolve dependencies. Keep observations for this setup; never create a webhook through MCP.'};
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
export async function saveMcpVerification(root, options) {
|
|
@@ -52,7 +67,19 @@ export async function saveMcpVerification(root, options) {
|
|
|
52
67
|
saveLocalStripe(root,{STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:'base64:'+Buffer.from(JSON.stringify(result.config)).toString('base64')});
|
|
53
68
|
}
|
|
54
69
|
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
55
|
-
|
|
70
|
+
const slots=slotsOf(root,options.slots);
|
|
71
|
+
if(options.slots!==undefined)writeFileSync(join(root,'.studio','booking-slots.json'),JSON.stringify(slots));
|
|
72
|
+
writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),result:{verified:true,verifiedAt:result.verifiedAt,local:result.local,site:result.site,mode:result.mode,config:result.config}},null,2));
|
|
56
73
|
}
|
|
57
74
|
return result;
|
|
58
75
|
}
|
|
76
|
+
|
|
77
|
+
/** Public verified configuration is tied to the current shop and origin, never to stored API responses. */
|
|
78
|
+
export function verifiedMcpConfig(root) {
|
|
79
|
+
const file=join(root,'.studio','stripe-mcp.json');if(!existsSync(file))return null;
|
|
80
|
+
const saved=JSON.parse(readFileSync(file,'utf8'));
|
|
81
|
+
if(saved.result?.local)return null; // Local builds read .dev.vars, never production bindings.
|
|
82
|
+
const slots=existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):saved.options?.slots??[];
|
|
83
|
+
if(!saved.result?.verified || saved.source!==hash({shop:readShop(root).shop,slots}) || saved.result.site!==new URL(siteUrl(root)).origin)throw new Error('Shop configuration changed; verify through stripe_shop_plan before deploying or installing its webhook.');
|
|
84
|
+
return saved;
|
|
85
|
+
}
|
package/lib/stripe-connect.mjs
CHANGED
|
@@ -90,14 +90,14 @@ export async function shopConnect(root, options = {}) {
|
|
|
90
90
|
let catalog, mcpAccount;
|
|
91
91
|
if (options.webhookOnly) {
|
|
92
92
|
try {
|
|
93
|
-
const {
|
|
94
|
-
const saved=
|
|
93
|
+
const {verifiedMcpConfig} = await import('./shop-mcp.mjs');
|
|
94
|
+
const saved=verifiedMcpConfig(root);
|
|
95
95
|
if(!saved.result?.verifiedAt || Date.now()-Date.parse(saved.result.verifiedAt)>15*60_000) return fail('verify','Run shop verify with fresh Stripe MCP reads before installing the webhook (verification is valid for 15 minutes).');
|
|
96
|
-
const verified=
|
|
96
|
+
const verified=saved.result;
|
|
97
97
|
if(!verified.verified || verified.local || verified.mode!==mode || verified.site!==new URL(siteUrl(root)).origin) return fail('verify','Run shop verify with fresh Stripe MCP reads for this site and mode first.');
|
|
98
98
|
mcpAccount=saved.options.account;
|
|
99
99
|
if(!/^acct_[A-Za-z0-9]+$/.test(mcpAccount??''))return fail('account','Verify with the Stripe MCP account ID first.');
|
|
100
|
-
catalog={config:verified.config,retire:
|
|
100
|
+
catalog={config:verified.config,retire:[]};
|
|
101
101
|
} catch { return fail('verify','Run shop verify with fresh Stripe MCP reads for this site first.'); }
|
|
102
102
|
}
|
|
103
103
|
const id = hash(realpathSync(root)).slice(0, 24);
|
|
@@ -3795,6 +3795,90 @@
|
|
|
3795
3795
|
}
|
|
3796
3796
|
}
|
|
3797
3797
|
}
|
|
3798
|
+
},
|
|
3799
|
+
"0.51.7": {
|
|
3800
|
+
"files": {
|
|
3801
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3802
|
+
"README.md": "684b3cca8001673c",
|
|
3803
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3804
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3805
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3806
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3807
|
+
"posts/README.md": "37841aae476bd540",
|
|
3808
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3809
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3810
|
+
},
|
|
3811
|
+
"sections": {
|
|
3812
|
+
"AGENTS.md": {
|
|
3813
|
+
"(intro)": "394cbbe934e80722",
|
|
3814
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3815
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3816
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3817
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3818
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3819
|
+
"## Making games": "bb715272fd1a615b",
|
|
3820
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3821
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3822
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3823
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3824
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3825
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3826
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3827
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3828
|
+
"## Beta": "9d02638207a26e73"
|
|
3829
|
+
}
|
|
3830
|
+
},
|
|
3831
|
+
"named": {
|
|
3832
|
+
"Homie Arcade": {
|
|
3833
|
+
"sections": {
|
|
3834
|
+
"AGENTS.md": {
|
|
3835
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
},
|
|
3841
|
+
"0.51.8": {
|
|
3842
|
+
"files": {
|
|
3843
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3844
|
+
"README.md": "684b3cca8001673c",
|
|
3845
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3846
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3847
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3848
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3849
|
+
"posts/README.md": "37841aae476bd540",
|
|
3850
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3851
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3852
|
+
},
|
|
3853
|
+
"sections": {
|
|
3854
|
+
"AGENTS.md": {
|
|
3855
|
+
"(intro)": "394cbbe934e80722",
|
|
3856
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3857
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3858
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3859
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3860
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3861
|
+
"## Making games": "bb715272fd1a615b",
|
|
3862
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3863
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3864
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3865
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3866
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3867
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3868
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3869
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3870
|
+
"## Beta": "9d02638207a26e73"
|
|
3871
|
+
}
|
|
3872
|
+
},
|
|
3873
|
+
"named": {
|
|
3874
|
+
"Homie Arcade": {
|
|
3875
|
+
"sections": {
|
|
3876
|
+
"AGENTS.md": {
|
|
3877
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3798
3882
|
}
|
|
3799
3883
|
}
|
|
3800
3884
|
}
|
package/lib/worker-config.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {verifiedMcpConfig} from './shop-mcp.mjs';
|
|
1
2
|
import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
|
|
2
3
|
import {join} from 'node:path';
|
|
3
4
|
import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
|
|
@@ -26,6 +27,8 @@ export function updateWorkerConfig(root,generated) {
|
|
|
26
27
|
writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
|
|
27
28
|
}
|
|
28
29
|
const next=merge(base,custom);
|
|
30
|
+
const verified=verifiedMcpConfig(root);
|
|
31
|
+
if(verified)next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:JSON.stringify(verified.result.config)};
|
|
29
32
|
// Crons are additive: studio-owned entries cannot erase required toolkit ticks.
|
|
30
33
|
const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
|
|
31
34
|
next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
|
package/netplay/netplay.ts
CHANGED
|
@@ -116,6 +116,8 @@ export type Movement = 'owner' | 'host';
|
|
|
116
116
|
|
|
117
117
|
/** What the shell injects as `window.HOMIE_NET` before the game boots. */
|
|
118
118
|
export interface NetConfig {
|
|
119
|
+
/** The served experience kind, independent of standalone packaging. */
|
|
120
|
+
kind?: 'app';
|
|
119
121
|
v: number;
|
|
120
122
|
/** The relay socket: dev `ws://127.0.0.1:<port>/__net?room=main`, site `wss://<host>/api/table/<CODE>/ws?as=net&s=<nonce>`. */
|
|
121
123
|
url: string;
|
|
@@ -1939,6 +1941,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1939
1941
|
}
|
|
1940
1942
|
/** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
|
|
1941
1943
|
function aloneText(): string {
|
|
1944
|
+
if (cfg?.kind === 'app') return 'Reconnecting. Your saved updates will appear shortly.';
|
|
1942
1945
|
if (!canOffline) return 'This game needs a connection.';
|
|
1943
1946
|
if (rulesGame && !rulesHost) return 'Loading offline play…';
|
|
1944
1947
|
if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.8",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/collision.ts
CHANGED
|
@@ -123,6 +123,7 @@ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: H
|
|
|
123
123
|
charge(24);
|
|
124
124
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
125
125
|
let best: Hit3 | null = null;
|
|
126
|
+
const currentBest = (): Hit3 | null => best;
|
|
126
127
|
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
|
|
127
128
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
128
129
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
@@ -141,10 +142,11 @@ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: H
|
|
|
141
142
|
charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
|
|
142
143
|
}
|
|
143
144
|
for (const tile of terrain) {
|
|
144
|
-
|
|
145
|
+
const nearest: Hit3 | null = currentBest();
|
|
146
|
+
if(exactTerrain && !hits && nearest?.t===0)break;
|
|
145
147
|
// Once a nearer surface is known, later solids only need to beat that time.
|
|
146
148
|
// This also bounds work on densely tessellated terrain behind the first hit.
|
|
147
|
-
const limit = exactTerrain && !hits &&
|
|
149
|
+
const limit = exactTerrain && !hits && nearest ? nearest.t : 1;
|
|
148
150
|
const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
|
|
149
151
|
const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
|
|
150
152
|
if (hit) take({...hit, t: hit.t * limit});
|
package/rules/terrain.ts
CHANGED
|
@@ -136,12 +136,13 @@ function roundedCast(source:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
|
136
136
|
|
|
137
137
|
const speed2=dot(d,d);if(speed2<1e-20)return null;
|
|
138
138
|
let best:Hit3|null=null;
|
|
139
|
+
const currentBest=():Hit3|null=>best;
|
|
139
140
|
const take=(t:number,normal:Vec3)=>{if(t< -1e-9||t>1||best&&t>=best.t)return;const len=Math.sqrt(dot(normal,normal));if(len<1e-12||dot(normal,d)>=-1e-12)return;best={t:Math.max(0,t),nx:normal.x/len,ny:normal.y/len,nz:normal.z/len};};
|
|
140
141
|
const sphere=(v:Vec3)=>{const o=sub(p,v),b=dot(o,d),c=dot(o,o)-r*r,h=b*b-speed2*c;if(h<0)return;const t=(-b-Math.sqrt(h))/speed2;take(t,sub(add(p,d,t),v));};
|
|
141
142
|
// Reuse the plane times from clipping. Parallel and receding faces cannot
|
|
142
143
|
// be entry contacts; do not project or retest their already-known times.
|
|
143
144
|
for(const {plane,t} of approaching){
|
|
144
|
-
charge(8);if(
|
|
145
|
+
charge(8);const nearest=currentBest();if(nearest&&t>=nearest.t)continue;charge(16);const hit=add(add(p,d,t),plane.n,-r);let inside=true;
|
|
145
146
|
for(const q of prism.planes){charge(4);if(dot(q.n,hit)>q.c+1e-10){inside=false;break;}}
|
|
146
147
|
if(inside)take(t,plane.n);
|
|
147
148
|
}
|
package/rules/tiles.ts
CHANGED
|
@@ -45,10 +45,14 @@ export function compileTiles(raw: any, wrap?: MapWrap): {
|
|
|
45
45
|
return { tiles, boxes };
|
|
46
46
|
}
|
|
47
47
|
export function tileApi(get: () => GameMap) {
|
|
48
|
-
const cell = (x: unknown, y: unknown) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const cell = (x: unknown, y: unknown) => {
|
|
49
|
+
const t = get().tiles;
|
|
50
|
+
if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y)) return -1;
|
|
51
|
+
const w = get().wrap;
|
|
52
|
+
const cx = w?.x ? ((x % t.width) + t.width) % t.width : x;
|
|
53
|
+
const cy = w?.y ? ((y % t.height) + t.height) % t.height : y;
|
|
54
|
+
return cx < 0 || cy < 0 || cx >= t.width || cy >= t.height ? -1 : cy * t.width + cx;
|
|
55
|
+
};
|
|
52
56
|
return {
|
|
53
57
|
tile: (x: unknown, y: unknown) => { charge(4); const id = cell(x, y); return id < 0 ? 1 : get().tiles!.data[id]; },
|
|
54
58
|
distance: (name: unknown, x: unknown, y: unknown) => { charge(4); const id = cell(x, y), f = typeof name === 'string' && get().tiles ? fields.get(get().tiles!)?.get(name) : null; return id < 0 || !f ? -1 : f.distance[id]; },
|