@homie-rocks/studio 0.32.1 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +67 -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 +22 -1
  21. package/lib/template-history.json +90 -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
@@ -0,0 +1,106 @@
1
+ /*
2
+ * COIN DASH — the view: what a player sees, and what they press.
3
+ *
4
+ * The view draws the room it is told about and sends input. It decides nothing: the coins, the scores and the rounds
5
+ * are the rules' (src/rules.ts), which run on the server. `openRoom()` joins the room the play page names; `room.me`
6
+ * is the player's own runner, `room.each` every entity at render time, `room.on` the effects the rules emit.
7
+ *
8
+ * Canvas 2D on purpose: the point is the two halves, in a page of code each.
9
+ */
10
+ import { openRoom } from '@homie-rocks/studio/rules/view';
11
+ import { PALETTE, guardGestures } from '@homie-rocks/studio/netplay';
12
+ import type rules from './rules';
13
+
14
+ const room = openRoom<typeof rules>();
15
+ const canvas = document.getElementById('c') as HTMLCanvasElement;
16
+ const g = canvas.getContext('2d') as CanvasRenderingContext2D;
17
+ guardGestures();
18
+
19
+ /* ------------------------------------------------------------------ input: keys, or a thumb dragged from where it landed */
20
+ const keys = new Set<string>();
21
+ addEventListener('keydown', (e) => { keys.add(e.code); });
22
+ addEventListener('keyup', (e) => { keys.delete(e.code); });
23
+ addEventListener('blur', () => keys.clear());
24
+ let thumb: { id: number; x0: number; y0: number; x: number; y: number } | null = null;
25
+ canvas.addEventListener('pointerdown', (e) => { if (!thumb) thumb = { id: e.pointerId, x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY }; });
26
+ addEventListener('pointermove', (e) => { if (thumb && e.pointerId === thumb.id) { thumb.x = e.clientX; thumb.y = e.clientY; } });
27
+ const lift = (e: PointerEvent): void => { if (thumb && e.pointerId === thumb.id) thumb = null; };
28
+ addEventListener('pointerup', lift);
29
+ addEventListener('pointercancel', lift);
30
+
31
+ function stick(): { ax: number; ay: number } {
32
+ let x = (keys.has('KeyD') || keys.has('ArrowRight') ? 1 : 0) - (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0);
33
+ let y = (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0) - (keys.has('KeyS') || keys.has('ArrowDown') ? 1 : 0);
34
+ if (thumb) { x = (thumb.x - thumb.x0) / 48; y = -(thumb.y - thumb.y0) / 48; }
35
+ const l = Math.hypot(x, y);
36
+ if (l > 1) { x /= l; y /= l; }
37
+ return { ax: Math.round(x * 127), ay: Math.round(y * 127) };
38
+ }
39
+
40
+ /* ------------------------------------------------------------------ effects the rules emit */
41
+ const dings: { x: number; y: number; at: number }[] = [];
42
+ room.on('ding', (e) => { if (e.at) dings.push({ x: e.at.x, y: e.at.y, at: performance.now() }); });
43
+
44
+ /* ------------------------------------------------------------------ drawing: metres to pixels, y up */
45
+ const map = room.map;
46
+ const W = map.bounds.max.x - map.bounds.min.x;
47
+ const H = map.bounds.max.y - map.bounds.min.y;
48
+ let scale = 1; let ox = 0; let oy = 0;
49
+ const px = (x: number): number => ox + (x - map.bounds.min.x) * scale;
50
+ const py = (y: number): number => oy + (map.bounds.max.y - y) * scale;
51
+
52
+ function fit(): void {
53
+ const dpr = Math.min(2, devicePixelRatio || 1);
54
+ canvas.width = Math.round(innerWidth * dpr); canvas.height = Math.round(innerHeight * dpr);
55
+ scale = Math.min(canvas.width / (W + 1), canvas.height / (H + 3));
56
+ ox = (canvas.width - W * scale) / 2; oy = (canvas.height - H * scale) / 2 + scale * 0.6;
57
+ }
58
+ addEventListener('resize', fit);
59
+ fit();
60
+
61
+ function frame(): void {
62
+ room.input(stick());
63
+ g.fillStyle = '#0b1020'; g.fillRect(0, 0, canvas.width, canvas.height);
64
+ g.fillStyle = '#141b33'; g.fillRect(px(map.bounds.min.x), py(map.bounds.max.y), W * scale, H * scale);
65
+ g.fillStyle = '#2a3560';
66
+ for (const b of map.boxes) g.fillRect(px(b.min.x), py(b.max.y), (b.max.x - b.min.x) * scale, (b.max.y - b.min.y) * scale);
67
+ for (const c of map.circles) { g.beginPath(); g.arc(px(c.at.x), py(c.at.y), c.r * scale, 0, Math.PI * 2); g.fill(); }
68
+
69
+ room.each('coin', (c) => {
70
+ g.fillStyle = '#ffd166'; g.beginPath(); g.arc(px(c.pos.x), py(c.pos.y), 0.28 * scale, 0, Math.PI * 2); g.fill();
71
+ g.fillStyle = '#fff3c4'; g.beginPath(); g.arc(px(c.pos.x) - 0.08 * scale, py(c.pos.y) - 0.08 * scale, 0.09 * scale, 0, Math.PI * 2); g.fill();
72
+ });
73
+ const now = performance.now();
74
+ for (let i = dings.length - 1; i >= 0; i -= 1) {
75
+ const t = (now - dings[i].at) / 450;
76
+ if (t >= 1) { dings.splice(i, 1); continue; }
77
+ g.strokeStyle = `rgba(255, 209, 102, ${1 - t})`; g.lineWidth = 0.08 * scale;
78
+ g.beginPath(); g.arc(px(dings[i].x), py(dings[i].y), (0.3 + t * 0.9) * scale, 0, Math.PI * 2); g.stroke();
79
+ }
80
+ const names = new Map(room.roster.map((r) => [r.seat, r]));
81
+ g.textAlign = 'center'; g.font = `${Math.round(0.42 * scale)}px system-ui, sans-serif`;
82
+ room.each('runner', (r) => {
83
+ const x = px(r.pos.x); const y = py(r.pos.y);
84
+ g.fillStyle = PALETTE[(r.seat ?? 0) % PALETTE.length];
85
+ g.beginPath(); g.arc(x, y, 0.5 * scale, 0, Math.PI * 2); g.fill();
86
+ // A dot where the runner faces.
87
+ g.fillStyle = '#0b1020'; g.beginPath(); g.arc(x + r.heading.x * 0.28 * scale, y - r.heading.y * 0.28 * scale, 0.11 * scale, 0, Math.PI * 2); g.fill();
88
+ if (r.mine) { g.strokeStyle = '#ffffff'; g.lineWidth = 0.07 * scale; g.beginPath(); g.arc(x, y, 0.58 * scale, 0, Math.PI * 2); g.stroke(); }
89
+ g.fillStyle = r.away ? '#7c86a8' : '#e8ecff';
90
+ g.fillText(names.get(r.seat ?? -1)?.name ?? '', x, y - 0.75 * scale);
91
+ });
92
+
93
+ // The HUD: the clock (from the room's own), and the board (from the room's roster).
94
+ const round = room.round;
95
+ g.fillStyle = '#e8ecff'; g.font = `600 ${Math.round(0.7 * scale)}px system-ui, sans-serif`;
96
+ const top = oy - 0.5 * scale;
97
+ if (room.status !== 'playing') g.fillText(room.status === 'connecting' ? 'Joining the room…' : 'Coin Dash needs a connection to its room', canvas.width / 2, top);
98
+ else if (round) g.fillText(round.phase === 'live' ? `Round ${round.n} ${Math.ceil(round.secondsLeft)}` : `Round ${round.n} is over. Next in ${Math.ceil(round.secondsLeft)}`, canvas.width / 2, top);
99
+ g.textAlign = 'left'; g.font = `${Math.round(0.42 * scale)}px system-ui, sans-serif`;
100
+ [...room.roster].sort((a, b) => b.score - a.score || a.seat - b.seat).forEach((r, i) => {
101
+ g.fillStyle = PALETTE[r.seat % PALETTE.length];
102
+ g.fillText(`${r.score} ${r.name}${r.me ? ' (you)' : ''}`, ox + 0.3 * scale, oy + (0.6 + i * 0.55) * scale);
103
+ });
104
+ requestAnimationFrame(frame);
105
+ }
106
+ requestAnimationFrame(frame);
@@ -0,0 +1,5 @@
1
+ {
2
+ "public": {
3
+ "speed": { "value": 6, "min": 2, "max": 6, "step": 0.1, "unit": "m/s", "group": "Run", "note": "How fast a runner goes. Never above the body's maxSpeed in rules.ts (6): the server holds every runner to that" }
4
+ }
5
+ }
@@ -21,6 +21,17 @@
21
21
  .panel li { margin: 4px 0; color: #e7d9b4; }
22
22
  .panel li small { color: #a39a80; }
23
23
  .panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
24
+ /* A panel never runs off a short screen (a phone on its side, a small frame in somebody's post): there the new
25
+ hero's card becomes two rows at the bottom, clear of the page's own buttons along the top. While a panel is
26
+ open the touch controls (drawn over everything, .hp-touch) are not drawn over it. */
27
+ .panel { max-height: calc(100vh - 16px); overflow: auto; }
28
+ body:has(.panel:not([hidden])) .hp-touch { visibility: hidden; }
29
+ @media (max-height: 340px) {
30
+ #make { top: auto; bottom: calc(8px + env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(520px, calc(100vw - 24px)); padding: 10px 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; align-items: center; }
31
+ #make h2 { margin: 0; font-size: 16px; }
32
+ #make p { display: none; }
33
+ #make label.hc { margin: 0; font-size: 11.5px; line-height: 1.25; }
34
+ }
24
35
  /* Asking a guide: buttons (agents.json "asks"), never a text box. On a computer: bottom centre. */
25
36
  .asks { left: 50%; transform: translateX(-50%); bottom: calc(14px + env(safe-area-inset-bottom)); max-width: min(560px, calc(100vw - 200px)); box-sizing: border-box; padding: 8px 10px; border-radius: 14px; background: rgba(10,20,18,.84); border: 1px solid rgba(127,216,200,.45); text-align: center; }
26
37
  .asks .who { font: 700 12.5px/1.2 inherit; font-family: inherit; color: #7fd8c8; margin: 0 0 6px; }
package/worker/brain.mjs CHANGED
@@ -53,7 +53,8 @@ function argSpec(raw) {
53
53
  }
54
54
 
55
55
  function argsOf(raw, where, problems) {
56
- const out = {};
56
+ // No prototype: an argument name comes from outside (a model's answer, a person's ask), and "constructor" is not one.
57
+ const out = Object.create(null);
57
58
  if (raw === undefined || raw === null) return out;
58
59
  if (typeof raw !== 'object' || Array.isArray(raw)) { problems.push(`${where}: args is an object of name: type`); return null; }
59
60
  const names = Object.keys(raw);
@@ -96,7 +97,7 @@ export function vocabularyOf(raw) {
96
97
  }
97
98
  }
98
99
  const section = (key, max, each) => {
99
- const out = {};
100
+ const out = Object.create(null);
100
101
  const src = raw[key];
101
102
  if (src === undefined) return out;
102
103
  if (!src || typeof src !== 'object' || Array.isArray(src)) { problems.push(`${key} is an object of id: entry`); return out; }
@@ -173,7 +174,7 @@ export function checkArgs(specs, args, ctx = {}) {
173
174
  /** How a value reads aloud: its label in agents.json, else the value with dashes as spaces. */
174
175
  export function labelOf(vocab, value) {
175
176
  const v = String(value ?? '');
176
- return vocab?.labels?.[v] ?? v.replace(/[-_]+/g, ' ');
177
+ return vocab?.labels && Object.hasOwn(vocab.labels, v) ? vocab.labels[v] : v.replace(/[-_]+/g, ' ');
177
178
  }
178
179
 
179
180
  /**
@@ -181,7 +182,7 @@ export function labelOf(vocab, value) {
181
182
  * the guide's own name for {me}. Null when the id or its arguments do not fit.
182
183
  */
183
184
  export function renderLine(vocab, id, args = {}, { nameOf = (seat) => `Player ${Number(seat) + 1}`, me = 'Guide', view = null, players = null, kind = 'lines' } = {}) {
184
- const entry = vocab?.[kind]?.[id];
185
+ const entry = vocab?.[kind] && typeof id === 'string' && Object.hasOwn(vocab[kind], id) ? vocab[kind][id] : null;
185
186
  if (!entry) return null;
186
187
  if (checkArgs(entry.args, args, { view, players })) return null;
187
188
  return entry.text.replace(SLOT_IN_TEXT, (m, k) => (k === 'me' ? String(me) : entry.args[k]?.kind === 'player' ? String(nameOf(args[k]) ?? '') : labelOf(vocab, args[k])));
@@ -304,14 +305,14 @@ export function parseDecision(out, vocab, { view = null, players = null, avoid =
304
305
  }
305
306
  if (!o || typeof o !== 'object' || Array.isArray(o)) return bad('not an object');
306
307
  for (const k of Object.keys(o)) if (!['goal', 'args', 'say', 'sayArgs'].includes(k)) return bad(`extra field ${String(k).slice(0, 16)}`);
307
- const goal = vocab.goals[o.goal];
308
+ const goal = (typeof o.goal === 'string' && Object.hasOwn(vocab.goals, o.goal) ? vocab.goals[o.goal] : null);
308
309
  if (typeof o.goal !== 'string' || !goal) return bad('goal is not one of the vocabulary\'s');
309
310
  const ga = o.args ?? {};
310
311
  const gw = checkArgs(goal.args, ga, { view, players });
311
312
  if (gw) return bad(`goal ${o.goal}: ${gw}`);
312
313
  let say = null; let sayArgs = {};
313
314
  if (o.say !== null && o.say !== undefined && o.say !== '') {
314
- const line = vocab.lines[o.say];
315
+ const line = (typeof o.say === 'string' && Object.hasOwn(vocab.lines, o.say) ? vocab.lines[o.say] : null);
315
316
  if (typeof o.say !== 'string' || !line) return bad('say is not one of the vocabulary\'s lines');
316
317
  sayArgs = o.sayArgs ?? {};
317
318
  const lw = checkArgs(line.args, sayArgs, { view, players });
@@ -332,7 +333,7 @@ export function scripted(vocab, { asks = [], view = null, players = null, avoid
332
333
  const ctx = { view, players };
333
334
  const near = (specs, a) => Object.entries(specs).some(([k, s]) => s.kind === 'player' && avoid.includes(a[k]));
334
335
  for (const a of [...asks].sort((x, y) => (y.at ?? 0) - (x.at ?? 0))) {
335
- const ask = vocab.asks[a.k];
336
+ const ask = typeof a.k === 'string' && Object.hasOwn(vocab.asks, a.k) ? vocab.asks[a.k] : null;
336
337
  if (!ask || (!ask.goal && !ask.say)) continue;
337
338
  // Arguments carry over by name; a player argument is the player who asked.
338
339
  const fill = (specs) => Object.fromEntries(Object.entries(specs).map(([k, s]) => [k, s.kind === 'player' ? a.from : a.args?.[k]]));
@@ -527,7 +527,7 @@ export const CHAT_JS = String.raw`(function () {
527
527
  else if (!typing && r.who === 'members') n = 'Only members of this server type here.';
528
528
  if (!reacts && !watchersOut && r.react === 'signed-in' && !cfg.acct) { n = 'Sign in to chat in this room. '; link = 'Sign in'; }
529
529
  note.textContent = n;
530
- if (link) { var a = el('a', '', link); a.href = cfg.account + '?next=' + encodeURIComponent(location.pathname + location.search); a.target = surface === 'play' ? '_blank' : '_self'; note.appendChild(a); }
530
+ if (link) { var a = el('a', '', link); a.href = cfg.account + '?next=' + encodeURIComponent(window.__shell && window.__shell.returnPath ? window.__shell.returnPath() : location.pathname + location.search); a.target = surface === 'play' ? '_blank' : '_self'; note.appendChild(a); }
531
531
  note.hidden = !n;
532
532
  bubbleBox.parentNode.hidden = surface !== 'play' || !r.bubbles || mySeat() === null;
533
533
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Player cards. X's reference: developer.x.com, "Cards: player card" (archived)
3
+ * (redirects to docs.x.com/overview as checked 2026-10-08). Surviving official sample:
4
+ * https://github.com/xdevplatform/cards-player-samples/blob/main/player/page.html
5
+ * https://devcommunity.x.com/t/player-cards-improved-security-and-faster-approval-times/50033
6
+ * https://devcommunity.x.com/t/fullscreen-for-player-cards/70717
7
+ * The archived reference reserves cards for linear audio/video and excludes games. Phone apps
8
+ * open twitter:player top-level in their in-app browser. The 2026-10-08 public X web bundle
9
+ * https://abs.twimg.com/x-web/x-web/assets/article-card-DEH89WAV.js (function Ts) only frames
10
+ * YouTube/SoundCloud/Periscope; other hosts link to card_url in a new tab. Not a signed-in test.
11
+ * X documents HTTPS media players and sandboxing, NOT a supported game API, a current exhaustive
12
+ * ancestor list, or guaranteed fullscreen. The X domain families and the sandbox below are observed
13
+ * (the frame-ancestors header of the live game page https://snekarena.com/r/main, read 2026-10-08, and
14
+ * the bundle above), not a promise from X.
15
+ * No og:video/stream tag: an HTML game is not a video. Consumers of Twitter player tags can use the
16
+ * same metadata; other platforms keep the OG image (https://ogp.me/). No universal game-card standard.
17
+ */
18
+ export const PLAYER_ANCESTORS = ['https://x.com', 'https://*.x.com', 'https://twitter.com', 'https://*.twitter.com'];
19
+ export const PLAYER_SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';
20
+ /**
21
+ * The size the tags advertise: 480×480.
22
+ * - The two game pages whose posts showed a play button the week of 2026-10-08 both advertise 480×480
23
+ * (tags read as Twitterbot from https://snekarena.com/r/main and from a game page on https://www.spawn.co),
24
+ * with pictures of 1200×628 and 1200×800: X accepts a picture whose shape is not the player's.
25
+ * - X's reference says the player is scaled to the post's width at this width:height ratio. A phone's post is
26
+ * about 358 px wide: a 16:9 player there is 201 px tall, too short for a game's own dialogs and this
27
+ * shell's room sheet; a square one is 358 px tall.
28
+ * - X's web bundle (above) clamps the ratio to max(width/height, 1), so square is the tallest it shows.
29
+ * X's phone apps open the player address full screen, where this size only shapes the picture in the post.
30
+ */
31
+ export const PLAYER_SIZE = { width: 480, height: 480 };
32
+ /**
33
+ * What X's reference says a card picture must be for the card to render: JPG, PNG, WEBP or GIF, at least 68,600
34
+ * pixels (262×262, or 350×196), under 5 MB. It also says the picture "should" have the player's dimensions:
35
+ * advice, which the build passes on as a warning and never as a refusal (lib/player-image.mjs).
36
+ */
37
+ export const PLAYER_IMAGE = { formats: ['jpeg', 'png', 'webp', 'gif'], minPixels: 68600, maxBytes: 5000000 };
38
+ export const playerImageRule = (m) => (!m || !PLAYER_IMAGE.formats.includes(m.format) ? 'format'
39
+ : !(m.width * m.height >= PLAYER_IMAGE.minPixels) ? 'pixels' : !(m.bytes < PLAYER_IMAGE.maxBytes) ? 'bytes' : null);
40
+
41
+ /** These are switches, not plan limits. TV-only games must declare their lack of a single-screen view. */
42
+ export function playerEnabled(cat, game) {
43
+ return cat?.studio?.site?.playerCard !== false && game?.playerCard !== false &&
44
+ game?.screen?.singleScreen !== false && !['private', 'invite'].includes(game?.launch);
45
+ }
46
+
47
+ /** Exact HTTPS origins, or an HTTPS DNS wildcard, never arbitrary CSP text. An explicit [] denies all. */
48
+ export function playerOrigins(value) {
49
+ if (!Array.isArray(value)) return [...PLAYER_ANCESTORS];
50
+ const origins = [];
51
+ for (const v of value) {
52
+ if (typeof v !== 'string' || !/^https:\/\/(?:\*\.)?[a-z0-9.-]+(?::[0-9]+)?$/i.test(v)) continue;
53
+ try {
54
+ const wildcard = v.includes('*.');
55
+ const u = new URL(v.replace('*.', ''));
56
+ const labels = u.hostname.split('.');
57
+ if (labels.length < 2 || labels.some(l => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(l))) continue;
58
+ // A wildcard must name a domain, never a top-level suffix.
59
+ if (wildcard && (labels.length < 2 || /^(?:co|com|net|org)\.[a-z]{2}$/.test(u.hostname))) continue;
60
+ const origin = 'https://' + (wildcard ? '*.' : '') + u.host;
61
+ if (!origins.includes(origin)) origins.push(origin);
62
+ if (origins.length === 8) break;
63
+ } catch {}
64
+ }
65
+ return origins;
66
+ }
67
+ export function playerAncestors(cat) {
68
+ return playerOrigins(cat?.studio?.site?.playerCardOrigins).join(' ') || "'none'";
69
+ }
70
+ export function httpsAddress(value, origin) {
71
+ try { const u = new URL(value, origin); return u.protocol === 'https:' && !u.username && !u.password ? u.href : null; } catch { return null; }
72
+ }
73
+ export function playerProperties(cat, game, { origin = '', title = game?.name, description = game?.blurb } = {}) {
74
+ const player = httpsAddress(`/${game?.id}/play/embed`, origin);
75
+ const measured = game?.playerImage;
76
+ const picture = measured && httpsAddress(measured.src, origin);
77
+ // twitter:site goes out when the studio has set its handle; a card is never withheld for the lack of one
78
+ // (neither live game page above carries it).
79
+ const handle = /^@[A-Za-z0-9_]{1,15}$/.test(cat?.studio?.site?.twitterSite || '') ? cat.studio.site.twitterSite : null;
80
+ if (!playerEnabled(cat, game) || !player || !picture || playerImageRule(measured)) return {};
81
+ return { ...(handle ? { 'twitter:site': handle } : {}), 'twitter:card': 'player', 'twitter:player': player, 'twitter:player:width': PLAYER_SIZE.width,
82
+ 'twitter:player:height': PLAYER_SIZE.height, 'twitter:image': picture, 'twitter:title': title,
83
+ 'twitter:description': String(description || `Play ${game.name} in your browser.`).slice(0, 200) };
84
+ }
85
+
86
+ /** Browser restrictions belong to the outer host; failures must not kill a game's input loop.
87
+ * MDN, "iframe: sandbox"
88
+ * MDN, "Permissions Policy"
89
+ * Used only for the embedded instance, ahead of the game's scripts. Existing port/early provides
90
+ * storage/cookie shims for imported games; netplay and the shell already guard their own storage.
91
+ */
92
+ export const EMBED_GAME_JS = String.raw`(function () {
93
+ function unavailable(feature) { parent.postMessage({t:'homie-net',what:'embed-unavailable',feature:feature}, '*'); }
94
+ function guarded(proto, name, feature) {
95
+ var real = proto && proto[name];
96
+ if (typeof real !== 'function') return;
97
+ try { proto[name] = function () {
98
+ try { var result = real.apply(this, arguments); return result && typeof result.catch === 'function' ? result.catch(function () { unavailable(feature); }) : result; }
99
+ catch (e) { unavailable(feature); return Promise.resolve(); }
100
+ }; } catch (e) {}
101
+ }
102
+ guarded(Element.prototype, 'requestPointerLock', 'pointer lock');
103
+ guarded(Element.prototype, 'requestFullscreen', 'fullscreen');
104
+ document.addEventListener('pointerlockerror', function () { unavailable('pointer lock'); });
105
+ document.addEventListener('fullscreenerror', function () { unavailable('fullscreen'); });
106
+ var pads = navigator.getGamepads;
107
+ if (pads) try { Object.defineProperty(navigator, 'getGamepads', { configurable:true, value:function () {
108
+ try { return pads.call(navigator); } catch (e) { if (!pads.warned) { pads.warned = true; unavailable('gamepad'); } return []; }
109
+ }}); } catch (e) {}
110
+ // Keep early requests pending and replay them on the first real press. A game that asks only once
111
+ // must start too; resolving a no-op resume/play loses that request.
112
+ var pressed = false, pending = [];
113
+ function afterPress(run) {
114
+ if (pressed) return run();
115
+ return new Promise(function (resolve, reject) { pending.push(function () { run().then(resolve, reject); }); });
116
+ }
117
+ ['AudioContext','webkitAudioContext'].forEach(function (name) {
118
+ var AC = window[name]; if (!AC) return;
119
+ try { window[name] = new Proxy(AC, { construct:function (ctor,args) {
120
+ var ctx = Reflect.construct(ctor,args), resume = ctx.resume.bind(ctx);
121
+ if (!pressed) ctx.suspend().catch(function () {});
122
+ ctx.resume = function () { return afterPress(resume); };
123
+ return ctx;
124
+ }}); } catch (e) {}
125
+ });
126
+ ['pointerdown','keydown','touchstart'].forEach(function (name) { addEventListener(name,function () {
127
+ if (pressed) return;
128
+ pressed = true; var queued = pending; pending = []; queued.forEach(function (run) { run(); });
129
+ },{capture:true,once:true}); });
130
+ var play = HTMLMediaElement.prototype.play;
131
+ HTMLMediaElement.prototype.play = function () {
132
+ var media = this;
133
+ return afterPress(function () { return play.call(media); });
134
+ };
135
+ }());`;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Server-hosted games: the rules this studio's build put into its Worker, and the host runtime a Table runs for one
3
+ * room of such a game (NETPLAY.md section 29; rules/host.ts is the runtime, worker/room.mjs the relay it serves).
4
+ *
5
+ * `homie-studio build` writes every server-hosted game's rules, guarded (lib/rules-guard.mjs), into the studio's
6
+ * site/src/rules/, with a table of them (site/src/rules/index.mjs): per game its rules module, its tunables, its map,
7
+ * its settings and its seats. The studio's site/src/worker.mjs hands that table over once, when the Worker loads:
8
+ *
9
+ * import { hostRules } from '@homie-rocks/studio/worker';
10
+ * import rules from './rules/index.mjs';
11
+ * hostRules(rules);
12
+ *
13
+ * Only the studio's own games are in it: no rules from outside the studio run on its server. The rules run in this
14
+ * Worker's isolate, behind the build's wall and the runtime's budget, until rules get an isolate of their own.
15
+ */
16
+ import { createHost } from '../rules/host.ts';
17
+ import { compileMap, compileRules } from '../rules/rules.ts';
18
+
19
+ /** game id → { rules, tune, map, settings, seats, build } as the build wrote it, and what compiling each gave. */
20
+ const games = new Map();
21
+ const compiled = new Map();
22
+
23
+ /** The table of this studio's server-hosted games (the build's site/src/rules/index.mjs). Called once, at load. */
24
+ export function hostRules(table) {
25
+ games.clear();
26
+ compiled.clear();
27
+ if (!table || typeof table !== 'object') return 0;
28
+ for (const [id, entry] of Object.entries(table)) if (entry && typeof entry === 'object' && entry.rules) games.set(id, entry);
29
+ return games.size;
30
+ }
31
+
32
+ /** Whether this Worker holds rules for a game. */
33
+ export const hostedGame = (id) => games.has(id);
34
+ export const hostedBuild = (id) => games.get(id);
35
+
36
+ /** A game's rules, compiled once per isolate. Throws, naming the declaration, when they do not fit the contract. */
37
+ export function compiledFor(id) {
38
+ if (compiled.has(id)) return compiled.get(id);
39
+ const e = games.get(id);
40
+ if (!e) throw new Error('this build of the site holds no rules for this game');
41
+ const c = compileRules(e.rules, { tune: e.tune, map: compileMap(e.map, e.map?.name ?? 'main'), settings: e.settings, seats: e.seats });
42
+ compiled.set(id, c);
43
+ return c;
44
+ }
45
+
46
+ /**
47
+ * The host runtime for one room. `send(frame, text)` is the relay's `hostFrame`; the clock is the Worker's own, and the
48
+ * tick loop a timer chain inside the object. The Table supplies its synchronous store and recovery settings.
49
+ */
50
+ export function startHost(game, { send, log, onPause, onResume, onEnd, ...recovery }) {
51
+ return createHost({
52
+ game, build: games.get(game)?.build ?? undefined, compiled: compiledFor(game), send, log, onPause, onResume, onEnd,
53
+ clock: { now: () => Date.now(), setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h) },
54
+ ...recovery,
55
+ });
56
+ }