@homie-rocks/studio 0.48.4 → 0.50.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 (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +20 -16
  34. package/functions/FUNCTIONS.md +1 -1
  35. package/guides/app/SKILL.md +178 -0
  36. package/guides/art/SKILL.md +154 -0
  37. package/guides/art/references/RIGHTS.md +20 -0
  38. package/guides/art/scripts/art.mjs +308 -0
  39. package/guides/models/SKILL.md +198 -0
  40. package/guides/models/references/RIGHTS.md +46 -0
  41. package/guides/models/references/models.json +175 -0
  42. package/guides/models/scripts/models.mjs +483 -0
  43. package/guides/music/SKILL.md +197 -0
  44. package/guides/music/references/COMPOSITION.md +59 -0
  45. package/guides/music/references/GAME-AUDIO.md +50 -0
  46. package/guides/music/references/RIGHTS.md +43 -0
  47. package/guides/music/scripts/lib/audio.mjs +258 -0
  48. package/guides/music/scripts/lib/eleven.mjs +260 -0
  49. package/guides/music/scripts/lib/studio.mjs +147 -0
  50. package/guides/music/scripts/music.mjs +644 -0
  51. package/guides/parts/SKILL.md +158 -0
  52. package/guides/plan/SKILL.md +126 -0
  53. package/guides/plan/references/CODEX.md +105 -0
  54. package/guides/plan/references/INTERVIEW.md +86 -0
  55. package/guides/playtest/SKILL.md +205 -0
  56. package/guides/playtest/references/METHOD.md +124 -0
  57. package/guides/playtest/references/REVIEWER.md +78 -0
  58. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  59. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  60. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  61. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  62. package/guides/playtest/scripts/playtest.mjs +870 -0
  63. package/guides/shop/SKILL.md +153 -0
  64. package/guides/sound/SKILL.md +156 -0
  65. package/guides/sound/references/GAME-SOUND.md +100 -0
  66. package/guides/sound/references/SCORE.md +131 -0
  67. package/guides/sound/references/TRAPS.md +93 -0
  68. package/guides/sound/scripts/lib/measure.mjs +252 -0
  69. package/guides/sound/scripts/lib/score.mjs +415 -0
  70. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  71. package/guides/sound/scripts/lib/synth.mjs +509 -0
  72. package/guides/sound/scripts/sound.mjs +520 -0
  73. package/guides/studio-setup/SKILL.md +419 -0
  74. package/guides/style/SKILL.md +105 -0
  75. package/lib/app-needs.mjs +63 -0
  76. package/lib/authoring-guides.mjs +5 -4
  77. package/lib/build.mjs +5 -4
  78. package/lib/check-site.mjs +6 -6
  79. package/lib/check.mjs +18 -5
  80. package/lib/codex.mjs +95 -18
  81. package/lib/decisions.mjs +9 -7
  82. package/lib/dev.mjs +4 -2
  83. package/lib/doctor.mjs +1 -1
  84. package/lib/handoff.mjs +13 -8
  85. package/lib/mcp-tools.mjs +19 -17
  86. package/lib/mcp.mjs +1 -1
  87. package/lib/parts.mjs +6 -6
  88. package/lib/scaffold.mjs +45 -11
  89. package/lib/shop-links.mjs +2 -2
  90. package/lib/shop.mjs +5 -3
  91. package/lib/studio.mjs +7 -6
  92. package/lib/template-history.json +84 -0
  93. package/package.json +4 -4
  94. package/shop/SHOP.md +4 -0
  95. package/shop/shop.ts +4 -4
  96. package/worker/app-format.mjs +1 -1
  97. package/worker/build-id.mjs +1 -1
  98. package/worker/functions-schema.mjs +15 -0
  99. package/worker/index.mjs +2 -1
  100. package/worker/parts.mjs +4 -4
  101. package/worker/shop-page.mjs +22 -16
  102. package/worker/shop-rules.mjs +4 -3
  103. package/worker/shop.mjs +6 -2
  104. package/worker/site.mjs +4 -2
  105. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -531,7 +531,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
531
531
  ...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
532
532
  };
