@homie-rocks/studio 0.50.7 → 0.51.1

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.
@@ -0,0 +1,37 @@
1
+ /** Offline Checkout fixture for local acceptance. No Stripe account, cards, or
2
+ * real credentials. This is a simulator, not evidence of Stripe sandbox access. */
3
+ import { createServer } from 'node:http';
4
+ import { signPayload } from '../worker/stripe.mjs';
5
+ export async function startStripeFixture({ origin }) {
6
+ const target=new URL(origin);
7
+ if (target.protocol!=='http:' || !['localhost','127.0.0.1'].includes(target.hostname)) throw new Error('Fixture requires a loopback studio');
8
+ const sessions=new Map(), idempotency=new Map(); let seq=0, base;
9
+ const key='rk_test_homieLocalFixture', secret='whsec_homieLocalFixture';
10
+ const server=createServer(async(req,res)=>{
11
+ const send=(data,status=200)=>{res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(data));};
12
+ try {
13
+ const url=new URL(req.url,base);let body='';for await(const chunk of req){body+=chunk;if(body.length>262144)return send({error:{message:'fixture body limit'}},413);}
14
+ const form=new URLSearchParams(body);
15
+ if(url.pathname==='/v1/checkout/sessions'&&req.method==='GET') return send({data:[...sessions.values()].filter(s=>s.created>=Number(url.searchParams.get('created[gte]')??0)),has_more:false});
16
+ if(url.pathname==='/v1/checkout/sessions'&&req.method==='POST') {
17
+ const idem=req.headers['idempotency-key'];if(idem&&idempotency.has(idem))return send(sessions.get(idempotency.get(idem)));
18
+ const id=`cs_fixture_${++seq}`,lines=[];
19
+ for(let i=0;form.has(`line_items[${i}][quantity]`);i++) {const p=`line_items[${i}]`;lines.push({quantity:Number(form.get(`${p}[quantity]`)),amount_subtotal:Number(form.get(`${p}[quantity]`))*Number(form.get(`${p}[price_data][unit_amount]`)),name:form.get(`${p}[price_data][product_data][name]`)});}
20
+ const s={id,object:'checkout.session',livemode:false,mode:form.get('mode'),status:'open',payment_status:'unpaid',created:Math.floor(Date.now()/1000),expires_at:Number(form.get('expires_at')),currency:form.get('line_items[0][price_data][currency]'),amount_subtotal:lines.reduce((n,l)=>n+l.amount_subtotal,0),metadata:{homie:form.get('metadata[homie]'),order:form.get('metadata[order]')},client_reference_id:form.get('client_reference_id'),url:`${base}/checkout/${id}`,success_url:form.get('success_url'),cancel_url:form.get('cancel_url'),lines};
21
+ s.amount_total=s.amount_subtotal;sessions.set(id,s);if(idem)idempotency.set(idem,id);return send(s);
22
+ }
23
+ const api=/^\/v1\/checkout\/sessions\/(cs_fixture_\d+)(\/expire)?$/.exec(url.pathname);
24
+ if(api){const s=sessions.get(api[1]);if(!s)return send({error:{code:'resource_missing'}},404);if(api[2]){if(s.status!=='open')return send({error:{message:'Session is not open'}},400);s.status='expired';await hook(s,'checkout.session.expired');}return send(s);}
25
+ const page=/^\/checkout\/(cs_fixture_\d+)$/.exec(url.pathname);
26
+ if(page){const s=sessions.get(page[1]);if(!s)return send({},404);res.writeHead(200,{'content-type':'text/html'});return res.end(`<!doctype html><meta name="viewport" content="width=device-width"><title>Offline Stripe fixture</title><style>body{font:20px system-ui;max-width:32em;margin:3em auto;padding:1em}button{padding:1em;font:inherit}</style><h1>Offline Stripe test fixture</h1><p>This is a simulation. No card or money is used.</p><p>${s.amount_total/100} ${s.currency.toUpperCase()}</p><form method="post" action="/complete/${s.id}"><button>Simulate successful test payment</button></form><form method="post" action="/expire/${s.id}"><button>Simulate expiry</button></form>`);}
27
+ const finish=/^\/(complete|expire)\/(cs_fixture_\d+)$/.exec(url.pathname);
28
+ if(finish&&req.method==='POST'){const s=sessions.get(finish[2]);if(!s||s.status!=='open')return send({error:'not open'},409);const paid=finish[1]==='complete';s.status=paid?'complete':'expired';s.payment_status=paid?'paid':'unpaid';if(paid){s.payment_intent={id:`pi_fixture${seq}`,status:'succeeded',latest_charge:{id:`ch_fixture${seq}`,created:Math.floor(Date.now()/1000),refunded:false,disputed:false}};if(s.mode==='subscription'){s.subscription=`sub_fixture${seq}`;s.customer=`cus_fixture${seq}`;}}await hook(s,paid?'checkout.session.completed':'checkout.session.expired');res.writeHead(303,{location:(paid?s.success_url:s.cancel_url).replace('{CHECKOUT_SESSION_ID}',s.id)});return res.end();}
29
+ if(url.pathname.startsWith('/v1/payment_intents/')) { const payment=[...sessions.values()].map(s=>s.payment_intent).find(p=>p?.id===url.pathname.split('/').at(-1));return payment?send(payment):send({error:{code:'resource_missing'}},404); }
30
+ if(url.pathname==='/v1/refunds')return send({data:[],has_more:false});
31
+ return send({error:{message:'Unsupported offline fixture operation'}},404);
32
+ }catch{send({error:{message:'Offline fixture failed'}},500);}
33
+ });
34
+ async function hook(session,type){const event={id:`evt_fixture_${session.id}_${type}`,type,livemode:false,created:Math.floor(Date.now()/1000),data:{object:session}};const payload=JSON.stringify(event),t=event.created;const response=await fetch(`${target.origin}/api/shop/hook`,{method:'POST',headers:{'stripe-signature':`t=${t},v1=${await signPayload(payload,secret,t)}`},body:payload});if(!response.ok)throw new Error('Studio rejected fixture webhook');}
35
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));base=`http://127.0.0.1:${server.address().port}`;
36
+ return {base,bindings:{STRIPE_CHECKOUT_MODE: 'sessions', STRIPE_KEY:key,STRIPE_WEBHOOK_SECRET:secret,STRIPE_API_BASE:base},close:()=>new Promise(resolve=>server.close(resolve))};
37
+ }
@@ -0,0 +1,80 @@
1
+ /** Local acceptance adapter. Stripe's CLI retains its credentials and forwards
2
+ * genuine sandbox events. Never deploy this adapter: the desktop is test-only. */
3
+ import { createServer } from 'node:http';
4
+ import { spawn } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { redactStripe } from '../worker/stripe.mjs';
7
+ import { stripeProcess } from './stripe-connect.mjs';
8
+ export async function startStripeSandbox({ origin, profile }) {
9
+ const target = new URL(origin);
10
+ if (target.protocol !== 'http:' || !['127.0.0.1','localhost'].includes(target.hostname)) throw new Error('Sandbox acceptance requires loopback');
11
+ if (!profile || typeof profile !== 'string') throw new Error('An explicit Stripe sandbox profile is required');
12
+ const call = async (method,path,params=new URLSearchParams(),idempotency) => {
13
+ const args=['--project-name',profile,method.toLowerCase(),path,'--stripe-version','2025-03-31.basil'];
14
+ if(method.toUpperCase()==='DELETE')args.push('--confirm');
15
+ for(const [k,v] of params)args.push('-d',`${k}=${v}`);
16
+ if(idempotency)args.push('--idempotency',idempotency);
17
+ const r=await stripeProcess(args);let body;try{body=JSON.parse(r.stdout);}catch{}
18
+ if(r.code!==0 || body?.error || !body) throw new Error('Stripe sandbox request failed: '+redactStripe(body?.error?.message ?? r.stderr ?? 'no response').slice(0,600));
19
+ return body;
20
+ };
21
+ // A mode-bearing object verifies test context without reading any credential.
22
+ const probe=await call('GET','/v1/prices',new URLSearchParams({limit:'1'}));
23
+ if(probe.data?.some(p=>p.livemode))throw new Error('Sandbox adapter refuses live data');
24
+ const key=`rk_test_${randomBytes(24).toString('hex')}`;
25
+ const server=createServer(async(req,res)=>{
26
+ const send=(value,status=200)=>{res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(value));};
27
+ if(req.headers.authorization!==`Bearer ${key}`)return send({error:{message:'Unauthorized sandbox adapter'}},401);
28
+ try{
29
+ const url=new URL(req.url,'http://127.0.0.1');
30
+ if(!url.pathname.startsWith('/v1/')||!['GET','POST','DELETE'].includes(req.method))return send({},404);
31
+ let body='';for await(const chunk of req){body+=chunk;if(body.length>262144)return send({},413);}
32
+ const data=await call(req.method,url.pathname,req.method==='GET'?url.searchParams:new URLSearchParams(body),req.headers['idempotency-key']);
33
+ if(data.livemode===true)throw new Error('Live object refused');
34
+ send(data);
35
+ }catch{send({error:{message:'Stripe sandbox adapter request failed'}},502);}
36
+ });
37
+ await new Promise(r=>server.listen(0,'127.0.0.1',r));
38
+ const childEnv={...process.env};for(const k of Object.keys(childEnv))if(k.startsWith('STRIPE_'))delete childEnv[k];
39
+ const listener=spawn('stripe',['--project-name',profile,'listen','--events','payment_link.updated,checkout.session.completed,checkout.session.expired,invoice.paid,invoice.payment_failed,customer.subscription.updated,customer.subscription.deleted,charge.refunded,refund.created,refund.updated','--forward-to',`${target.origin}/api/shop/hook`],{env:childEnv,stdio:['ignore','pipe','pipe']});
40
+ const close=async()=>{listener.kill();await new Promise(r=>server.close(r));};
41
+ try {
42
+ const secret=await new Promise((resolve,reject)=>{
43
+ let buffer='';const timer=setTimeout(()=>reject(new Error('Stripe listener did not become ready')),30000);
44
+ const collect=b=>{buffer=(buffer+b).slice(-16000);const match=/whsec_[A-Za-z0-9]+/.exec(buffer);if(match){clearTimeout(timer);resolve(match[0]);}};
45
+ listener.stdout.on('data',collect);listener.stderr.on('data',collect);
46
+ listener.on('error',()=>{clearTimeout(timer);reject(new Error('Stripe listener failed'));});
47
+ listener.on('exit',()=>{clearTimeout(timer);reject(new Error('Stripe listener exited: '+redactStripe(buffer).slice(-1200)));});
48
+ });
49
+ return {bindings:{STRIPE_CHECKOUT_MODE: 'sessions', STRIPE_KEY:key,STRIPE_WEBHOOK_SECRET:secret,STRIPE_API_BASE:`http://127.0.0.1:${server.address().port}`},call,close};
50
+ }catch(e){await close();throw e;}
51
+ }
52
+
53
+ /** Local keyless acceptance: only links and the listener's signing secret reach
54
+ * the Worker. Object sync uses the approved CLI directly, never an API adapter. */
55
+ export async function startKeylessStripeSandbox({origin,profile,shop,bookingSlots=[],name='keyless-dev'}) {
56
+ const {syncPaymentLinks}=await import('./shop-links.mjs');
57
+ const {checkShop}=await import('../worker/shop-rules.mjs');
58
+ const {formEncode}=await import('../worker/stripe.mjs');
59
+ const checked=checkShop(shop);
60
+ if(!checked.ok)throw new Error('Check shop.json before sandbox setup');
61
+ const sandbox=await startStripeSandbox({origin,profile});
62
+ let links,endpoint;
63
+ const api=(method,path,params={},idempotency)=>sandbox.call(method,path,formEncode(params),idempotency);
64
+ const synchronize=async bookingSlots=>{
65
+ const result=await syncPaymentLinks({api,shop:checked.shop,slug:name,site:`https://${name}.example.com`,mode:'test',bookingSlots,
66
+ w:async(args,opts)=>{if(args[1]==='list')return{code:0,stdout:'[]'};if(args[1]==='bulk')links=JSON.parse(opts.input).STRIPE_SHOP_LINKS;return{code:0};},saveReceipt:r=>{endpoint=r.endpoint??endpoint;}});
67
+ if(!result.ok)throw Error('Sandbox Payment Link sync failed');
68
+ // The placeholder endpoint points at an unreachable example address; signed
69
+ // events reach the loopback studio through `stripe listen`. Remove it so
70
+ // repeated syncs never accumulate toward Stripe's per-account endpoint limit.
71
+ if(endpoint){await api('DELETE',`/v1/webhook_endpoints/${endpoint}`);endpoint=undefined;}
72
+ return {links:JSON.parse(links),bindings:{STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:'base64:'+Buffer.from(links).toString('base64'),STRIPE_WEBHOOK_SECRET:sandbox.bindings.STRIPE_WEBHOOK_SECRET}};
73
+ };
74
+ try {
75
+ const client={...await synchronize(bookingSlots),call:api,
76
+ sync:async slots=>{Object.assign(client,await synchronize(slots));return client;},
77
+ close:async()=>{try{const config=JSON.parse(links);for(const link of [...config.items,...config.menus,...config.slots])await api('POST',`/v1/payment_links/${link.id}`,{active:false});}finally{await sandbox.close();}}};
78
+ return client;
79
+ }catch(e){await sandbox.close();throw e;}
80
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -20,7 +20,7 @@ import { providerKey } from './provider-key.mjs';
20
20
  import { prepareNode } from './prepare.mjs';
21
21
  import { studioDomain } from './domain.mjs';
22
22
  import { spawnSync } from 'node:child_process';
23
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
23
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
24
  import { homedir } from 'node:os';
25
25
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
26
26
  import { renderCodex, newCodex } from './codex.mjs';
@@ -1177,6 +1177,20 @@ export function toolDefs(ctx, avail = {}) {
1177
1177
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1178
1178
  },
1179
1179
  },
