@homie-rocks/studio 0.32.1 → 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 (66) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  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/studio.mjs +21 -1
  21. package/lib/template-history.json +50 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
package/worker/site.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  * What a studio puts in site/ wins: a whole page (site/pages), a partial (site/partials), its tokens
10
10
  * (site/theme.json) and CSS (site/theme.css). The build gathers them into games.json and site/dist/_site/.
11
11
  */
12
+ import { playerProperties } from './embed.mjs';
12
13
  import { isLocalOrigin, qrSvg } from './qr.mjs';
13
14
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
15
  import { basedOnRow, licenseLabel } from './license.mjs';
@@ -597,13 +598,24 @@ export function footer(cat) {
597
598
  </div></footer>`;
598
599
  }
599
600
 
600
- const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta ${k.startsWith('twitter:') ? 'name' : 'property'}="${esc(k)}" content="${esc(v)}">`).join('\n');
601
+ export const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta ${k.startsWith('twitter:') ? 'name' : 'property'}="${esc(k)}" content="${esc(v)}">`).join('\n');
602
+
603
+ /**
604
+ * Play, Watch and the player address say what a post of their address shows only when the game has a player card:
605
+ * the card's tags and the Open Graph ones beside them. Without one these pages carry no social tags, as before.
606
+ */
607
+ export function gameSocialTags(cat, g, origin, enabled = true) {
608
+ const player = enabled ? playerProperties(cat, g, { origin }) : {};
609
+ if (!player['twitter:card']) return '';
610
+ return ogTags({ 'og:type': 'website', 'og:title': g.name, 'og:description': g.blurb,
611
+ 'og:url': origin ? `${origin}/${g.id}/` : null, 'og:image': player['twitter:image'], ...player });
612
+ }
601
613
 
602
614
  /**
603
615
  * A whole generated page. `hero` pages draw the top line over the hero; `style` is the game's own accent on its
604
616
  * landing (game.json landing.theme).
605
617
  */
606
- export function layout(cat, { title, description = '', origin = '', path = '/', image = null, type = 'website', active = null, page = 'page', hero = false, head = '', ld = null, main, over = null, status = 200, extraHeaders = {} }) {
618
+ export function layout(cat, { title, description = '', origin = '', path = '/', image = null, type = 'website', active = null, page = 'page', hero = false, head = '', ld = null, main, over = null, status = 200, extraHeaders = {}, playerGame = null }) {
607
619
  const theme = cat.studio?.theme ?? {};
608
620
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
609
621
  const icon = theme.icon ? `<link rel="icon" href="${esc(theme.icon)}">` : `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="3" fill="${(over?.accent ?? theme.accent ?? TOKEN_DEFAULTS.accent)}"/></svg>`)}">`;