533
533
  let html = await res.text();
534
- const head = `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
534
+ const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;top:80px;padding:16px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
535
+ const head = fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
535
536
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
536
537
  return new Response(html, {
537
538
  headers: {
package/worker/parts.mjs CHANGED
@@ -263,13 +263,13 @@ ${sayRow('Say', sayAdd(p))}
263
263
  * build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
264
264
  */
265
265
  export function partsBand(g, parts, host) {
266
- const mine = parts.filter((p) => (p.from?.game ?? p.from?.app) === g.id);
266
+ const mine = parts.filter((p) => (p.from?.game ?? p.from?.app ?? (p.from?.kind === 'app' ? p.from.id : null)) === g.id);
267
267
  if (!mine.length) return '';
268
- const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new game in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
268
+ const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new ${g.kind === 'app' ? 'app' : 'game'} in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
269
269
  return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
270
270
  <p class="ptag">Take a piece</p>
271
- <h2 id="parts-title">Parts from this game</h2>
272
- <p class="plead">Pieces of ${esc(g.name)} you can put in a game of your own, each with its licence. Mix them with parts from other games: a creature from here, a level from there.</p>
271
+ <h2 id="parts-title">Parts from this ${g.kind === 'app' ? 'app' : 'game'}</h2>
272
+ <p class="plead">Pieces of ${esc(g.name)} you can put in ${g.kind === 'app' ? 'an app' : 'a game'} of your own, each with its licence.</p>
273
273
  <ul class="pgrid">${mine.map(card).join('')}</ul>
274
274
  ${sayRow('In a chat with your Homie studio open, say', say)}
275
275
  <div class="keys"><a class="btn" href="/parts/#from-${esc(g.id)}">${icon('spark')}<span>See the parts</span></a></div>
@@ -61,17 +61,21 @@ export const WAY_LINES = Object.freeze({
61
61
  export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
62
62
  const studio = cat.studio?.name ?? 'Studio';
63
63
  const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
64
- const boot = { game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio };
64
+ const preview = !open && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/.test(origin);
65
+ const giving = Boolean(shop.giving);
66
+ const previewItems = preview ? shop.items.map(i => `<article class="item"><h2>${esc(i.name)}</h2><span class="price">${esc(money(i.price === 'choose' ? i.min : i.price, shop.currency))}${i.interval ? ' / month' : ''}</span><p>${esc(i.blurb ?? '')}</p><button disabled>Preview only</button></article>`).join('') : '';
67
+ const boot = { giving, preview, game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio };
65
68
  const main = `<section class="shop" data-shop>
66
69
  <p class="kicker">${esc(studio)}</p>
