@homie-rocks/studio 0.51.7 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +51 -1
  4. package/bin/homie-studio.mjs +9 -4
  5. package/docs/toolkit-place-patterns.md +68 -0
  6. package/functions/FUNCTIONS.md +27 -0
  7. package/functions/functions.ts +4 -0
  8. package/guides/app/PLACES.md +123 -0
  9. package/guides/app/SKILL.md +11 -0
  10. package/guides/app/examples/reserve.mjs +13 -0
  11. package/guides/functions/SKILL.md +2 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/guides/studio-setup/SKILL.md +4 -0
  15. package/lib/app-needs.mjs +41 -6
  16. package/lib/app-ready.mjs +31 -0
  17. package/lib/build.mjs +5 -0
  18. package/lib/check.mjs +13 -10
  19. package/lib/cloudflare.mjs +2 -2
  20. package/lib/connector-token.mjs +30 -0
  21. package/lib/dev-restart.mjs +25 -0
  22. package/lib/dev.mjs +6 -1
  23. package/lib/functions-build.mjs +3 -0
  24. package/lib/mcp-tools.mjs +2 -2
  25. package/lib/scaffold.mjs +22 -1
  26. package/lib/shop-links.mjs +4 -4
  27. package/lib/shop-mcp.mjs +82 -14
  28. package/lib/stripe-connect.mjs +9 -4
  29. package/lib/template-history.json +84 -0
  30. package/lib/upgrade-places.mjs +49 -0
  31. package/lib/upgrade.mjs +2 -1
  32. package/lib/worker-config.mjs +6 -3
  33. package/links/links.d.mts +5 -0
  34. package/links/links.d.ts +2 -0
  35. package/links/links.mjs +2 -0
  36. package/links/time.mjs +20 -0
  37. package/netplay/netplay.ts +3 -0
  38. package/package.json +1 -1
  39. package/rules/collision.ts +4 -2
  40. package/rules/terrain.ts +2 -1
  41. package/rules/tiles.ts +8 -4
  42. package/shop/SHOP.md +27 -12
  43. package/shop/shop.ts +1 -1
  44. package/tools/tools.ts +12 -0
  45. package/worker/app-graphics.mjs +8 -0
  46. package/worker/app-records.mjs +5 -1
  47. package/worker/app-shell.mjs +61 -17
  48. package/worker/app-streams.mjs +57 -0
  49. package/worker/app-tools.mjs +44 -0
  50. package/worker/booking-holds.mjs +87 -0
  51. package/worker/build-id.mjs +1 -1
  52. package/worker/business-events.mjs +31 -0
  53. package/worker/connector-token.mjs +11 -0
  54. package/worker/event-resources.mjs +1 -1
  55. package/worker/functions.mjs +5 -0
  56. package/worker/index.mjs +24 -6
  57. package/worker/join-area.mjs +35 -0
  58. package/worker/mcp-events.mjs +2 -2
  59. package/worker/mcp-tools.mjs +3 -0
  60. package/worker/mcp.mjs +17 -9
  61. package/worker/pages.mjs +10 -7
  62. package/worker/records-page.mjs +5 -4
  63. package/worker/records-prefs.mjs +24 -0
  64. package/worker/shop-links.mjs +12 -0
  65. package/worker/shop.mjs +14 -4
  66. package/worker/version.mjs +1 -1
package/lib/shop-mcp.mjs CHANGED
@@ -6,11 +6,11 @@ import {join} from 'node:path';
6
6
  import {readShop, SHOP_HOOK_EVENTS} from './shop.mjs';
7
7
  import {readStudio, siteUrl} from './studio.mjs';
8
8
  import {reconcilePaymentLinks} from './shop-links.mjs';
