@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/apps/APPS.md +26 -0
  3. package/bin/homie-studio.mjs +10 -5
  4. package/docs/toolkit-place-patterns.md +62 -0
  5. package/functions/FUNCTIONS.md +2 -2
  6. package/guides/app/SKILL.md +34 -0
  7. package/guides/app/examples/reserve.mjs +13 -0
  8. package/guides/functions/SKILL.md +2 -0
  9. package/guides/office/SKILL.md +27 -1
  10. package/guides/servers/SKILL.md +27 -1
  11. package/guides/shop/SKILL.md +38 -15
  12. package/guides/studio-setup/SKILL.md +4 -0
  13. package/lib/app-needs.mjs +8 -2
  14. package/lib/app-ready.mjs +31 -0
  15. package/lib/check.mjs +9 -6
  16. package/lib/cloudflare.mjs +4 -4
  17. package/lib/connector-token.mjs +30 -0
  18. package/lib/mcp-tools.mjs +1 -1
  19. package/lib/shop-links.mjs +70 -32
  20. package/lib/shop-mcp.mjs +37 -10
  21. package/lib/stripe-connect.mjs +4 -4
  22. package/lib/template-history.json +84 -0
  23. package/lib/worker-config.mjs +3 -0
  24. package/netplay/netplay.ts +3 -0
  25. package/package.json +1 -1
  26. package/rules/collision.ts +4 -2
  27. package/rules/terrain.ts +2 -1
  28. package/rules/tiles.ts +8 -4
  29. package/shop/SHOP.md +38 -15
  30. package/worker/app-graphics.mjs +8 -0
  31. package/worker/app-shell.mjs +36 -15
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/connector-token.mjs +11 -0
  34. package/worker/index.mjs +5 -2
  35. package/worker/join-area.mjs +35 -0
  36. package/worker/mcp-tools.mjs +12 -3
  37. package/worker/mcp.mjs +14 -6
  38. package/worker/pages.mjs +6 -3
  39. package/worker/records-page.mjs +3 -2
  40. package/worker/records-prefs.mjs +24 -0
  41. package/worker/shop.mjs +2 -2
  42. package/worker/version.mjs +1 -1
@@ -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 claimed itself in the directory');
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 claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
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 the exact next read/write with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, clear observations after writes, repeat until verified. verify:true saves the public config for stripe_webhook. Never pass webhook creation responses or secrets.',
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=>{
@@ -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 products = await list('/v1/products');
24
- const prices = await list('/v1/prices');
25
- const links = await list('/v1/payment_links');
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
- for (const item of shop.items) {
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) continue; // Free orders never leave the studio.
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 items = group.slice(offset, offset + 10);
83
- if (items.length < 2) continue;
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)continue;
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))continue;
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
- // Retired booking links must be closed before publishing a configuration which
164
- // no longer names them; staff can then move into that local-only slot safely.
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
- /** Pure, incremental Stripe plan. The owner's AI supplies public API reads; no Stripe transport here. */
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) => JSON.stringify([path,Object.fromEntries(Object.entries(params).sort(([a],[b])=>a.localeCompare(b)))]);
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=slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
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
- if(method!=='GET')throw {next:{method,path,params,...(idempotency?{idempotency}:{}),retry:'Read fresh before retrying; updates set desired fields and are idempotent.'}};
29
- const found=observations.find(o=>readKey(o.path,o.params??{})===readKey(path,params));
30
- if(!found)throw {next:{method,path,params}};
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
- return {...base,ok:!verify,verified:false,next:e.next,instructions:e.next.method==='GET'?'Read through Stripe MCP; append {path,params,body} to observations, including every pagination page.':'Perform this exact operation through Stripe MCP. Then discard observations and read fresh before planning again. Do not create the webhook through MCP.'};
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
- writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options,result},null,2));
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
+ }
@@ -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 {saveMcpVerification} = await import('./shop-mcp.mjs');
94
- const saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));
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=await saveMcpVerification(root,saved.options);
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:verified.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
  }
@@ -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.
@@ -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.6",
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",
@@ -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
- if(exactTerrain && !hits && best?.t===0)break;
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 && best ? best.t : 1;
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(best&&t>=best.t)continue;charge(16);const hit=add(add(p,d,t),plane.n,-r);let inside=true;
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) => { const t = get().tiles; if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y))
49
- return -1; const w = get().wrap; if (w?.x)
50
- x = ((x % t.width) + t.width) % t.width; if (w?.y)
51
- y = ((y % t.height) + t.height) % t.height; return x < 0 || y < 0 || x >= t.width || y >= t.height ? -1 : y * t.width + x; };
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]; },