67
- <h1>${g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
70
+ <h1>${giving ? 'Give' : g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
68
71
  ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test cards only)</p>' : ''}
69
- <p class="lead">Things you can buy here, in real money, from ${esc(studio)} itself. Paid purchases open Stripe's checkout page. Guest purchases stay in this browser; keep its cookies or sign in to keep access. ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
72
+ <p class="lead">${giving ? `Support ${esc(studio)} with a one-time or monthly gift. Gifts go directly to the organisation through its own Stripe account.` : `Things you can buy here, in real money, from ${esc(studio)} itself. Paid purchases open Stripe's checkout page. Guest purchases stay in this browser; keep its cookies or sign in to keep access.`} ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
70
73
  <noscript><p class="lead">This page needs JavaScript.</p></noscript>
71
74
  <div data-shop-age hidden></div>
72
- <div class="items" data-shop-items><p>${open ? 'Reading the shop…' : 'The shop is not open yet.'}</p></div>
75
+ <div class="items" data-shop-items><p>${open ? 'Reading the shop…' : preview ? 'PREVIEW — checkout is disabled. Connect Stripe to open.' : 'Connect the organisation’s Stripe account to open giving or sales.'}</p>${previewItems}</div>
76
+ ${giving && shop.giving.receiptText ? `<p class="note">${esc(shop.giving.receiptText)}</p>` : ''}
73
77
  <p class="msg" data-shop-msg role="status" aria-live="polite"></p>
74
- <p class="note">${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.</p>
78
+ <p class="note">${giving ? `${esc(studio)} receives your gift through its own Stripe account. Stripe emails the payment receipt.` : `${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.`}</p>
75
79
  </section>
76
80
  <script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
77
81
  <script src="/_homie/shop.js" defer></script>`;
@@ -81,13 +85,13 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
81
85
  export function thanksPage(cat, shop, { session = null, game = null } = {}) {
82
86
  const studio = cat.studio?.name ?? 'Studio';
83
87
  const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
84
- const boot = { thanks: session, game: g ? g.id : null, studio };
88
+ const boot = { giving: Boolean(shop.giving), thanks: session, game: g ? g.id : null, studio };
85
89
  const main = `<section class="shop" data-shop-thanks>
86
90
  <p class="kicker">${esc(studio)}</p>
87
91
  <h1 data-thanks-title>Thank you</h1>
88
- <p class="lead" data-thanks-line>Stripe is confirming the payment. This page says when it is yours (a few seconds).</p>
92
+ <p class="lead" data-thanks-line>Stripe is confirming the payment. ${shop.giving ? 'Thank you for supporting our work.' : 'This page says when it is yours (a few seconds).'}</p>
89
93
  <p>${g ? `<a class="btn" href="/${esc(g.id)}/play">Back to ${esc(g.name)}</a>` : '<a class="btn" href="/">Back to the studio</a>'} <a href="/account/">Your account</a></p>
90
- <p class="note">Your receipt comes from Stripe by email. ${refundWords(shop)}</p>
94
+ <p class="note">Your receipt comes from Stripe by email. ${shop.giving ? esc(shop.giving.receiptText ?? '') : refundWords(shop)}</p>
91
95
  </section>
92
96
  <script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
93
97
  <script src="/_homie/shop.js" defer></script>`;
@@ -97,6 +101,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
97
101
  /** The refund policy (Stripe asks every seller for one; the kit's is generous by default). */
98
102
  export function refundsPage(cat, shop) {
99
103
  const studio = cat.studio?.name ?? 'Studio';
104
+ if (shop.giving) return layout(cat, { title: `Giving · ${studio}`, page: 'shop', main: `<section class="shop"><h1>Giving</h1><p>For questions about your gift, contact ${esc(studio)}.</p><p>${esc(shop.giving.receiptText ?? '')}</p></section>` });
100
105
  const main = `<section class="shop">
101
106
  <p class="kicker">${esc(studio)}</p>
102
107
  <h1>Refunds</h1>
@@ -182,9 +187,10 @@ export const SHOP_JS = String.raw`(function () {
182
187
  cartBox.appendChild(checkout);
183
188
  }
184
189
  function draw(s) {
190
+ if (boot.preview && !s.open) return;
185
191
  list.textContent = '';
186
192
  if (!s.ok) { list.appendChild(el('p', null, s.message || 'The shop did not answer.')); return; }
187
- if (!s.open) { list.appendChild(el('p', null, s.kids ? 'Nothing is sold here.' : 'The shop is not open yet.')); return; }
193
+ if (!s.open) { list.appendChild(el('p', null, s.kids ? 'Nothing is sold here.' : 'Connect the organisation’s Stripe account to open giving or sales.')); return; }
188
194
  var age = $('[data-shop-age]');
189
195
  var needsAge = s.items.some(function (i) { return i.way === 'age-question'; });
190
196
  if (age) {
@@ -217,7 +223,7 @@ export const SHOP_JS = String.raw`(function () {
217
223
  if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
218
224
  act.appendChild(amount);
219
225
  }
220
- var b = el('button', { type: 'button', class: 'buy' }, i.price === 'choose' ? 'Give on Stripe' : 'Buy on Stripe');
226
+ var b = el('button', { type: 'button', class: 'buy' }, boot.giving || i.kind === 'donation' || i.price === 'choose' ? 'Give on Stripe' : 'Buy on Stripe');
221
227
  b.addEventListener('click', function () {
222
228
  b.disabled = true; say('Opening Stripe…');
223
229
  api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? units(amount.value, s.currencyScale) : undefined }).then(function (r) {
@@ -230,7 +236,7 @@ export const SHOP_JS = String.raw`(function () {
230
236
  var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
231
237
  add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
232
238
  if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
233
- else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
239
+ else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
234
240
  } else if (i.way === 'owned') {
235
241
  act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
236
242
  } else if (i.way === 'make-an-account') {
@@ -255,7 +261,7 @@ export const SHOP_JS = String.raw`(function () {
255
261
  list.appendChild(card);
256
262
  });
257
263
  if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
258
- if (!s.items.length) list.appendChild(el('p', null, 'Nothing for sale here right now.'));
264
+ if (!s.items.length) list.appendChild(el('p', null, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
259
265
  if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
260
266
  }
261
267
  function load(cursor) { api('GET', '/api/shop' + q + (q ? '&' : '?') + 'cursor=' + encodeURIComponent(cursor || '')).then(draw).catch(function () { draw({ ok: false }); }); }
@@ -276,9 +282,9 @@ export const SHOP_JS = String.raw`(function () {
276
282
  (function poll() {
277
283
  tries += 1;
278
284
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
279
- if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = 'It\'s yours'; $('[data-thanks-line]').textContent = 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
285
+ if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
280
286
  if (tries < 40) setTimeout(poll, 1500);
281
- else $('[data-thanks-line]').textContent = 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
287
+ else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
282
288
  }).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
283
289
  })();
284
290
  }
@@ -459,7 +465,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
459
465
  listBox.appendChild(row);
460
466
  });
461
467
  if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { loadItems(s.nextCursor); }; listBox.appendChild(next); }
462
- if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, 'Nothing for sale here right now.'));
468
+ if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
463
469
  }).catch(function () { listBox.appendChild(el('p', { class: 'sub' }, 'The shop did not answer.')); });
464
470
  }
465
471
  loadItems('');
@@ -485,7 +491,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
485
491
  if (m.op === 'checkout') {
486
492
  if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
487
493
  var checkoutTab = window.open('', '_blank');
488
- return api('POST', '/api/shop/buy', { lines: m.lines, game: GAME, server: SERVER }).then(function (r) {
494
+ return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, game: GAME, server: SERVER }).then(function (r) {
489
495
  if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
490
496
  reply(r);
491
497
  }).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
@@ -169,6 +169,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
169
169
  if (why) err(`${at}.price`, why);
170
170
  else price = numeric(it.price);
171
171
  }
172
+ if (it.interval !== undefined && (it.interval !== 'month' || chosen)) err(`${at}.interval`, 'monthly gifts need a fixed suggested amount');
172
173
  const charge = chosen ? min : price;
173
174
  if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
174
175
  if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
@@ -196,10 +197,10 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
196
197
  const advantage = it.advantage === true;
197
198
  const badge = gives.find((g) => g.startsWith('badge:'));
198
199
  for (const key of Object.keys(it)) {
199
- if (!['id', 'kind', 'name', 'blurb', 'description', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
200
+ if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
200
201
  }
201
202
  items.push({
202
- id, kind, name, ...(blurb ? { blurb } : {}),
203
+ id, kind, name, ...(it.interval === 'month' ? { interval: 'month' } : {}), ...(blurb ? { blurb } : {}),
203
204
  ...(chosen ? { price: 'choose', min, max } : { price }),
204
205
  gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
205
206
  ...(days !== null ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
@@ -236,7 +237,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
236
237
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
237
238
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
238
239
  const shop = {
239
- v: 1, policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
240
+ v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
240
241
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
241
242
  };
242
243
  // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-custom_text-submit-message
package/worker/shop.mjs CHANGED
@@ -461,7 +461,7 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
461
461
  }
462
462
 
463
463
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
464
- async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, requestAt = Date.now() }) {
464
+ async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
465
465
  lines ??= [{ item, amount, quantity: 1 }];
466
466
  const keyless = amount > 0 && !hasStripeKey(env);
467
467
  if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
@@ -480,6 +480,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
480
480
  lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
481
481
  const reserve = async () => {
482
482
  const results = await env.DB.batch([reservation(),
483
+ ...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
483
484
  ...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
484
485
  .bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
485
486
  env.DB.prepare('UPDATE shop_orders SET referral_terms = ?2, checkout_player = player WHERE id = ?1').bind(order.id, shop.referrals ? JSON.stringify(shop.referrals) : null),
@@ -564,6 +565,8 @@ function purchaseLimited(request, shop, player, kind) {
564
565
  async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
565
566
  const b = await bodyOf(request, requestSize(shop));
566
567
  if (b.error) return b.error;
568
+ const reference = b.body.reference ?? null;
569
+ if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
567
570
  const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
568
571
  if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
569
572
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
@@ -603,7 +606,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
603
606
  cookies = guest.session.cookies;
604
607
  }
605
608
  const via = await viaFor(request, env, url, shop, player);
606
- const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, requestAt });
609
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, reference, requestAt });
607
610
  const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
608
611
  if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
609
612
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
@@ -864,6 +867,7 @@ async function bindLinkSession(env, session, mode) {
864
867
  const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
865
868
  await env.DB.batch([
866
869
  env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, session, referral_terms) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, via, ?5, ?5, ?3, referral_terms FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
870
+ env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`),
867
871
  env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
868
872
  ]);
869
873
  order = await orderBySession(env, session.id);
package/worker/site.mjs CHANGED
@@ -555,6 +555,8 @@ export function sectionsOf(cat) {
555
555
  const games = (cat.games ?? []).some((g) => g.kind !== 'app');
556
556
  const apps = (cat.games ?? []).some((g) => g.kind === 'app');
557
557
  return [
558
+ ...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
559
+ cat.shop && { key: 'shop', href: '/shop/', label: cat.shop.giving ? 'Give' : 'Shop' },
558
560
  apps && { key: 'apps', href: '/apps/', label: 'Apps' },
559
561
  games && { key: 'games', href: '/games/', label: 'Games' },
560
562
  (cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
@@ -859,11 +861,11 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
859
861
  <div class="hero-copy">
860
862
  <p class="kicker">${esc(tagline || 'A Homie studio')}</p>
861
863
  <h1 class="title ${titleClass(name)}" id="hero-title">${esc(name)}</h1>
862
- <p class="line">First game coming soon.</p>
864
+ <p class="line">${['business', 'cause', 'app'].includes(cat.studio?.kind) ? 'Your app is coming soon.' : 'First game coming soon.'}</p>
863
865
  <p class="live"><span class="live-dot" aria-hidden="true"></span><span>${posts.length ? 'In the making · the latest news is below' : 'In the making'}</span></p>
864
866
  </div>
865
867
  </section>`;
866
- const next = [
868
+ const next = ['business', 'cause', 'app'].includes(cat.studio?.kind) ? [['Apps', 'Your live app', 'Open it on a phone, wall or staff tablet.'], ['Pages', 'Your own pages', 'Services, events and information for your community.']] : [
867
869
  ['Games', 'The first game', 'It opens here with a Play button: a live public room on a phone, a computer or a TV, with bots in the empty seats.'],
868
870
  ['Posts', 'News and drops', 'The studio\'s posts land on this page, with feeds to follow.'],
869
871
  ['Music and videos', 'Songs and trailers', 'Scores, songs and trailers get pages of their own.'],
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.48.4';
2
+ export const STUDIO_VERSION_TAG = '0.50.0';