9
- import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
9
+ import {fullerReason, linkSettings, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
10
10
  const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
11
11
  export const readKey = (path, params) => canonicalJson([path,params]);
12
12
  const slotsOf=(root,slots)=>slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
13
- export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
13
+ export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false, refresh = false, account} = {}) {
14
14
  const checked=readShop(root);
15
15
  if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
16
16
  if(!['test','live'].includes(mode))throw new Error('Choose test or live mode');
@@ -26,8 +26,38 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
26
26
  const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
27
27
  // Reject secrets before persisting observations. Webhook creation is never part of this plan.
28
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');
29
+ let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
30
+ const cacheFile=join(root,'.studio','stripe-mcp-observations.json');
31
+ let cache;try{cache=JSON.parse(readFileSync(cacheFile,'utf8'));}catch{}
32
+ const source=hash({shop:checked.shop,slots:bookingSlots});
33
+ const reusable=!refresh && cache?.site===site && cache.mode===mode && (!account || cache.account===account);
34
+ const legacy=!refresh && saved?.result?.site===site && saved.result.mode===mode && (!account || saved.options?.account===account);
35
+ let prior=reusable?cache.observations:legacy?saved.options?.observations??[]:[];
36
+ // Catalogue edits retain product/price discovery, but refresh mutable slot counts and writes.
37
+ const legacySourceMatches=legacy && (saved.source===source || saved.result.fingerprint===hash({shop:checked.shop,bookingSlots,site,mode,catalog:{config:saved.result.config,retire:saved.result.retire??[]}}));
38
+ if(reusable && cache.source!==source || !reusable && legacy && !legacySourceMatches)prior=prior.filter(o=>/\/v1\/(products|prices)(\/|$)/.test(o.path));
39
+ observations=[...prior,...observations];
40
+ if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
41
+ // Latest response per operation, bounded by operations rather than replanning attempts.
42
+ const latest=new Map();
43
+ for(const o of observations){const key=canonicalJson([o.method??'GET',o.path,o.params??{}]);latest.delete(key);latest.set(key,o);}
44
+ observations=[...latest.values()];
45
+ const observedAt=refresh?new Date().toISOString():reusable?cache.observedAt:legacy?saved.result.verifiedAt??new Date().toISOString():new Date().toISOString();
46
+ mkdirSync(join(root,'.studio'),{recursive:true});
47
+ writeFileSync(cacheFile,JSON.stringify({site,mode,account:account??cache?.account??saved?.options?.account,source,observedAt,observations}));
29
48
  const api=async(method,path,params={},idempotency)=>{
30
49
  let found=observations.findLast(o=>(o.method??'GET')===method && readKey(o.path,o.params??{})===readKey(path,params));
50
+ // Upgrade old list reads to the scoped searches, and hydrate old link lists
51
+ // from the expanded reads already saved by 0.51.6.
52
+ if(!found && method==='GET' && /\/v1\/(products|prices)\/search$/.test(path) && !params.page)
53
+ found=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===path.replace('/search','') && o.body?.has_more===false);
54
+ if(!found && method==='GET' && path==='/v1/payment_links' && !params.starting_after) {
55
+ const old=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===path && o.body?.has_more===false);
56
+ if(old) {
57
+ const data=old.body.data.map(l=>observations.findLast(o=>o.path===`${path}/${l.id}`)?.body??l);
58
+ if(data.every(l=>l.line_items?.data))found={...old,body:{...old.body,data}};
59
+ }
60
+ }
31
61
  if(method==='GET'&&path.split('/').length>=4&&!path.endsWith('/search')) {
32
62
  const written=observations.findLast(o=>o.method==='POST'&&o.body?.id===path.split('/').at(-1));
33
63
  if(written&&observations.indexOf(written)>observations.indexOf(found)) {
@@ -36,22 +66,24 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
36
66
  }
37
67
  }
38
68
  if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
39
- const body=found.body;
69
+ let body=found.body;
70
+ if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
71
+ const kind=path.split('/')[2], rows=new Map(body.data.map(o=>[o.id,o]));
72
+ for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST') {
73
+ if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
74
+ if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
75
+ }
76
+ body={...body,data:[...rows.values()]};
77
+ }
40
78
  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');
41
79
  return body;
42
80
  };
43
81
  const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
44
82
  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
- }
51
83
  try {
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});
84
+ const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,knownPortal:saved?.result?.site===site&&saved.result.mode===mode&&(!account||saved.options?.account===account)?saved.result.config?.portalId:undefined});
53
85
  if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
54
- const result={...base,ok:true,verified:true,verifiedAt:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
86
+ const result={...base,ok:true,verified:true,verifiedAt:observedAt,...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
55
87
  return result;
56
88
  } catch(e) {
57
89
  if(!e.next&&!e.operations)throw e;
@@ -69,7 +101,7 @@ export async function saveMcpVerification(root, options) {
69
101
  mkdirSync(join(root,'.studio'),{recursive:true});
70
102
  const slots=slotsOf(root,options.slots);
71
103
  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));
104
+ writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account??JSON.parse(readFileSync(join(root,'.studio','stripe-mcp-observations.json'),'utf8')).account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),sourceConfig:{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));
73
105
  }