@@ -617,7 +629,7 @@ export function layout(cat, { title, description = '', origin = '', path = '/',
617
629
  ${description ? `<meta name="description" content="${esc(description)}">` : ''}
618
630
  ${origin ? `<link rel="canonical" href="${esc(`${origin}${path}`)}">` : ''}
619
631
  <meta name="theme-color" content="${esc(over?.bg ?? theme.bg ?? TOKEN_DEFAULTS.bg)}">
620
- ${ogTags({ 'og:title': title, 'og:description': description, 'og:type': type, 'og:url': origin ? `${origin}${path}` : null, 'og:image': abs(image ?? theme.social ?? null), 'og:site_name': studioName(cat), 'twitter:card': image || theme.social ? 'summary_large_image' : 'summary' })}
632
+ ${ogTags({ 'og:title': title, 'og:description': description, 'og:type': type, 'og:url': origin ? `${origin}${path}` : null, 'og:image': abs(image ?? theme.social ?? null), 'og:site_name': studioName(cat), 'twitter:card': image || theme.social ? 'summary_large_image' : 'summary', ...(playerGame ? playerProperties(cat, playerGame, { origin, title, description }) : {}) })}
621
633
  ${icon}${feeds}${head}${origin && ld ? ldScript(ld) : ''}
622
634
  <style>${tokensCss(theme, over)}${BASE_CSS}${cat.site?.css ?? ''}</style>
623
635
  ${partial(cat, 'head') ?? ''}
@@ -643,7 +655,16 @@ ${footer(cat)}
643
655
  * and <!-- homie:home-hero --> or <!-- homie:home-hero <id> --> (Home's hero for the featured game, or for the
644
656
  * public game it names: homeHero, below; a game that is not public, or not this studio's, leaves nothing there).
645
657
  */
646
- export function customPage(cat, html, { active = null, schema = '' } = {}) {
658
+ export function customPage(cat, html, { active = null, schema = '', playerGame = null, origin = '' } = {}) {
659
+ // An owner's hand-written Twitter metadata wins as a set. Do not partially replace it.
660
+ if (playerGame && !/<meta\b[^>]*(?:name|property)\s*=\s*(?:["']twitter:|twitter:)/i.test(html)) {
661
+ const tags = ogTags(playerProperties(cat, playerGame, { origin }));
662
+ if (tags) {
663
+ if (/<\/head\s*>/i.test(html)) html = html.replace(/<\/head\s*>/i, () => tags + '</head>');
664
+ else if (/<html\b[^>]*>/i.test(html)) html = html.replace(/<html\b[^>]*>/i, m => m + '<head>' + tags + '</head>');
665
+ else html = html.replace(/^(<!doctype[^>]*>)?/i, m => m + '<head>' + tags + '</head>');
666
+ }
667
+ }
647
668
  const out = String(html)
648
669
  .replace(/<!--\s*homie:schema\s*-->/g, () => schema)
649
670
  .replace(/<!--\s*homie:home-hero(?:\s+([a-z0-9][a-z0-9-]{0,39}))?\s*-->/g, (m, id) => {
@@ -1232,7 +1253,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1232
1253
  return layout(cat, {
1233
1254
  title: `${g.name} — play free in your browser`,
1234
1255
  description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
1235
- origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
1256
+ playerGame: listed ? g : null, origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
1236
1257
  head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
1237
1258
  // A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
1238
1259
  ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
package/worker/stripe.mjs CHANGED
@@ -76,14 +76,17 @@ export const isPermissionError = (error) => error instanceof StripeError && (err
76
76
  * The catalog Product a shop item is, the same id in a sandbox and in live mode: `homie_<studio slug>_<item id>`
77
77
  * (Stripe takes a product id of our choosing). Null without a slug or an item id.
78
78
  */
79
- export function productIdOf(slug, item) {
80
- const s = String(slug ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
81
- const i = String(item ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
82
- return s && i ? `homie_${s}_${i}` : null;
79
+ export async function productIdOf(slug, item) {
80
+ if (!slug || !item) return null;
81
+ const s = String(slug).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
82
+ if (!s) return null;
83
+ if (/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(item))) return `homie_${s}_${String(item).replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')}`;
84
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(item)));
85
+ return `homie_${s}_item_${[...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
83
86
  }
84
87
 
85
88
  /** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
86
- export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch } = {}) {
89
+ export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, timeout = 15_000 } = {}) {
87
90
  const key = String(env?.STRIPE_KEY ?? '');
88
91
  if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
89
92
  const body = params && method !== 'GET' ? formEncode(params).toString() : null;
@@ -97,7 +100,7 @@ export async function stripeCall(env, method, path, params = null, { idempotency
97
100
  ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
98
101
  },
99
102
  ...(body !== null ? { body } : {}),
100
- signal: AbortSignal.timeout(15_000),
103
+ signal: AbortSignal.timeout(timeout),
101
104
  });
102
105
  let json = null;
103
106
  try { json = await res.json(); } catch { json = null; }
@@ -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.32.1';
2
+ export const STUDIO_VERSION_TAG = '0.33.0';