@homie-rocks/studio 0.32.0 → 0.33.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 (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
@@ -324,17 +324,23 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
324
324
 
325
325
  /* -------------------------------------------------- who is playing, and signing in */
326
326
  var sheet = null;
327
+ function account() {
328
+ var href = '/account/?next=' + encodeURIComponent(window.__shell && window.__shell.returnPath ? window.__shell.returnPath() : location.pathname + location.search);
329
+ if (window.__HOMIE_PLAY.embed && window.__shell.openStudio) window.__shell.openStudio(href);
330
+ else location.href = href;
331
+ }
327
332
  function paintWho() {
328
333
  var row = document.querySelector('[data-who]');
329
334
  if (!row) return;
330
335
  var name = row.querySelector('[data-who-name]');
331
336
  var btn = row.querySelector('[data-who-act]');
332
- if (S.player && !S.player.guest) { name.textContent = S.player.name; btn.textContent = 'Account'; btn.onclick = function () { location.href = '/account/?next=' + encodeURIComponent(location.pathname + location.search); }; }
337
+ if (S.player && !S.player.guest) { name.textContent = S.player.name; btn.textContent = 'Account'; btn.onclick = function () { account(); }; }
333
338
  else { name.textContent = S.player ? S.player.name + ' (guest)' : 'Guest'; btn.textContent = 'Save my progress'; btn.onclick = function () { openSheet(''); }; }
334
339
  row.hidden = false;
335
340
  }
336
341
 
337
342
  function openSheet(reason) {
343
+ if (window.__HOMIE_PLAY.embed && window.top !== window) { account(); return; }
338
344
  if (sheet) return;
339
345
  var signedIn = S.player && !S.player.guest;
340
346
  if (signedIn) { tell('player', { player: me(), cache: snapshot(), same: true }); return; }
package/worker/schema.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  * win, and `aggregateRating`, `review` and `offers` are refused (lib/build.mjs warns). A page of the studio's own
23
23
  * (site/pages) takes `<!-- homie:schema -->` where it wants these blocks.
24
24
  */
25
+ import { currencyScale } from './shop-rules.mjs';
25
26
  import { licenseOf } from './license.mjs';
26
27
 
27
28
  const CONTEXT = 'https://schema.org';
@@ -138,14 +139,14 @@ export function shopOffers(shop, g, origin) {
138
139
  if (!/^[A-Z]{3}$/.test(currency)) return [];
139
140
  const now = Date.now();
140
141
  return shop.items
141
- .filter((i) => (!i.game || i.game === g.id) && Number.isInteger(i.price) && i.price > 0)
142
+ .filter((i) => (!i.game || i.game === g.id) && Number.isInteger(i.price) && i.price >= 0)
142
143
  .filter((i) => !(i.ends && Date.parse(i.ends) < now))
143
144
  .map((i) => ({
144
145
  '@type': 'Offer',
145
146
  name: String(i.name ?? i.id),
146
- ...(text(i.blurb, 300) ? { description: text(i.blurb, 300) } : {}),
147
+ ...(text(i.blurb, Infinity) ? { description: text(i.blurb, Infinity) } : {}),
147
148
  category: String(i.kind ?? 'item'),
148
- price: Number((i.price / 100).toFixed(2)),
149
+ price: Number((i.price / currencyScale(currency)).toFixed(2)),
149
150
  priceCurrency: currency,
150
151
  url: `${origin}/shop/?game=${encodeURIComponent(g.id)}&item=${encodeURIComponent(i.id)}`,
151
152
  availability: 'https://schema.org/OnlineOnly',
@@ -14,9 +14,11 @@
14
14
  * button is never the play button. Everything a player or the owner typed is escaped or set as text.
15
15
  */
16
16
  import { esc, layout } from './site.mjs';
17
- import { money } from './shop-rules.mjs';
17
+ import { money, ZERO_DECIMAL, ITEM_ID, currencyScale, amountStep, parseAmount } from './shop-rules.mjs';
18
18
  import { PRIVATE, shell } from './office-page.mjs';
19
19
 
20
+ const refundWords = (shop) => shop.refundDays === null ? 'Ask the studio about refunds.' : `The studio offers self-service refunds for ${shop.policy?.refundUsedItems ? 'items, including used items,' : 'unused items'} within ${shop.refundDays} days from your account page.`;
21
+
20
22
  const NOINDEX = { 'x-robots-tag': 'noindex', 'cache-control': 'no-store, private' };
21
23
 
22
24
  export const SHOP_CSS = `
@@ -45,11 +47,11 @@ export const SHOP_CSS = `
45
47
 
46
48
  /** The words for each way an item may be got (shop-rules.mjs wayFor). Plain, never pressing. */
47
49
  export const WAY_LINES = Object.freeze({
48
- 'make-an-account': 'Make an account (a passkey) first, so what you buy stays yours on every device.',
50
+ 'make-an-account': 'This studio requires an account (a passkey), so purchases stay yours on every device.',
49
51
  'age-question': 'One question first.',
50
52
  no: 'Not available on this account.',
51
- 'ask-a-parent': 'A parent or guardian can buy this for you on their own phone.',
52
- cap: 'This account has reached this month\'s limit here.',
53
+ 'ask-a-parent': 'This studio asks a parent or guardian to buy this on their own phone.',
54
+ cap: 'This account has reached the studio monthly spending limit.',
53
55
  'not-yet': 'Not on sale yet.',
54
56
  over: 'No longer on sale.',
55
57
  closed: 'The shop is not open yet.',
@@ -59,7 +61,7 @@ export const WAY_LINES = Object.freeze({
59
61
  export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
60
62
  const studio = cat.studio?.name ?? 'Studio';
61
63
  const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
62
- const boot = { game: g ? g.id : null, item: /^[a-z0-9][a-z0-9-]{0,39}$/.test(String(item ?? '')) ? item : null, studio, years: yearsList() };
64
+ const boot = { game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio, years: yearsList() };
63
65
  const main = `<section class="shop" data-shop>
64
66
  <p class="kicker">${esc(studio)}</p>
65
67
  <h1>${g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
@@ -69,17 +71,17 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
69
71
  <div data-shop-age hidden></div>
70
72
  <div class="items" data-shop-items><p>${open ? 'Reading the shop…' : 'The shop is not open yet.'}</p></div>
71
73
  <p class="msg" data-shop-msg role="status" aria-live="polite"></p>
72
- <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. An unused item can be refunded within ${shop.refundDays} days from your account page; <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.</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>
73
75
  </section>
74
76
  <script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
75
77
  <script src="/_homie/shop.js" defer></script>`;
76
78
  return layout(cat, { title: `Shop · ${studio}`, description: `Things to buy from ${studio}, in real money.`, origin, path: '/shop/', page: 'shop', head: `<style>${SHOP_CSS}</style><meta name="robots" content="noindex">`, main, extraHeaders: NOINDEX });
77
79
  }
78
80
 
79
- /** Years for the age question: this year back 100 years, NO default selected, nothing hinting which answer opens anything. */
81
+ /** Years for the age question: this year back 120 years, NO default selected, nothing hinting which answer opens anything. */
80
82
  function yearsList(now = new Date()) {
81
83
  const y = now.getUTCFullYear();
82
- return Array.from({ length: 101 }, (_, i) => y - i);
84
+ return Array.from({ length: 121 }, (_, i) => y - i);
83
85
  }
84
86
 
85
87
  export function thanksPage(cat, shop, { session = null, game = null } = {}) {
@@ -91,7 +93,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
91
93
  <h1 data-thanks-title>Thank you</h1>
92
94
  <p class="lead" data-thanks-line>Stripe is confirming the payment. This page says when it is yours (a few seconds).</p>
93
95
  <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>
94
- <p class="note">Your receipt comes from Stripe by email. Changed your mind about an unused item? Refund it from your account page within ${shop.refundDays} days.</p>
96
+ <p class="note">Your receipt comes from Stripe by email. ${refundWords(shop)}</p>
95
97
  </section>
96
98
  <script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
97
99
  <script src="/_homie/shop.js" defer></script>`;
@@ -106,13 +108,13 @@ export function refundsPage(cat, shop) {
106
108
  <h1>Refunds</h1>
107
109
  <p class="lead">${esc(studio)} sells digital items for its games through its own Stripe account. ${esc(studio)} is the seller${shop.till === 'stripe-managed' ? ' (Stripe\'s Link, LLC is the seller of record and handles sales tax; your statement reads LINK.COM*)' : ''}.</p>
108
110
  <ul>
109
- <li><b>Unused items, within ${shop.refundDays} days:</b> refund them yourself from your <a href="/account/">account page</a>. The money goes back to the card you paid with in 5 to 10 days, and the item leaves your account.</li>
111
+ <li>${refundWords(shop)} <a href="/account/">Your account</a>.</li>
110
112
  <li><b>Anything else</b> (an item you used, a problem, a purchase you did not make): ask ${esc(studio)}. The studio's owner can refund any order with one tap.</li>
111
113
  <li><b>A parent who paid for a teenager's item</b> can ask the same way.</li>
112
114
  <li><b>Disputes:</b> a card dispute never deletes or locks your account. While it is open nothing changes; if the bank decides it for you, that one item is taken back as a refund would.</li>
113
- <li>Items are delivered to your account at once. Buying one says you want it now, which ends the EU and UK 14-day right to withdraw; the refunds above still apply.</li>
115
+ <li>${shop.policy.withdrawalAcknowledgement ? 'Items are delivered to your account at once. Buying one says you want it now, which ends the EU and UK 14-day right to withdraw; the refunds above still apply.' : 'Entitlements are delivered to your account at once. The studio sets its refund terms; your statutory rights still apply.'}</li>
114
116
  </ul>
115
- <p class="note">Prices are in ${esc(shop.currency.toUpperCase())}. Tax is added at checkout where it applies. Nothing here is random: you always see exactly what you buy.</p>
117
+ <p class="note">Prices are in ${esc(shop.currency.toUpperCase())}. Tax is added at checkout where it applies. ${shop.policy?.paidRandomRewards ? '' : 'The studio policy does not offer paid random rewards.'}</p>
116
118
  </section>`;
117
119
  return layout(cat, { title: `Refunds · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style>`, main });
118
120
  }
@@ -125,12 +127,13 @@ export function parentPage(cat, shop, { gone = false, item = null, name = '', to
125
127
  else if (paid) body = `<h1>Done</h1><p class="lead">${esc(item.name)} is on ${esc(name)}'s account. Stripe sent your receipt by email.</p>`;
126
128
  else {
127
129
  body = `<h1>${esc(name)} asked you for ${esc(item.name)}</h1>
128
- <p class="lead">${esc(item.name)} costs <b>${esc(money(item.price, shop.currency))}</b>${item.days ? ` and lasts ${item.days} days` : ''}, from ${esc(studio)}${item.game ? ` (${esc((cat.games ?? []).find((g) => g.id === item.game)?.name ?? item.game)})` : ''}. ${item.blurb ? esc(item.blurb) : ''}</p>
130
+ <p class="lead">${esc(item.name)} costs <b>${esc(money(item.kind === 'tip' ? item.min : item.price, shop.currency))}</b>${item.days ? ` and lasts ${item.days} days` : ''}, from ${esc(studio)}${item.game ? ` (${esc((cat.games ?? []).find((g) => g.id === item.game)?.name ?? item.game)})` : ''}. ${item.blurb ? esc(item.blurb) : ''}</p>
129
131
  <p>It goes to the player account named <b>${esc(name)}</b> on ${esc(studio)}. You pay in your own name on Stripe's page; ${esc(studio)} never sees your card. Saying no is fine: nothing happens.</p>
130
132
  ${said ? `<p class="msg" role="alert">${esc(said)}</p>` : ''}
131
133
  ${open ? `<form method="post" style="margin-top:16px"><label style="display:flex;gap:10px;align-items:flex-start;margin:0 0 14px"><input type="checkbox" name="grownup" value="yes" style="width:22px;height:22px;margin:2px 0 0"> <span>I am ${esc(name)}'s parent or guardian, and an adult.</span></label>
132
- <button class="btn" type="submit">Pay ${esc(money(item.price, shop.currency))} on Stripe</button></form>` : '<p>The shop is not open just now. Try this link again later.</p>'}
133
- <p class="note">Unused items can be refunded within ${shop.refundDays} days: <a href="/shop/refunds/">refunds</a>.</p>`;
134
+ ${item.kind === 'tip' ? `<label>Amount in ${esc(shop.currency.toUpperCase())} <input name="amount" type="number" min="${item.min / currencyScale(shop.currency)}" step="${amountStep(shop.currency) / currencyScale(shop.currency)}" value="${item.min / currencyScale(shop.currency)}"${item.max === null ? '' : ` max="${item.max / currencyScale(shop.currency)}"`}></label>` : ''}
135
+ <button class="btn" type="submit">${item.kind === 'tip' ? 'Give' : 'Pay'} ${esc(money(item.kind === 'tip' ? item.min : item.price, shop.currency))} on Stripe</button></form>` : '<p>The shop is not open just now. Try this link again later.</p>'}
136
+ <p class="note">${refundWords(shop)} <a href="/shop/refunds/">refunds</a>.</p>`;
134
137
  }
135
138
  const main = `<section class="shop"><p class="kicker">${esc(studio)}</p>${body}</section>`;
136
139
  return layout(cat, { title: `For ${name || 'a player'} · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style><meta name="robots" content="noindex">`, main, status: gone ? 410 : 200, extraHeaders: { ...NOINDEX, 'referrer-policy': 'no-referrer' } });
@@ -153,8 +156,10 @@ export const SHOP_JS = String.raw`(function () {
153
156
  if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
154
157
  return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
155
158
  }
159
+ var units = ${parseAmount.toString()};
156
160
  function say(text, bad) { var m = $('[data-shop-msg]'); if (m) { m.textContent = text || ''; m.style.color = bad ? '#ff8a80' : ''; } }
157
161
  var q = boot.game ? '?game=' + encodeURIComponent(boot.game) : '';
162
+ if (boot.item) q += (q ? '&' : '?') + 'item=' + encodeURIComponent(boot.item);
158
163
 
159
164
  /* ---------------- /shop/ */
160
165
  var list = $('[data-shop-items]');
@@ -188,17 +193,18 @@ export const SHOP_JS = String.raw`(function () {
188
193
  if (i.days) card.appendChild(el('p', null, 'Lasts ' + i.days + ' days.'));
189
194
  if (i.ends) card.appendChild(el('p', null, 'On sale until ' + new Date(i.ends).toISOString().slice(0, 10) + '.'));
190
195
  var act = el('div', { class: 'act' });
196
+ if (i.way === 'cap' && i.retryWay) { act.appendChild(el('span', { class: 'why' }, WAY.cap)); i.way = i.retryWay; }
191
197
  if (i.way === 'checkout') {
192
198
  var amount = null;
193
199
  if (i.kind === 'tip') {
194
- amount = el('select', { 'aria-label': 'How much' });
195
- [i.min, i.min * 2, i.min * 5].filter(function (v, k, a) { return v <= i.max && a.indexOf(v) === k; }).forEach(function (v) { amount.appendChild(el('option', { value: String(v) }, (v / 100).toFixed(2) + ' ' + s.currency.toUpperCase())); });
200
+ amount = el('input', { type: 'number', 'aria-label': 'How much in ' + s.currency.toUpperCase(), min: String(i.min / s.currencyScale), step: String(s.amountStep / s.currencyScale), value: String(i.min / s.currencyScale) });
201
+ if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
196
202
  act.appendChild(amount);
197
203
  }
198
204
  var b = el('button', { type: 'button', class: 'buy' }, i.kind === 'tip' ? 'Give on Stripe' : 'Buy on Stripe');
199
205
  b.addEventListener('click', function () {
200
206
  b.disabled = true; say('Opening Stripe…');
201
- api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? Number(amount.value) : undefined }).then(function (r) {
207
+ api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? units(amount.value, s.currencyScale) : undefined }).then(function (r) {
202
208
  if (!r.ok || !r.url) throw new Error(r.message || 'That did not work.');
203
209
  location.href = r.url;
204
210
  }).catch(function (e) { b.disabled = false; say(e.message, true); });
@@ -227,10 +233,11 @@ export const SHOP_JS = String.raw`(function () {
227
233
  card.appendChild(act);
228
234
  list.appendChild(card);
229
235
  });
236
+ if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
230
237
  if (!s.items.length) list.appendChild(el('p', null, 'Nothing for sale here right now.'));
231
238
  if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
232
239
  }
233
- function load() { api('GET', '/api/shop' + q).then(draw).catch(function () { draw({ ok: false }); }); }
240
+ function load(cursor) { api('GET', '/api/shop' + q + (q ? '&' : '?') + 'cursor=' + encodeURIComponent(cursor || '')).then(draw).catch(function () { draw({ ok: false }); }); }
234
241
  if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Nothing was charged.'); load(); }
235
242
 
236
243
  /* ---------------- /shop/thanks */
@@ -269,7 +276,7 @@ export const SHOP_JS = String.raw`(function () {
269
276
  ul.appendChild(li);
270
277
  });
271
278
  body.appendChild(ul);
272
- body.appendChild(el('p', { class: 'note' }, 'Unused items can be refunded within ' + r.refundDays + ' days. Receipts come from Stripe by email.'));
279
+ body.appendChild(el('p', { class: 'note' }, (r.refundDays === null ? 'Ask the studio about refunds.' : 'The studio offers refunds for ' + (r.refundUsedItems ? 'items' : 'unused items') + ' within ' + r.refundDays + ' days.') + ' Receipts come from Stripe by email.'));
273
280
  }).catch(function () {});
274
281
  }
275
282
  })();
@@ -284,6 +291,7 @@ export const SHOP_SHELL_CSS = `
284
291
  .shopsheet h2 { margin: 0 0 4px; font-size: 19px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
285
292
  .shopsheet h2 button { background: none; border: 0; color: #aab3c7; font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; width: 40px; height: 40px; }
286
293
  .shopsheet .sub { margin: 0 0 12px; color: #aab3c7; font-size: 13px; }
294
+ .shopsheet a.buy { display: inline-flex; align-items: center; min-height: 44px; color: inherit; font-weight: 700; }
287
295
  .shopsheet .it { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: center; padding: 12px 0; border-top: 1px solid rgba(255,255,255,.1); }
288
296
  .shopsheet .it b { font-size: 15.5px; }
289
297
  .shopsheet .it .pr { font-weight: 800; }
@@ -302,7 +310,7 @@ export const SHOP_SHELL_CSS = `
302
310
  /**
303
311
  * The play shell's half of `@homie-rocks/studio/shop` (shop/SHOP.md): answers the game's `homie-shop` messages, only
304
312
  * from its own frame and only for THIS game, and shows the store sheet when the game (or the room button) asks. On a
305
- * television the sheet is only a code to scan and buy on a phone; on a kids server there is no shop at all.
313
+ * television or kids server, visibility and checkout follow the studio policy.
306
314
  */
307
315
  export const SHOP_SHELL_JS = String.raw`(function () {
308
316
  'use strict';
@@ -312,8 +320,8 @@ export const SHOP_SHELL_JS = String.raw`(function () {
312
320
  if (!frame) return;
313
321
  var GAME = boot.game;
314
322
  var SERVER = boot.server ? boot.server.id : 'public';
315
- var kids = !!(boot.server && boot.server.kids);
316
- var screen = !!boot.screen;
323
+ var kids = !!(boot.server && boot.server.kids && (!cfg || !cfg.policy.kidsServer));
324
+ var screen = !!(boot.screen && (!cfg || !cfg.policy.televisionCheckout));
317
325
  var WAY = ${JSON.stringify(WAY_LINES)};
318
326
  var owns = [];
319
327
  var lastSeen = '';
@@ -329,7 +337,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
329
337
  if (!cfg || kids || screen) return Promise.resolve(owns);
330
338
  return api('GET', '/api/player/owns' + q).then(function (r) {
331
339
  var next = (r && r.owns) || [];
332
- var key = next.slice().sort().join(',');
340
+ var key = JSON.stringify(next.slice().sort());
333
341
  if (key !== lastSeen) { var fresh = lastSeen !== '' || owns.length === 0; lastSeen = key; owns = next; post({ ev: 'owns', owns: owns, fresh: fresh }); }
334
342
  return owns;
335
343
  }).catch(function () { return owns; });
@@ -355,8 +363,16 @@ export const SHOP_SHELL_JS = String.raw`(function () {
355
363
  sheet.addEventListener('click', function (e) { if (e.target === sheet) close(); });
356
364
  document.body.appendChild(sheet);
357
365
  if (kids || !cfg) { box.appendChild(el('p', { class: 'sub' }, 'Nothing is sold here.')); return; }
366
+ // The player address, and any page shown inside another site's frame, never starts a purchase: it links to the shop.
367
+ var inFrame = false;
368
+ try { inFrame = Boolean(window.top) && window.top !== window; } catch (e) { inFrame = true; }
369
+ if (boot.embed || inFrame) {
370
+ box.appendChild(el('p', { class: 'sub' }, inFrame ? 'Purchases open on the studio’s site, outside this post.' : 'Purchases are made in the studio’s shop.'));
371
+ box.appendChild(el('a', { class: 'buy', href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener noreferrer' }, inFrame ? 'Buy on the studio’s site ↗' : 'Go to the shop'));
372
+ return;
373
+ }
358
374
  if (screen) {
359
- // The television never sells: a code to scan and buy on a phone, in the phone's own browser.
375
+ // The studio's protective TV policy: a code to scan and buy on a phone, in the phone's own browser.
360
376
  box.appendChild(el('p', { class: 'sub' }, 'Buy on your phone: scan this code. Nothing is sold on this screen.'));
361
377
  if (cfg.qr) { var qr = el('div', { class: 'qr' }); qr.innerHTML = cfg.qr; box.appendChild(qr); }
362
378
  if (cfg.url) box.appendChild(el('p', { class: 'foot' }, cfg.url.replace(/^https?:\/\//, '')));
@@ -365,14 +381,17 @@ export const SHOP_SHELL_JS = String.raw`(function () {
365
381
  box.appendChild(el('p', { class: 'sub' }, 'Real money, from the studio itself. Each purchase opens Stripe\'s own page in a new tab; your game keeps going here.'));
366
382
  var said = el('p', { class: 'said', role: 'status' });
367
383
  var listBox = el('div'); box.appendChild(listBox); box.appendChild(said);
368
- var foot = el('p', { class: 'foot' }); var all = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener' }, 'The whole shop'); foot.appendChild(all); foot.appendChild(document.createTextNode(' · unused items can be refunded from your account page.')); box.appendChild(foot);
369
- api('GET', '/api/shop' + q).then(function (s) {
384
+ var foot = el('p', { class: 'foot' }); var all = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener' }, 'The whole shop'); foot.appendChild(all); foot.appendChild(document.createTextNode(' · see the studio refund terms in the whole shop.')); box.appendChild(foot);
385
+ function loadItems(cursor) {
386
+ listBox.textContent = '';
387
+ api('GET', '/api/shop' + q + '&cursor=' + encodeURIComponent(cursor || '')).then(function (s) {
370
388
  if (!s.ok || !s.open) { listBox.appendChild(el('p', { class: 'sub' }, s.kids ? 'Nothing is sold here.' : 'The shop is not open yet.')); return; }
371
389
  s.items.forEach(function (i) {
372
390
  var row = el('div', { class: 'it' });
373
391
  row.appendChild(el('b', null, i.name)); row.appendChild(el('span', { class: 'pr' }, i.shown));
374
392
  if (i.blurb) row.appendChild(el('small', null, i.blurb));
375
393
  var go = el('div', { class: 'go' });
394
+ if (i.way === 'cap' && i.retryWay) { go.appendChild(el('span', null, WAY.cap)); i.way = i.retryWay; }
376
395
  if (i.way === 'checkout' && i.kind !== 'tip') {
377
396
  var b = el('button', { type: 'button', class: 'buy' }, 'Buy on Stripe');
378
397
  b.addEventListener('click', function () {
@@ -397,8 +416,11 @@ export const SHOP_SHELL_JS = String.raw`(function () {
397
416
  if (want && want === i.id) setTimeout(function () { row.scrollIntoView && row.scrollIntoView({ block: 'center' }); }, 0);
398
417
  listBox.appendChild(row);
399
418
  });
419
+ if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { loadItems(s.nextCursor); }; listBox.appendChild(next); }
400
420
  if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, 'Nothing for sale here right now.'));
401
421
  }).catch(function () { listBox.appendChild(el('p', { class: 'sub' }, 'The shop did not answer.')); });
422
+ }
423
+ loadItems('');
402
424
  }
403
425
  var opener = document.querySelector('[data-shop-open]');
404
426
  if (opener) opener.addEventListener('click', function () { open(null); });
@@ -419,7 +441,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
419
441
  if (m.op === 'close') { close(); return reply({ ok: true }); }
420
442
  if (m.op === 'used') {
421
443
  var key = String(m.key || '');
422
- if (!/^[a-z0-9][a-z0-9_.:-]{0,63}$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
444
+ if (!/^[^\u0000-\u001f\u007f]+$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
423
445
  return api('POST', '/api/shop/used', { key: key }).then(function (r) { reply({ ok: !!r.ok }); }).catch(function () { reply({ ok: false }); });
424
446
  }
425
447
  reply({ ok: false, error: 'op' });
@@ -439,15 +461,17 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
439
461
  if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
440
462
  return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
441
463
  }
442
- function cents(n, c) { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: String(c || 'usd').toUpperCase() }).format(n / 100); } catch (e) { return (n / 100).toFixed(2); } }
464
+ var zeroDecimal = ${JSON.stringify([...ZERO_DECIMAL])};
465
+ function cents(n, c) { var scale = zeroDecimal.indexOf(String(c || 'usd').toLowerCase()) >= 0 ? 1 : ['bhd', 'jod', 'kwd', 'omr', 'tnd'].indexOf(String(c).toLowerCase()) >= 0 ? 1000 : 100; try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: String(c || 'usd').toUpperCase(), minimumFractionDigits: scale === 1 || ['isk', 'ugx'].indexOf(c) >= 0 ? 0 : Math.log10(scale), maximumFractionDigits: scale === 1 || ['isk', 'ugx'].indexOf(c) >= 0 ? 0 : Math.log10(scale) }).format(n / scale); } catch (e) { return (n / scale).toFixed(scale === 1 ? 0 : Math.log10(scale)); } }
443
466
  function toast(t) { var x = $('#toast'); x.textContent = t; x.hidden = false; clearTimeout(toast.t); toast.t = setTimeout(function () { x.hidden = true; }, 4000); }
444
467
  function link(href, text) { var a = el('a', { href: href, target: '_blank', rel: 'noopener' }, text); return a; }
445
- function load() {
446
- api('GET', '/_studio/api/shop').then(function (s) {
468
+ function load(cursor, orderCursor) {
469
+ api('GET', '/_studio/api/shop?cursor=' + encodeURIComponent(cursor || '') + '&orderCursor=' + encodeURIComponent(orderCursor || '')).then(function (s) {
447
470
  var top = $('#state'); top.textContent = '';
448
471
  if (!s.ok) { top.appendChild(el('p', { class: 'empty' }, s.message || 'The shop did not answer.')); return; }
449
472
  top.appendChild(el('p', null, s.ready ? ('Open' + (s.mode === 'test' ? ' in TEST MODE (no real money)' : ', live') + (s.shop ? ', till: ' + s.shop.till : '')) : 'Not selling yet.'));
450
473
  if (s.missing.length) { var ul = el('ul'); s.missing.forEach(function (m) { ul.appendChild(el('li', null, m.words)); }); top.appendChild(ul); }
474
+ if (s.shop && s.shop.policy) top.appendChild(el('p', { class: 'dim' }, 'Studio policy: ' + s.shop.policy.preset + '. Edit policy and optional amounts in shop.json.'));
451
475
  var links = el('p', { class: 'links' });
452
476
  [['Payments', s.stripe.payments], ['Refunds', s.stripe.refunds], ['Disputes', s.stripe.disputes], ['Payouts', s.stripe.payouts], ['Balance', s.stripe.balance], ['Tax', s.stripe.tax]].concat(s.stripe.managedPayments ? [['Managed Payments', s.stripe.managedPayments]] : []).forEach(function (l) { links.appendChild(link(l[1], l[0])); });
453
477
  links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
@@ -460,9 +484,18 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
460
484
  row.appendChild(el('span', null, new Date(o.createdAt).toISOString().slice(0, 16).replace('T', ' ')));
461
485
  row.appendChild(el('b', null, o.name + ' · ' + o.shown));
462
486
  row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : 'a deleted account') + (o.parent ? ' (a parent paid)' : '') + (o.via ? ' · sent by ' + o.via : '')));
463
- row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '')));
487
+ row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '') + (o.note ? ' · ' + o.note : '')));
464
488
  if (o.stripe) row.appendChild(link(o.stripe, 'In Stripe'));
465
489
  if (o.dispute) row.appendChild(link(o.dispute, 'The dispute'));
490
+ if (o.releasable) {
491
+ var release = el('button', { type: 'button', class: 'ghost small' }, 'Release reservation');
492
+ release.addEventListener('click', function () {
493
+ if (!confirm('Release ' + o.name + ' (' + o.shown + ') from the cap? We ask Stripe to expire an open Checkout Session. This does not refund a payment. A payment that still arrives grants the item, counts toward spending and can be refunded from the office.')) return;
494
+ release.disabled = true;
495
+ api('POST', '/_studio/api/shop/release', { order: o.id }).then(function (r) { if (!r.ok) throw new Error(r.message || 'That did not work.'); toast('Reservation released.'); load(); }).catch(function (e) { release.disabled = false; toast(e.message); });
496
+ });
497
+ row.appendChild(release);
498
+ }
466
499
  if (o.refundable) {
467
500
  var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
468
501
  b.addEventListener('click', function () {
@@ -474,27 +507,59 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
474
507
  }
475
508
  ord.appendChild(row);
476
509
  });
510
+ if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load('', s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
477
511
  var ref = $('#referrals'); ref.textContent = '';
512
+ if (s.referrals.nextCursor) { var next = el('button', { type: 'button' }, 'Next referrers'); next.onclick = function () { load(s.referrals.nextCursor); }; ref.appendChild(next); }
513
+ (s.referrals.failures || []).forEach(function (x) { ref.appendChild(el('p', null, 'Statement failed for ' + x.via + ': ' + (x.why || x.status) + '. Start again after completion to retry.')); });
478
514
  var owe = s.referrals.owe || [];
479
515
  ref.appendChild(el('h3', null, 'You owe referrers'));
480
516
  if (!owe.length) ref.appendChild(el('p', { class: 'dim' }, 'Nothing: no sale came from another site\'s link yet.'));
481
517
  owe.forEach(function (b) {
518
+ var detail = el('details'); detail.appendChild(el('summary', null, 'Sales'));
519
+ var sales = el('div'); detail.appendChild(sales);
520
+ var more = el('button', { type: 'button' }, 'Show sales'); detail.appendChild(more);
521
+ var lineCursor = '';
522
+ more.onclick = function () {
523
+ more.disabled = true;
524
+ api('GET', '/_studio/api/shop/lines?via=' + encodeURIComponent(b.via) + '&currency=' + b.currency + '&cursor=' + encodeURIComponent(lineCursor)).then(function (page) {
525
+ sales.textContent = '';
526
+ page.lines.forEach(function (line) { sales.appendChild(el('p', null, line.date + ' · ' + line.item + ' · ' + cents(line.share, b.currency) + ' · ' + line.state)); });
527
+ lineCursor = page.nextCursor; more.hidden = !lineCursor; more.disabled = false; more.textContent = 'Next sales';
528
+ }).catch(function () { more.disabled = false; toast('Sales did not answer.'); });
529
+ };
482
530
  var row = el('div', { class: 'row' });
483
531
  row.appendChild(el('b', null, b.via));
484
- row.appendChild(el('span', null, 'due ' + cents(b.due, b.currency) + ' · held ' + cents(b.pending, b.currency) + ' · ' + b.lines.length + ' sale(s)'));
532
+ row.appendChild(el('span', null, 'due ' + cents(b.due, b.currency) + ' · held ' + cents(b.pending, b.currency) + ' · ' + b.lineCount + ' sale(s)'));
485
533
  if (b.due > 0) {
486
534
  var m = el('button', { type: 'button', class: 'ghost small' }, 'Mark paid');
487
535
  m.addEventListener('click', function () {
488
536
  var ref0 = prompt('What paid it (an invoice number, a PayPal reference)?'); if (ref0 === null) return;
489
- api('POST', '/_studio/api/shop/settle', { via: b.via, ref: ref0 }).then(function (r) { if (!r.ok) throw new Error(r.message); toast('Marked paid.'); load(); }).catch(function (e) { toast(e.message); });
537
+ api('POST', '/_studio/api/shop/settle', { via: b.via, currency: b.currency, ref: ref0 }).then(function (r) { if (!r.ok) throw new Error(r.message); toast('Marked paid.'); load(); }).catch(function (e) { toast(e.message); });
490
538
  });
491
539
  row.appendChild(m);
492
540
  }
493
- ref.appendChild(row);
541
+ ref.appendChild(row); ref.appendChild(detail);
494
542
  });
495
543
  var send = el('button', { type: 'button', class: 'ghost small' }, 'Send last month\'s signed statements');
496
- send.addEventListener('click', function () { api('POST', '/_studio/api/shop/statements/send', {}).then(function (r) { toast(r.ok ? 'Sent ' + r.sent.filter(function (x) { return x.ok; }).length + ' of ' + r.sent.length + '.' : (r.message || 'Not sent.')); }); });
497
- if (owe.length) ref.appendChild(send);
544
+ send.addEventListener('click', async function () {
545
+ send.disabled = true; send.textContent = 'Sending; keep this tab open. Close it and press again to resume safely.';
546
+ var cursor = '', period = null, sent = 0, failed = [];
547
+ try {
548
+ do {
549
+ var r = await api('POST', '/_studio/api/shop/statements/send', { cursor: cursor, period: period });
550
+ if (!r.ok) throw new Error(r.message || 'Not sent.');
551
+ sent += r.sent.filter(function (x) { return x.ok; }).length;
552
+ failed = failed.concat(r.sent.filter(function (x) { return !x.ok; }).map(function (x) { return x.via + ': ' + (x.why || x.status); }));
553
+ send.textContent = 'Sent ' + sent + ' pages. Keep open; press again after closing to resume safely.';
554
+ cursor = r.nextCursor; period = r.period;
555
+ if (r.retryAfter) { send.textContent = 'Retrying with backoff; keep open, or press again later to resume safely.'; await new Promise(function (resolve) { setTimeout(resolve, r.retryAfter * 1000); }); }
556
+ } while (cursor !== null);
557
+ if (r.failures) failed = r.failures.map(function (x) { return x.via + ': ' + (x.why || x.status); });
558
+ toast('Sent ' + sent + ' statement pages; ' + failed.length + ' failed.' + (failed.length ? ' ' + failed.join('; ') : ''));
559
+ } catch (e) { toast(e.message); }
560
+ send.disabled = false; send.textContent = 'Send last month’s signed statements';
561
+ });
562
+ ref.appendChild(send);
498
563
  ref.appendChild(el('h3', null, 'Owed to you'));
499
564
  var inn = s.referrals.owedToUs || [];
500
565
  if (!inn.length) ref.appendChild(el('p', { class: 'dim' }, 'No statements yet. When another studio sells to a player you sent, it sends a signed statement here.'));
@@ -502,6 +567,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
502
567
  var row = el('div', { class: 'row' });
503
568
  row.appendChild(el('b', null, x.seller.replace(/^https:\/\//, '')));
504
569
  row.appendChild(el('span', null, x.period + ': due ' + cents(x.due, x.currency) + ', held ' + cents(x.pending, x.currency)));
570
+ row.appendChild(link('/_studio/api/shop/received?seller=' + encodeURIComponent(x.seller) + '&period=' + encodeURIComponent(x.period), 'Signed statement pages'));
505
571
  if (x.due > 0) row.appendChild(link(s.referrals.invoice, 'Invoice them in Stripe'));
506
572
  ref.appendChild(row);
507
573
  });