74
106
  return result;
75
107
  }
@@ -78,8 +110,44 @@ export async function saveMcpVerification(root, options) {
78
110
  export function verifiedMcpConfig(root) {
79
111
  const file=join(root,'.studio','stripe-mcp.json');if(!existsSync(file))return null;
80
112
  const saved=JSON.parse(readFileSync(file,'utf8'));
81
- if(saved.result?.local)return null; // Local builds read .dev.vars, never production bindings.
113
+ if(!saved.result?.verified || saved.result.local)return null; // Local builds read .dev.vars, never production bindings.
82
114
  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.');
115
+ const shop=readShop(root).shop, source=hash({shop,slots}), site=new URL(siteUrl(root)).origin;
116
+ const legacyFingerprint=hash({shop,bookingSlots:slots,site,mode:saved.result.mode,catalog:{config:saved.result.config,retire:saved.result.retire??[]}});
117
+ if(!saved.source && saved.result.fingerprint===legacyFingerprint) {
118
+ saved.source=source;saved.sourceConfig={shop,slots};
119
+ writeFileSync(file,JSON.stringify(saved,null,2));
120
+ }
121
+ if(saved.source!==source || saved.result.site!==site) {
122
+ const changes=[];
123
+ if(saved.result.site!==site)changes.push('site URL');
124
+ const previous=saved.sourceConfig?.shop;
125
+ if(previous)for(const key of new Set([...Object.keys(previous),...Object.keys(shop??{})])) {
126
+ if(canonicalJson(previous[key]??null)!==canonicalJson(shop?.[key]??null))changes.push(`shop.${key}`);
127
+ }
128
+ else {
129
+ let before;try{before=JSON.parse(saved.result.config?.settings);}catch{}
130
+ const after=JSON.parse(linkSettings(shop));
131
+ if(before)for(const key of new Set([...Object.keys(before),...Object.keys(after)])) {
132
+ if(canonicalJson(before[key]??null)!==canonicalJson(after[key]??null))changes.push(`shop.${key}`);
133
+ }
134
+ else changes.push('shop settings');
135
+ const receipt=saved.options?.observations?.find(o=>o.body?.custom_text?.submit?.message)?.body.custom_text.submit.message;
136
+ if(receipt!==(shop.giving?.receiptText||undefined))changes.push('shop.giving.receiptText');
137
+ }
138
+ if(canonicalJson(saved.sourceConfig?.slots??saved.options?.slots??[])!==canonicalJson(slots))changes.push('booking slots');
139
+ throw new Error(`Shop configuration changed: ${changes.join(', ')||'shop settings (saved verification differs)'}. Run stripe_shop_plan with verify:true before deploying or installing its webhook.`);
140
+ }
84
141
  return saved;
85
142
  }
143
+
144
+ /** Unfinished public plans also publish a closed setup, even before first verification. */
145
+ export function publicMcpSetup(root) {
146
+ for(const name of ['stripe-mcp.json','stripe-mcp-observations.json']) {
147
+ const file=join(root,'.studio',name);
148
+ if(!existsSync(file))continue;
149
+ const saved=JSON.parse(readFileSync(file,'utf8'));
150
+ return !saved.result?.local && !(saved.site??saved.result?.site??saved.options?.url??'').startsWith('http:');
151
+ }
152
+ return false;
153
+ }
@@ -37,11 +37,11 @@ const stripeReason = (result, call) => {
37
37
  };
38
38
 
39
39
  // This output stays private, including CLI config, completion, errors and API responses.
40
- export function stripeProcess(args, { cwd, env = process.env } = {}) {
40
+ export function stripeProcess(args, { cwd, env = process.env, command = 'stripe' } = {}) {
41
41
  const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
42
42
  for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
43
43
  return new Promise((resolve) => {
44
- const p = spawn('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
44
+ const p = spawn(command, args, { cwd, env: childEnv, shell: process.platform==='win32' && command==='npm.cmd', stdio: ['ignore', 'pipe', 'pipe'] });
45
45
  let stdout = '', stderr = '', ended = false;
46
46
  const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
47
47
  const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
@@ -92,7 +92,7 @@ export async function shopConnect(root, options = {}) {
92
92
  try {
93
93
  const {verifiedMcpConfig} = await import('./shop-mcp.mjs');
94
94
  const saved=verifiedMcpConfig(root);
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).');
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
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;
@@ -110,7 +110,12 @@ export async function shopConnect(root, options = {}) {
110
110
  try {
111
111
  const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
112
112
  const installed = await cli(['--version']);
113
- if (installed.code !== 0) return fail('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
113
+ if (installed.code !== 0) {
114
+ log('Installing Stripe’s official CLI with npm…');
115
+ const install=options.installExec??((command,args)=>stripeProcess(args,{cwd:root,command}));
116
+ const result=await install(process.platform==='win32'?'npm.cmd':'npm',['install','--global','@stripe/cli@latest']);
117
+ if(result.code!==0 || (await cli(['--version'])).code!==0)return fail('stripe-cli','Automatic Stripe CLI installation failed. Check npm install permissions and PATH, then run shop connect again.',{next:['npm install --global @stripe/cli@latest']});
118
+ }
114
119
 
115
120
  // A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
116
121
  let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
@@ -3837,6 +3837,90 @@
3837
3837
  }
3838
3838
  }
3839
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
+ }
3882
+ },
3883
+ "0.52.0": {
3884
+ "files": {
3885
+ "CLAUDE.md": "ddf2b2a0376202f6",
3886
+ "README.md": "684b3cca8001673c",
3887
+ "HANDOFF.md": "29337e2ace95b2e7",
3888
+ "games/README.md": "5ad32fe80c61f117",
3889
+ "music/README.md": "a3dc042dadd39df8",
3890
+ "videos/README.md": "16babc3437d5545b",
3891
+ "posts/README.md": "37841aae476bd540",
3892
+ "site/README.md": "1dbdf22c03a1e505",
3893
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3894
+ },
3895
+ "sections": {
3896
+ "AGENTS.md": {
3897
+ "(intro)": "394cbbe934e80722",
3898
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3899
+ "## Layout": "f3a280b8c96cd068",
3900
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3901
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3902
+ "## The site": "0cb8dd9a6f8a0096",
3903
+ "## Making games": "bb715272fd1a615b",
3904
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3905
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3906
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3907
+ "## Servers and AI seats": "1ccfd549d2369a60",
3908
+ "## Selling things (the shop)": "0df5a5fe1833c624",
3909
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3910
+ "## Rules": "b2234d80f6cbe71d",
3911
+ "## Telling Homie": "80b46fdd9e578db8",
3912
+ "## Beta": "9d02638207a26e73"
3913
+ }
3914
+ },
3915
+ "named": {
3916
+ "Homie Arcade": {
3917
+ "sections": {
3918
+ "AGENTS.md": {
3919
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3920
+ }
3921
+ }
3922
+ }
3923
+ }
3840
3924
  }
3841
3925
  }
3842
3926
  }
@@ -0,0 +1,49 @@
1
+ import {createRequire} from 'node:module';
2
+ import {existsSync,readdirSync,readFileSync} from 'node:fs';
3
+ import {join} from 'node:path';
4
+ /** Only a standalone, named refresh timer over known record clients is mechanical. */
5
+ export function upgradePlaceView(source,root){
6
+ // Desktop ships no compiler; use the studio’s installed compiler when available.
7
+ let ts;try{ts=createRequire(root?join(root,'package.json'):import.meta.url)('typescript');}catch{return source;}
8
+ const file=ts.createSourceFile('view.ts',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS);
9
+ const factories=new Set(),clients=[];
10
+ for(const statement of file.statements){
11
+ if(ts.isImportDeclaration(statement)&&statement.moduleSpecifier.text==='@homie-rocks/studio/apps'){
12
+ for(const name of statement.importClause?.namedBindings?.elements??[])if((name.propertyName?.text??name.name.text)==='createAppRecords')factories.add(name.name.text);
13
+ }
14
+ if(ts.isVariableStatement(statement))for(const d of statement.declarationList.declarations){
15
+ if(ts.isIdentifier(d.name)&&d.initializer&&ts.isCallExpression(d.initializer)&&ts.isIdentifier(d.initializer.expression)&&factories.has(d.initializer.expression.text))clients.push(d.name.text);
16
+ }
17
+ }
18
+ if(!clients.length)return source;
19
+ const edits=[];
20
+ for(const statement of file.statements){
21
+ if(!ts.isExpressionStatement(statement)||!ts.isCallExpression(statement.expression))continue;
22
+ const call=statement.expression;
23
+ if(!ts.isIdentifier(call.expression)||call.expression.text!=='setInterval'||call.arguments.length!==2)continue;
24
+ const [callback,delay]=call.arguments;
25
+ if(!ts.isIdentifier(callback)||!/^refresh/i.test(callback.text)||!ts.isNumericLiteral(delay)||+delay.text<2000||+delay.text>3000)continue;
26
+ edits.push({start:statement.getStart(file),end:statement.end,text:clients.map(name=>`${name}.subscribe(${callback.text});`).join('\n')});
27
+ }
28
+ for(const edit of edits.reverse())source=source.slice(0,edit.start)+edit.text+source.slice(edit.end);
29
+ return source;
30
+ }
31
+ /** Exact obsolete notes only. Never rewrite owner's words, evidence, dates or unresolved needs. */
32
+ export function upgradePlaceCodex(source){
33
+ const replacements=new Map([
34
+ ['The runner has no staff surface or MCP step.','The toolkit supports staff surfaces and MCP needs. Rerun the needs; earlier manual evidence is not a passing proof.'],
35
+ ['Real Homie gap: no push for studio events, so the AI polls.','Declared studio event feeds support push subscriptions; reconnect from the last cursor.'],
36
+ ['Staff device runs the jobs Homie has no scheduler for.','Run these jobs in scheduled server functions; remove the duplicate staff-device job after proving its replacement.'],
37
+ ['ctx.database has only get/put (no atomic append).','Use versioned database writes and named event feeds; do not maintain an event log with sleep-and-retry.']
38
+ ]);
39
+ return source.split('\n').map(line=>replacements.get(line)??line).join('\n');
40
+ }
41
+ export function placeUpgradeChanges(root){
42
+ const dir=join(root,'apps');if(!existsSync(dir))return [];
43
+ const changes=[];
44
+ for(const app of readdirSync(dir,{withFileTypes:true}).filter(d=>d.isDirectory()))for(const [suffix,rewrite] of [['CODEX.md',upgradePlaceCodex],['src/view.ts',upgradePlaceView]]){
45
+ const file=`apps/${app.name}/${suffix}`,path=join(root,file);if(!existsSync(path))continue;
46
+ const from=readFileSync(path,'utf8'),text=rewrite(from,root);if(from!==text)changes.push({file,kind:'update-file',from,text,what:'replace a recognized obsolete place workaround with the supported toolkit contract; rerun needs'});
47
+ }
48
+ return changes;
49
+ }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {placeUpgradeChanges} from './upgrade-places.mjs';
1
2
  import { upgradeMediaGuidance } from './upgrade-media.mjs';
2
3
  import {appPresetChanges} from './upgrade-app-presets.mjs';
3
4
  import {refreshWorkerConfig} from './worker-config.mjs';
@@ -176,7 +177,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
176
177
  const who = { name, slug };
177
178
  const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
178
179
  const { files: oldFiles, secs: oldSecs } = known(history, who);
179
- const changes = appPresetChanges(root);
180
+ const changes = [...appPresetChanges(root),...placeUpgradeChanges(root)];
180
181
  const kept = [];
181
182
 
182
183
  // package.json: the pin, and the scripts the template's studios have.
@@ -1,4 +1,4 @@
1
- import {verifiedMcpConfig} from './shop-mcp.mjs';
1
+ import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
2
2
  import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
3
3
  import {join} from 'node:path';
4
4
  import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
@@ -27,8 +27,11 @@ export function updateWorkerConfig(root,generated) {
27
27
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
28
28
  }
29
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)};
30
+ verifiedMcpConfig(root);
31
+ if(publicMcpSetup(root)) {
32
+ next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS_ASSET:'/_site/stripe-shop-links.json'};
33
+ delete next.vars.STRIPE_SHOP_LINKS;
34
+ }
32
35
  // Crons are additive: studio-owned entries cannot erase required toolkit ticks.
33
36
  const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
34
37
  next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
@@ -0,0 +1,5 @@
1
+ export function randomId(): string;
2
+ export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
3
+ export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
4
+ /** Epoch milliseconds for local date/time in an IANA zone; gaps and repeats reject by default. */
5
+ export function placeTime(date: string, time: string, timeZone: string, options?: {disambiguation?: 'reject' | 'earlier' | 'later'}): number;
package/links/links.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export function randomId(): string;
2
2
  export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
3
3
  export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
4
+ /** Epoch milliseconds for local date/time in an IANA zone; gaps and repeats reject by default. */
5
+ export function placeTime(date: string, time: string, timeZone: string, options?: {disambiguation?: 'reject' | 'earlier' | 'later'}): number;
package/links/links.mjs CHANGED
@@ -14,3 +14,5 @@ export function appLink(address, params = {}, base = globalThis.location?.href)
14
14
  }