1180
+ {
1181
+ name:'stripe_bookings_sync', title:'Sync published booking slots to Stripe',
1182
+ description:'Owner AI: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
1183
+ inputSchema:{type:'object',properties:{...STUDIO_ARG,slots:{type:'array',items:{type:'object',properties:{app:{type:'string'},collection:{type:'string'},slots:{type:'string'},slot:{type:'string'},item:{type:'string'},capacity:{type:'integer',minimum:0},confirmed:{type:'integer',minimum:0},active:{type:'boolean'}},required:['app','collection','slots','slot','item','capacity'],additionalProperties:false}},live:{type:'boolean'}},required:['slots']},
1184
+ annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
1185
+ run:async a=>{
1186
+ const root=ctx.root(a.studio);
1187
+ mkdirSync(join(root,'.studio'),{recursive:true});
1188
+ writeFileSync(join(root,'.studio','booking-slots.json'),JSON.stringify(a.slots,null,2));
1189
+ const job=cliJob(root,'Sync Stripe booking links',['shop','connect',...(a.live?['--live']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
1190
+ await waitJob(job,Math.min(ctx.waitMs,1000));
1191
+ return ok('Booking link sync started. Check studio_job for completion.',{kind:'job',...jobView(job)});
1192
+ },
1193
+ },
1180
1194
  {
1181
1195
  name: 'stripe_login', title: 'Connect the studio to Stripe',
1182
1196
  description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. An existing key supports fuller checkout; manual only if explicitly chosen for those features. live only when the owner says go live.',
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
2
+ import { SUBSCRIPTIONS_MIGRATION, SUBSCRIPTIONS_MIGRATION_FILE } from '../worker/shop-subscriptions.mjs';
3
+ import { BOOKINGS_MIGRATION, BOOKINGS_MIGRATION_FILE } from '../worker/booking-store.mjs';
2
4
  import { FUNCTIONS_RUNS_MIGRATION, FUNCTIONS_RUNS_MIGRATION_FILE, FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
3
5
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
4
6
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
@@ -822,6 +824,8 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
822
824
  [`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
823
825
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
824
826
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
827
+ [`site/migrations/${BOOKINGS_MIGRATION_FILE}`]: BOOKINGS_MIGRATION,
828
+ [`site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`]: SUBSCRIPTIONS_MIGRATION,
825
829
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
826
830
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
827
831
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
@@ -999,6 +1003,16 @@ function ensurePartsStateMigration(root) {
999
1003
  return `site/migrations/${PURCHASE_STATE_FILE}`;
1000
1004
  }
1001
1005
 
1006
+ function ensureSubscriptions(root) {
1007
+ const file=join(root,'site','migrations',SUBSCRIPTIONS_MIGRATION_FILE);
1008
+ if(existsSync(file))return null;
1009
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,SUBSCRIPTIONS_MIGRATION);return `site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`;
1010
+ }
1011
+ function ensureBookings(root) {
1012
+ const file=join(root,'site','migrations',BOOKINGS_MIGRATION_FILE);
1013
+ if(existsSync(file))return null;
1014
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKINGS_MIGRATION);return `site/migrations/${BOOKINGS_MIGRATION_FILE}`;
1015
+ }
1002
1016
  function ensureFunctionRuns(root) {
1003
1017
  const file=join(root,'site','migrations',FUNCTIONS_RUNS_MIGRATION_FILE);
1004
1018
  if(existsSync(file))return null;
@@ -1035,7 +1049,7 @@ function ensureToolDatabase(root) {
1035
1049
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
1036
1050
  }
1037
1051
  export function ensureMigrations(root) {
1038
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root)].filter(Boolean);
1052
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root)].filter(Boolean);
1039
1053
  }
1040
1054
 
1041
1055
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -6,7 +6,7 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
6
6
  import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
7
7
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
- export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt }) {
9
+ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [] }) {
10
10
  const list = async (path, params = {}) => {
11
11
  const all = []; let cursor;
12
12
  do {
@@ -28,15 +28,17 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
28
28
  const links = await list('/v1/payment_links');
29
29
  const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
30
30
  const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
31
+ const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
32
+ const catalogPrices = new Map();
31
33
  const keepPrices = new Set(), keepLinks = new Set(), keepProducts = new Set();
32
34
  for (const item of shop.items) {
33
35
  const id = await productIdOf(slug, item.id);
34
36
  keepProducts.add(id);
35
37
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
36
- const fields = { name: item.name, description: item.blurb ?? '', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
38
+ const fields = { name: item.name, ...(item.blurb ? {description:item.blurb} : {}), tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
37
39
  let product = products.find((p) => p.id === id);
38
40
  if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
39
- 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);
41
+ 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);
40
42
  // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
43
  // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
42
44
  const priceFields = { product: id, currency: shop.currency, ...(item.interval ? { recurring: { interval: item.interval } } : {}), tax_behavior: 'exclusive', ...(item.price === 'choose'
@@ -47,9 +49,10 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
47
49
  if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
48
50
  if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
49
51
  keepPrices.add(price.id);
52
+ catalogPrices.set(item.id, price.id);
50
53
  if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
51
54
  if (item.price === 0) continue; // Free orders never leave the studio.
52
- const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
55
+ const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
53
56
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
54
57
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
55
58
  ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
@@ -60,7 +63,76 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
60
63
  if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
61
64
  if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
62
65
  keepLinks.add(link.id);
63
- config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
66
+ config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, revision: linkRevision });
67
+ }
68
+ // Menus use paid invoice snapshots: Checkout events omit their line items.
69
+ // Stripe allows 20 Payment Link lines; invoice snapshots include only the first
70
+ // 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
71
+ config.menus = [];
72
+ const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
73
+ const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
74
+ const groups=Map.groupBy(menuItems,i=>i.game??'');
75
+ for(const group of groups.values()) for (let offset = 0; offset < group.length; offset += 10) {
76
+ const items = group.slice(offset, offset + 10);
77
+ if (items.length < 2) continue;
78
+ 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}})),
79
+ invoice_creation:{enabled:true,invoice_data:{metadata}},
80
+ after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}},
81
+ automatic_tax:{enabled:shop.automaticTax}, payment_intent_data:{metadata} };
82
+ const revision=hash(params);
83
+ let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
84
+ if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
85
+ if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
86
+ keepLinks.add(link.id);
87
+ config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
88
+ }
89
+ config.slots=[];
90
+ const pause=async link=>{
91
+ await api('POST',`/v1/payment_links/${link.id}`,{active:false});
92
+ for(const session of await list('/v1/checkout/sessions',{payment_link:link.id,status:'open'})) {
93
+ await api('POST',`/v1/checkout/sessions/${session.id}/expire`);
94
+ }
95
+ };
96
+ const slotKeys=new Set();
97
+ for(const slot of bookingSlots) {
98
+ if(!['app','collection','slots','slot','item'].every(k=>typeof slot[k]==='string'&&/^[a-zA-Z0-9_-]{1,64}$/.test(slot[k])) || !Number.isSafeInteger(slot.capacity) || slot.capacity<0 || slot.confirmed!==undefined && (!Number.isSafeInteger(slot.confirmed)||slot.confirmed<0||slot.confirmed>slot.capacity))throw new Error('Invalid booking slot manifest');
99
+ const resource=JSON.stringify([slot.app,slot.slots,slot.slot]);
100
+ if(slotKeys.has(resource))throw new Error('Publish only one service Payment Link per capacity slot');
101
+ slotKeys.add(resource);
102
+ const item=shop.items.find(i=>i.id===slot.item);
103
+ if(!item || item.interval || item.price==='choose')throw new Error('Bookings require a fixed one-time service price');
104
+ if(!item.price)continue;
105
+ const identity=hash({slug,site,app:slot.app,collection:slot.collection,slots:slot.slots,slot:slot.slot});
106
+ let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
107
+ if(!link && (!slot.capacity||slot.active===false))continue;
108
+ 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.');
109
+ if(slot.confirmed!==undefined && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
110
+ const count=link?.restrictions?.completed_sessions?.count??0;
111
+ // Initial publication uses capacity exactly. After a reviewed move/refund the
112
+ // AI supplies current confirmed seats; keep the existing Stripe lifetime count.
113
+ const limit=slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
114
+ const active=slot.active!==false && limit>count;
115
+ const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
116
+ restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
117
+ if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
118
+ else {
119
+ // Pause first whenever publishing a changed budget, so old sessions cannot
120
+ // spend a previous capacity after staff moves or removes a slot.
121
+ if(!active || link.restrictions?.completed_sessions?.limit!==limit)await pause(link);
122
+ link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active});
123
+ }
124
+ keepLinks.add(link.id);
125
+ 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});
126
+ }
127
+ // Retired booking links must be closed before publishing a configuration which
128
+ // no longer names them; staff can then move into that local-only slot safely.
129
+ for(const link of links.filter(p=>own(p)&&p.metadata?.slot&&!keepLinks.has(p.id)))await pause(link);
130
+ if(shop.items.some(i=>i.interval)) {
131
+ const portals=await list('/v1/billing_portal/configurations');
132
+ let portal=portals.find(p=>own(p));
133
+ 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};
134
+ portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
135
+ config.portal=portal.login_page?.url;
64
136
  }
65
137
  const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
66
138
  const purchaseSecrets = {};
@@ -69,7 +141,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
69
141
  const pair = await generateKeyPair('Ed25519', { extractable: true });
70
142
  purchaseSecrets.PURCHASE_SIGNING_KEYS = JSON.stringify([await exportJWK(pair.privateKey)]);
71
143
  }
72
- const events = purchases?.config.offers.length ? HOOK_EVENTS : SHOP_HOOK_EVENTS;
144
+ const events = purchases?.config.offers.length ? [...new Set([...HOOK_EVENTS,...SHOP_HOOK_EVENTS])] : SHOP_HOOK_EVENTS;
73
145
  const endpoints = await list('/v1/webhook_endpoints');
74
146
  const url = `${site}/api/shop/hook`;
75
147
  const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && events.every((event) => e.enabled_events?.includes(event)));
@@ -84,7 +156,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
84
156
  const fingerprint = hash({config, purchases:purchases?.config});
85
157
  const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
86
158
  if (!unchanged) {
87
- const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
159
+ const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
88
160
  if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
89
161
  // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
90
162
  if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
package/lib/shop.mjs CHANGED
@@ -63,6 +63,7 @@ export const KEY_PERMISSIONS = Object.freeze([
63
63
  ]);
64
64
 
65
65
  export const SHOP_HOOK_EVENTS = Object.freeze([
66
+ 'payment_link.updated', 'invoice.paid', 'invoice.payment_failed', 'customer.subscription.updated', 'customer.subscription.deleted',
66
67
  'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
67
68
  'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
68
69
  ]);
@@ -114,7 +115,7 @@ export function shopInit(root, { supporter = false, giving = false, currency = n
114
115
  const shop = {
115
116
  ...(giving ? { giving: { receiptText: '' } } : {}),
116
117
  till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
117
- items: giving ? [1000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
118
+ items: giving ? [1000, 2000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
118
119
  };
119
120
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
120
121
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
@@ -323,7 +324,8 @@ export async function shopConnectManual(root, { fromClipboard = false, clipboard
323
324
  const machineNote = ` Machine payments: ${Object.entries(machine.machine).map(([name, enabled]) => `${name}: ${enabled ? 'permission check passed' : machine.reasons[name] ?? 'unavailable'}`).join('; ')}.`;
324
325
  finished = true;
325
326
  const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
326
- const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
327
+ const selected = a.code === 0 ? w(['secret','put','STRIPE_CHECKOUT_MODE'],{input:'sessions\n'}) : {code:1};
328
+ const b = selected.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
327
329
  hook = '';
328
330
  const readiness = { ...machine, key: createHash('sha256').update(key).digest('hex'), mode: modeOf(key), recurring: recurringReady };
329
331
  const c = !paidParts ? b : b.code === 0 ? w(['secret', 'put', 'PURCHASE_PAYMENT_CAPABILITIES'], { input: JSON.stringify(readiness) }) : { code: 1 };
@@ -134,6 +134,7 @@ export async function shopConnect(root, options = {}) {
134
134
  const receiptFile = join(dir, `${mode}.json`);
135
135
  const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
136
136
  const result = await syncPaymentLinks({ root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
137
+ bookingSlots: options.bookingSlots ?? (existsSync(join(root,'.studio','booking-slots.json')) ? JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')) : []),
137
138
  saveReceipt: (record) => {
138
139
  writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
139
140
  renameSync(`${receiptFile}.tmp`, receiptFile);
@@ -3492,6 +3492,90 @@
3492
3492
  }
3493
3493
  }
3494
3494
  }
3495
+ },
3496
+ "0.51.0": {
3497
+ "files": {
3498
+ "CLAUDE.md": "ddf2b2a0376202f6",
3499
+ "README.md": "684b3cca8001673c",
3500
+ "HANDOFF.md": "29337e2ace95b2e7",
3501
+ "games/README.md": "5ad32fe80c61f117",
3502
+ "music/README.md": "a3dc042dadd39df8",
3503
+ "videos/README.md": "16babc3437d5545b",
3504
+ "posts/README.md": "37841aae476bd540",
3505
+ "site/README.md": "1dbdf22c03a1e505",
3506
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3507
+ },
3508
+ "sections": {
3509
+ "AGENTS.md": {
3510
+ "(intro)": "394cbbe934e80722",
3511
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3512
+ "## Layout": "f3a280b8c96cd068",
3513
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3514
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3515
+ "## The site": "0cb8dd9a6f8a0096",
3516
+ "## Making games": "bb715272fd1a615b",
3517
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3518
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3519
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3520
+ "## Servers and AI seats": "1ccfd549d2369a60",
3521
+ "## Selling things (the shop)": "43a3556be93d1235",
3522
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3523
+ "## Rules": "b2234d80f6cbe71d",
3524
+ "## Telling Homie": "80b46fdd9e578db8",
3525
+ "## Beta": "9d02638207a26e73"
3526
+ }
3527
+ },
3528
+ "named": {
3529
+ "Homie Arcade": {
3530
+ "sections": {
3531
+ "AGENTS.md": {
3532
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3533
+ }
3534
+ }
3535
+ }
3536
+ }
3537
+ },
3538
+ "0.51.1": {
3539
+ "files": {
3540
+ "CLAUDE.md": "ddf2b2a0376202f6",
3541
+ "README.md": "684b3cca8001673c",
3542
+ "HANDOFF.md": "29337e2ace95b2e7",
3543
+ "games/README.md": "5ad32fe80c61f117",
3544
+ "music/README.md": "a3dc042dadd39df8",
3545
+ "videos/README.md": "16babc3437d5545b",
3546
+ "posts/README.md": "37841aae476bd540",
3547
+ "site/README.md": "1dbdf22c03a1e505",
3548
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3549
+ },
3550
+ "sections": {
3551
+ "AGENTS.md": {
3552
+ "(intro)": "394cbbe934e80722",
3553
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3554
+ "## Layout": "f3a280b8c96cd068",
3555
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3556
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3557
+ "## The site": "0cb8dd9a6f8a0096",
3558
+ "## Making games": "bb715272fd1a615b",
3559
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3560
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3561
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3562
+ "## Servers and AI seats": "1ccfd549d2369a60",
3563
+ "## Selling things (the shop)": "43a3556be93d1235",
3564
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3565
+ "## Rules": "b2234d80f6cbe71d",
3566
+ "## Telling Homie": "80b46fdd9e578db8",
3567
+ "## Beta": "9d02638207a26e73"
3568
+ }
3569
+ },
3570
+ "named": {
3571
+ "Homie Arcade": {
3572
+ "sections": {
3573
+ "AGENTS.md": {
3574
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3575
+ }
3576
+ }
3577
+ }
3578
+ }
3495
3579
  }
3496
3580
  }
3497
3581
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.50.7",
3
+ "version": "0.51.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,35 @@
1
+ import type { Vec3 } from './math.ts';
2
+
3
+ /** A bounded, incremental presentation route. Never participates in simulation.
4
+ * Each edge is accepted by the same body sweep used to draw the runner. Search
5
+ * is spread over frames so an unusual correction cannot monopolize a phone. */
6
+ export function correctionPath(start: Vec3, goal: Vec3, spacing: number) {
7
+ type Node = { x: number; y: number; pos: Vec3; cost: number; estimate: number; parent?: Node };
8
+ const distance = (a: Vec3, b: Vec3) => Math.hypot(a.x-b.x,a.y-b.y);
9
+ const first: Node = {x:0,y:0,pos:start,cost:0,estimate:distance(start,goal)};
10
+ const open = [first], costs = new Map<string,number>([['0,0',0]]);
11
+ let route: Vec3[] | null = null, exhausted = false;
12
+ return {
13
+ goal,
14
+ advance(clear: (from: Vec3,to: Vec3)=>boolean): readonly Vec3[] | null {
15
+ if (route || exhausted) return route;
16
+ for (let budget=0;budget<24 && open.length;budget++) {
17
+ let best=0;for(let i=1;i<open.length;i++)if(open[i].estimate<open[best].estimate)best=i;
18
+ const node=open.splice(best,1)[0];
19
+ if(clear(node.pos,goal)) {
20
+ route=[goal];let n:Node|undefined=node;while(n?.parent){route.unshift(n.pos);n=n.parent;}return route;
21
+ }
22
+ for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]) {
23
+ const x=node.x+dx,y=node.y+dy,key=`${x},${y}`,cost=node.cost+Math.hypot(dx,dy)*spacing;
24
+ if(cost>=(costs.get(key)??Infinity))continue;
25
+ const pos={x:start.x+x*spacing,y:start.y+y*spacing,z:0};
26
+ if(!clear(node.pos,pos))continue;
27
+ costs.set(key,cost);open.push({x,y,pos,cost,estimate:cost+distance(pos,goal),parent:node});
28
+ }
29
+ if(costs.size>4096){exhausted=true;return null;}
30
+ }
31
+ if(!open.length)exhausted=true;
32
+ return null;
33
+ },
34
+ };
35
+ }
@@ -61,15 +61,15 @@ function nearbyFlatMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:num
61
61
  const out:any={bounds:map.bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
62
62
  function touches(n:{min:Vec3;max:Vec3}):boolean {
63
63
  charge(12);let near=0,far=1;
64
- for(const k of dims===2?['x','y'] as const:axes){const lo=n.min[k]-(k==='z'?height:radius),hi=n.max[k]+(k==='z'?0:radius);
64
+ for(let axis=0;axis<(dims===2?2:3);axis++){const k=axes[axis],lo=n.min[k]-(k==='z'?height:radius),hi=n.max[k]+(k==='z'?0:radius);
65
65
  if(d[k]===0){if(p[k]<lo-1e-7||p[k]>hi+1e-7)return false;}
66
- else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-7)return false;}
66
+ else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b){const swap=a;a=b;b=swap;}near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-7)return false;}
67
67
  }return true;
68
68
  }
69
69
  const found:Entry[]=[];
70
- function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e of n.entries)if(touches(e))found.push(e);}else{visit(n.left!);visit(n.right!);}}
70
+ function visit(n:Node){if(!touches(n))return;if(n.entries){for(let i=0;i<n.entries.length;i++){const e=n.entries[i];if(touches(e))found.push(e);}}else{visit(n.left!);visit(n.right!);}}
71
71
  visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
72
- found.sort((a,b)=>a.order-b.order);for(const e of found)out[e.key].push(e.value);
72
+ found.sort((a,b)=>a.order-b.order);for(let i=0;i<found.length;i++){const e=found[i];out[e.key].push(e.value);}
73
73
  if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const raw of map[key]??[]){
74
74
  const v:any=raw;let min:Vec3,max:Vec3;
75
75
  if(key==='boxes'){min=v.min;max=v.max;}