15
15
  return url.href;
16
16
  }
17
+
18
+ export {placeTime} from './time.mjs';
package/links/time.mjs ADDED
@@ -0,0 +1,20 @@
1
+ /** Interpret a place's calendar date and wall time using the runtime's IANA zone data. */
2
+ export function placeTime(date,time,timeZone,{disambiguation='reject'}={}){
3
+ if(!/^\d{4}-\d{2}-\d{2}$/.test(date)||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time))throw new Error('Use a calendar date YYYY-MM-DD and time HH:MM');
4
+ if(typeof timeZone!=='string'||/^[+-]/.test(timeZone))throw new Error('Use an IANA time zone');
5
+ if(!['reject','earlier','later'].includes(disambiguation))throw new Error('Unknown time disambiguation');
6
+ const naive=Date.parse(date+'T'+time+':00Z');
7
+ if(!Number.isFinite(naive)||new Date(naive).toISOString().slice(0,10)!==date)throw new Error('Invalid calendar date');
8
+ let format;try{format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});}catch{throw new Error('Use a valid IANA time zone');}
9
+ const wall=epoch=>{
10
+ const p=Object.fromEntries(format.formatToParts(epoch).map(p=>[p.type,p.value]));
11
+ return Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`);
12
+ };
13
+ const offsets=new Set();
14
+ // Sample both sides of even a whole-day civil transition; do not assume an hour-sized DST shift.
15
+ for(let h=-48;h<=48;h+=6){const sample=naive+h*3600000;offsets.add(wall(sample)-sample);}
16
+ const choices=[...offsets].map(offset=>naive-offset).filter(epoch=>wall(epoch)===naive).sort((a,b)=>a-b);
17
+ if(!choices.length)throw new Error('This local time does not exist in the place’s time zone');
18
+ if(choices.length>1&&disambiguation==='reject')throw new Error('This local time is ambiguous; choose earlier or later explicitly');
19
+ return disambiguation==='later'?choices.at(-1):choices[0];
20
+ }
@@ -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.7",
3
+ "version": "0.52.0",
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]; },
package/shop/SHOP.md CHANGED
@@ -122,27 +122,34 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
122
122
  3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
123
123
  for **writes as well as reads**. Keep this setup's observations across batches.
124
124
  Product creation includes its default Price; link creation expands line items.
125
- Product and Price Search use studio/origin metadata. Existing links are fetched by
126
- IDs from the verified configuration; Stripe has no Payment Link metadata search.
127
- Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
128
- it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
129
- window, recover the studio's existing link IDs before setup to avoid duplicates.
130
- Follow search `next_page` cursors. Search is eventually consistent: write responses
131
- are the immediate evidence. On an uncertain write, retry its exact idempotency key;
132
- never discard successful responses or invent IDs. Start a new read set when checking
133
- later external edits, changing accounts, or changing mode.
125
+ Product and Price Search use studio/origin metadata. Payment Links use paginated
126
+ list reads with `expand[]=data.line_items`, then filter studio/origin metadata locally;
127
+ Stripe has no Payment Link metadata search. There is no per-link read loop.
128
+ The planner saves public observations in `.studio/stripe-mcp-observations.json` and
129
+ reuses them on the next call; send only new responses. Keep that file and
130
+ `.studio/stripe-mcp.json` when moving computers. Changed shop/slot settings refresh
131
+ mutable links while retaining product/price discovery. Account, mode and origin
132
+ changes start separate reads. Pass `refresh: true` for Dashboard changes or a fresh
133
+ verification before webhook installation (CLI: include it in `--have` input).
134
+ Follow search `next_page` and list `starting_after` cursors. On an uncertain write,
135
+ retry its exact idempotency key; never discard successful responses or invent IDs.
134
136
  4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
135
137
  `homie-studio shop verify --have plan-input.json`). Verification checks real fields,
136
138
  line items, menus, slot limits and portal settings. It saves compact public Worker
137
139
  configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
138
- Deploy installs those links. Checkout then reports only `webhook-secret` missing;
140
+ Build writes public links to `site/dist/_site/stripe-shop-links.json`; deploy ships
141
+ that asset, with only its path in Wrangler vars. Large catalogues never use a text
142
+ binding. Deploy after verifying, including when setup began on an already-live site. Checkout then reports only `webhook-secret` missing;
139
143
  it cannot open until signed payment events can confirm orders. Changing shop.json,
140
144
  the slot manifest or origin requires verification again. Local test configuration
141
145
  goes to `.dev.vars`.
142
146
  5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
143
147
  Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
144
- checks the same account as the MCP, and installs the signing secret and verified
145
- links through Wrangler stdin. The CLI needs one separate browser approval if its
148
+ checks the same account as the MCP, and installs only the signing secret through
149
+ Wrangler stdin. If Stripe CLI is missing, the tool installs the official
150
+ `@stripe/cli@latest` npm package automatically, checks the executable, then continues.
151
+ An installation failure reports npm permissions/PATH and a retry command. The CLI
152
+ needs one separate browser approval if its
146
153
  studio profile is not already connected. MCP approval cannot be reused or exported.
147
154
  Cloudflare authorization and an existing deployment are prerequisites.
148
155
  6. After installation, perform the returned `retire` operations through Stripe MCP to
@@ -153,6 +160,14 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
153
160
  endpoint's delivery result and the app's verified records. Do not type a card into
154
161
  a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
155
162
 
163
+ Upgrades accept 0.51.6 verifications without a source fingerprint only when their
164
+ saved content fingerprint still matches the current shop, slots, site and mode.
165
+ Actual changes name the affected settings and require verification. An unfinished
166
+ setup still builds and deploys with checkout closed and one next step: finish
167
+ `stripe_shop_plan` with `verify: true`. Missing/unreadable assets also close checkout;
168
+ old link secrets cannot reopen it. Cached observations never renew the verification
169
+ time; use fresh reads if the private webhook tool requests them.
170
+
156
171
  Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
157
172
 
158
173
  **Why the separate approval (checked 2026-10-14).** Stripe's
package/shop/shop.ts CHANGED
@@ -71,7 +71,7 @@ export interface Shop {
71
71
  cart(): CartLine[];
72
72
  clear(): void;
73
73
  /** Opens Checkout: keyless bookings are pending until paid; explicit session checkout holds capacity. The returned ID tracks the public booking record. */
74
- book(item: string, booking: { collection: string; slot: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
74
+ book(item: string, booking: { collection: string; slot: string; hold?: string; token?: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
75
75
  checkout(options?: { reference?: string }): Promise<boolean>;
76
76
  buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
77
77
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
package/tools/tools.ts CHANGED
@@ -6,6 +6,14 @@ export interface ToolContext {
6
6
  records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update' | 'delete'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
7
7
  office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
8
8
  shop(): Promise<unknown>;
9
+ bookings: {
10
+ hold(args: {app: string; collection: string; slot: string; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
11
+ get(args: {id: string; token?: string}): Promise<BookingHold>;
12
+ confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
13
+ release(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
14
+ releaseExpired(args: {app: string; collection: string; now?: number}): Promise<{released: number}>;
15
+ };
16
+ events: { publish(event: {feed: string; id: string; data: unknown}): Promise<{id: string}> };
9
17
  rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
10
18
  database: {
11
19
  get(key: string): Promise<unknown>;
@@ -32,7 +40,11 @@ export interface StudioTool<Input = Record<string, unknown>> {
32
40
  namespace?: string;
33
41
  /** A named HMAC secret delegates this one tool as an existing account. */
34
42
  webhook?: { secret: string; person: string };
43
+ /** Named business feeds this trusted definition may publish. */
44
+ publish?: string[];
35
45
  events?: Record<string, {game: string; schema: Record<string, unknown>; audience?: Audience}>;
36
46
  handler(input: Input, context: ToolContext): unknown | Promise<unknown>;
37
47
  }
38
48
  export function defineTool<Input = Record<string, unknown>>(tool: StudioTool<Input>): StudioTool<Input> { return tool; }
49
+
50
+ export interface BookingHold { id: string; slot: string; quantity: number; state: string; version: number; expiresAt: number | null; paid: boolean }
@@ -0,0 +1,8 @@
1
+ export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
2
+ if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
3
+ try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
4
+ const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
5
+ p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
6
+ const canvas=document.querySelector('canvas');
7
+ canvas.insertAdjacentElement('afterend',p);
8
+ }