@homie-rocks/studio 0.31.0 → 0.32.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 (42) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +44 -6
  3. package/bin/homie-studio.mjs +101 -8
  4. package/lib/build.mjs +1 -1
  5. package/lib/cloudflare.mjs +80 -6
  6. package/lib/directory.mjs +61 -9
  7. package/lib/doctor.mjs +23 -10
  8. package/lib/mcp-tools.mjs +18 -11
  9. package/lib/mcp.mjs +2 -0
  10. package/lib/parts.mjs +27 -0
  11. package/lib/routes.mjs +110 -2
  12. package/lib/scaffold.mjs +3 -3
  13. package/lib/standalone-cli.mjs +106 -0
  14. package/lib/standalone-files.mjs +360 -0
  15. package/lib/standalone-icons.mjs +106 -0
  16. package/lib/standalone-lock.json +3404 -0
  17. package/lib/standalone-pins.json +18 -0
  18. package/lib/standalone-tools.mjs +63 -0
  19. package/lib/standalone.mjs +868 -0
  20. package/lib/template-history.json +80 -0
  21. package/netplay/NETPLAY.md +148 -10
  22. package/netplay/netplay.ts +234 -33
  23. package/package.json +2 -1
  24. package/parts/PARTS.md +8 -2
  25. package/port/index.ts +1 -1
  26. package/port/room.ts +228 -23
  27. package/standalone/STANDALONE.md +313 -0
  28. package/standalone/electron/files.cjs +50 -0
  29. package/standalone/electron/main.cjs +139 -0
  30. package/standalone/electron/pack.mjs +82 -0
  31. package/standalone/shell/index.html +36 -0
  32. package/standalone/shell/shell.css +46 -0
  33. package/standalone/shell/shell.js +346 -0
  34. package/starters/ember-vale/src/main.ts +4 -0
  35. package/starters/gem-rush/src/main.ts +29 -7
  36. package/starters/gem-rush-3d/src/main.ts +36 -7
  37. package/starters/hero-rush-3d/src/main.ts +36 -7
  38. package/worker/index.mjs +55 -8
  39. package/worker/pages.mjs +19 -10
  40. package/worker/parts.mjs +43 -2
  41. package/worker/standalone.mjs +34 -0
  42. package/worker/version.mjs +1 -1
@@ -49,6 +49,13 @@
49
49
  * byte). A model that is refused or not there is drawn as a stand-in in the same colours: a round never waits on, or
50
50
  * breaks for, a model.
51
51
  *
52
+ * HEADINGS, AND THERE ARE TWO: a body's facing (`fa`, drawn as `yaw`) is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
53
+ * (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
54
+ * `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
55
+ * package takes a body's facing as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
56
+ * `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
57
+ * `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
58
+ *
52
59
  * PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
53
60
  * renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
54
61
  * disc under each body, coin, tree and rock (a computer draws the sun's real shadows). Repeated things are one
@@ -70,7 +77,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
70
77
  import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
71
78
  // Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
72
79
  import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
73
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
80
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
74
81
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
75
82
  // labels that never pile up (port/view.ts).
76
83
  import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -246,9 +253,12 @@ function newZone(n: number): Zone {
246
253
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
247
254
 
248
255
  /** A round starts with every hero on its spawn ring facing the camera (fa 0): a line-up the hero intro opens on. */
249
- function startRound(n: number): void {
256
+ function startRound(n: number, rollover = false): void {
250
257
  const now = net.now();
251
258
  roster.trim();
259
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
260
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
261
+ if (rollover && !net.offline) seatWaiting();
252
262
  syncBodiesFromRoster();
253
263
  let i = 0;
254
264
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -369,16 +379,31 @@ net.on('role', (e) => {
369
379
  if (e.role === 'host') becomeHost(e);
370
380
  else { hosting = false; }
371
381
  });
372
- net.on('join', (p) => {
373
- if (!hosting || p.seat === null) return;
382
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
383
+ function giveBody(p: Peer): boolean {
384
+ if (p.seat === null) return false;
374
385
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
375
386
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
376
- if (!c) return; // full: they watch
387
+ if (!c) return false;
377
388
  welcome(p.seat);
378
389
  syncBodiesFromRoster();
379
390
  const b = bodies.get(c.slot.slot);
380
391
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
381
392
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
393
+ return true;
394
+ }
395
+ /**
396
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
397
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
398
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
399
+ */
400
+ function seatWaiting(): boolean {
401
+ let any = false;
402
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
403
+ return any;
404
+ }
405
+ net.on('join', (p) => {
406
+ if (!hosting || !giveBody(p)) return;
382
407
  publishRoster();
383
408
  net.snapshot(buildSnap(), tick, true);
384
409
  });
@@ -387,7 +412,10 @@ net.on('leave', (p) => {
387
412
  roster.release(p.seat); // their body stays, driven by a bot
388
413
  firstRound.delete(p.seat); // whoever sits in this seat next is new here
389
414
  syncBodiesFromRoster();
415
+ // A body just went to a bot: somebody seated with none takes it now.
416
+ const seated = seatWaiting();
390
417
  publishRoster();
418
+ if (seated) net.snapshot(buildSnap(), tick, true);
391
419
  });
392
420
  // The server's policy changed: its AI seats come (or go between rounds) at once.
393
421
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -889,7 +917,7 @@ function stepHost(dt: number): void {
889
917
  }
890
918
  }
891
919
  if (now >= round.endsAt) endRound();
892
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
920
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
893
921
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
894
922
  }
895
923
 
@@ -2482,7 +2510,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2482
2510
  ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
2483
2511
  });
2484
2512
  }
2485
- if (!net.offline && !net.connected && net.role !== 'host') {
2513
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
2514
+ if (net.link === 'reconnecting' && net.role !== 'host') {
2486
2515
  ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
2487
2516
  const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
2488
2517
  chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
package/worker/index.mjs CHANGED
@@ -23,7 +23,8 @@
23
23
  * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
24
24
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
25
25
  * /<game>/__watch?room= the room's facts, for the shell
26
- * /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server
26
+ * /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server;
27
+ * the one answer a standalone copy's app may read (worker/standalone.mjs)
27
28
  * /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
28
29
  * /<game>/servers/ the game's servers (worker/servers.mjs): named, lasting room pools with their own
29
30
  * policy (open, humans-only, hybrid, beginner) and door; /<game>/api/servers as JSON
@@ -83,6 +84,7 @@
83
84
  * it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
84
85
  */
85
86
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
87
+ import { appCors, isAppOrigin } from './standalone.mjs';
86
88
  import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
87
89
  import {
88
90
  PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
@@ -576,7 +578,16 @@ function finish(res, path) {
576
578
  export default {
577
579
  async fetch(request, env, ctx) {
578
580
  const url = new URL(request.url);
579
- const res = finish(await route(request, env, ctx), url.pathname);
581
+ // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
582
+ // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
583
+ const appLobby = isAppOrigin(request) && /^\/[^/]+\/api\/lobby\/?$/.test(url.pathname);
584
+ let res;
585
+ try { res = finish(await route(request, env, ctx), url.pathname); } catch (error) {
586
+ if (!appLobby) throw error;
587
+ try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
588
+ res = json({ ok: false, error: 'failed' }, 500);
589
+ }
590
+ if (appLobby) return appCors(request, res);
580
591
  // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
581
592
  // that pays referrals. The page is answered first; a cookie is added to it only then.
582
593
  if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
@@ -998,9 +1009,14 @@ async function route(request, env, ctx) {
998
1009
  return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
999
1010
  }
1000
1011
  if (sub === 'api/lobby') {
1012
+ // A standalone copy's page reads this answer from another origin: the Worker's own `fetch` adds its header to
1013
+ // every answer of this address (worker/standalone.mjs), whichever line here made it.
1014
+ const app = isAppOrigin(request);
1001
1015
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1002
1016
  // ?server=<id>: a room of that server's pool (its door first); none: public. Strangers never meet across servers.
1003
1017
  const sid = url.searchParams.get('server') ?? 'public';
1018
+ // An app plays on the public server only: it has no account and no invite to show a server's door.
1019
+ if (app && sid !== 'public') return json({ ok: false, error: 'app-public', message: 'A standalone copy plays on the public server.' }, 403);
1004
1020
  const srv = SERVER_ID.test(sid) || sid === 'public' ? serverById(sid) : null;
1005
1021
  if (!srv) return json({ ok: false, error: 'no-server', message: `${meta.name} has no server called ${String(sid).slice(0, 24)}.` }, 404);
1006
1022
  if (srv.id === 'public' && publicHidden() && !(await pageDoor(srv)).owner) return json({ ok: false, error: 'closed', message: 'Pick a server to play on.' }, 403);
@@ -1010,8 +1026,11 @@ async function route(request, env, ctx) {
1010
1026
  const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
1011
1027
  const pol = policyFor(srv);
1012
1028
  // Strangers on different builds of the game never meet (section 23): the Lobby matches within the live build.
1013
- const ver = versionOf(meta?.netplay?.version);
1014
- return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}`, { method: 'POST' });
1029
+ // A standalone copy is the build it was made from, whatever is live now: it says its own (`gv`, or none for a
1030
+ // game that named none then) and is matched with copies of that build. What it says is its own word, so the
1031
+ // Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
1032
+ const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
1033
+ return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
1015
1034
  }
1016
1035
  if (sub === 'api/agent') {
1017
1036
  // An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
@@ -1700,6 +1719,11 @@ export class Table {
1700
1719
  * room, where it waits as a spectator and is seated when a seat frees (`full: true`). An AI (`agent=1`) is only ever
1701
1720
  * sent to a room with people in it; it never opens one. Players are people: agents are reported apart.
1702
1721
  */
1722
+ /** How many builds of a game besides the live one may have Lobby rooms on one server at once (standalone copies name their own). */
1723
+ export const LOBBY_APP_VERSIONS = 8;
1724
+ /** How many rooms a game's Lobby keeps in all. Its list is one stored value: 512 rooms is about a third of what one may hold. */
1725
+ export const LOBBY_ROOMS_MAX = 512;
1726
+
1703
1727
  export class Lobby {
1704
1728
  constructor(ctx, env) {
1705
1729
  this.ctx = ctx;
@@ -1779,8 +1803,15 @@ export class Lobby {
1779
1803
  const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
1780
1804
  for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
1781
1805
  // Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
1782
- const ofServer = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
1806
+ const everyRoom = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server);
1807
+ const ofServer = everyRoom.filter((r) => !not.has(r.name) && !this.closed.has(r.name));
1783
1808
  const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
1809
+ // A standalone copy names its own build (the Worker says `app=1`), and anybody can claim to be one: only a few
1810
+ // builds besides the live one ever have rooms here, so a caller that invents a build a request makes no pool.
1811
+ const app = url.searchParams.get('app') === '1';
1812
+ if (app && !everyRoom.some((r) => (r.ver ?? '') === ver) && new Set(everyRoom.map((r) => r.ver ?? '')).size > LOBBY_APP_VERSIONS) {
1813
+ return json({ ok: false, room: null, error: 'versions', message: 'Too many versions of this game are being played at once: play offline for now.' }, 503);
1814
+ }
1784
1815
  if (url.searchParams.get('agent') === '1') {
1785
1816
  // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
1786
1817
  const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
@@ -1802,11 +1833,27 @@ export class Lobby {
1802
1833
  }
1803
1834
  let room = best?.r;
1804
1835
  let full = false;
1805
- if (!room && server !== 'public' && pool.length >= roomsMax) {
1806
- // Every room this server may have is full: the fullest one, where the visitor waits for a seat.
1807
- room = pool.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), pool[0]);
1836
+ const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
1837
+ if (!room && server !== 'public' && everyRoom.length >= roomsMax) {
1838
+ // Every room this server may have exists, whatever build each runs: the fullest one of this build, where the
1839
+ // visitor waits for a seat; with none of this build, the fullest of the server (its players are told a newer
1840
+ // build is live, and the visitor is let in when they have it). Never a room past the server's own number.
1841
+ const from = pool.length ? pool : ofServer;
1842
+ if (!from.length) return json({ ok: false, room: null, error: 'full', message: 'Every room of this server is taken.' }, 503);
1843
+ room = fullest(from);
1808
1844
  full = true;
1809
1845
  }
1846
+ if (!room && this.rooms.size >= LOBBY_ROOMS_MAX) {
1847
+ // The Lobby keeps a bounded list (it is one stored value): rooms nobody is seated in go first, the oldest
1848
+ // first. When every room has people in it, nobody gets a new one: this build's fullest, or no room at all.
1849
+ const empty = [...this.rooms.values()].filter((r) => r.players === 0).sort((a, b) => a.at - b.at);
1850
+ for (const r of empty) { if (this.rooms.size < LOBBY_ROOMS_MAX) break; this.rooms.delete(r.name); }
1851
+ if (this.rooms.size >= LOBBY_ROOMS_MAX) {
1852
+ if (!pool.length) return json({ ok: false, room: null, error: 'busy', message: 'Every room is taken right now.' }, 503);
1853
+ room = fullest(pool);
1854
+ full = true;
1855
+ }
1856
+ }
1810
1857
  if (!room) {
1811
1858
  let n = this.next[server] ?? 1;
1812
1859
  while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
package/worker/pages.mjs CHANGED
@@ -455,6 +455,9 @@ const SHELL_JS = String.raw`(function () {
455
455
  if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
456
456
  // The link (section 22): in the room, knocking again, or playing alone because the room never answered.
457
457
  if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
458
+ // Every seat is taken and this browser asked to play (the room's own word, in its welcome): it watches until one
459
+ // frees up, and the chip says so. Not full any more once it has been seated, or plays by itself.
460
+ if (m.what === 'full') state.full = m.full === true;
458
461
  // The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
459
462
  // arrival should be 'game'. A performance probe reads both here.
460
463
  if (m.what === 'ready') {
@@ -469,11 +472,13 @@ const SHELL_JS = String.raw`(function () {
469
472
  if (m.final === true || screenMode) reloadGame();
470
473
  else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
471
474
  }
472
- if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
475
+ if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; if (typeof m.seat === 'number') state.full = false; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
473
476
  if (m.what === 'stats') state.stats = m.stats;
474
477
  if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
475
478
  if (m.what === 'roster') state.roster = m.slots;
476
- if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
479
+ // Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
480
+ // with its own words, because "try another room" is the only thing a player can do about it.
481
+ if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
477
482
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
478
483
  if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
479
484
  paint();
@@ -520,8 +525,10 @@ const SHELL_JS = String.raw`(function () {
520
525
  var next = {};
521
526
  Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
522
527
  if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
523
- var how = prefsWrite(next);
524
- if (how === 'too-large') out.why = how; else { out.ok = true; out.kept = how; }
528
+ // A NaN or an Infinity would be written as null and read back as a setting (a volume of null plays as 0):
529
+ // refused, whatever helper sent it.
530
+ var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
531
+ if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
525
532
  } else out.why = 'op';
526
533
  } catch (e) { out.why = 'error'; }
527
534
  state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
@@ -656,8 +663,8 @@ const SHELL_JS = String.raw`(function () {
656
663
  var label = labelOf(state.room || '');
657
664
  var box = document.createElement('div'); box.className = 'notice'; box.setAttribute('data-notice', kind); box.setAttribute('role', 'alertdialog'); box.setAttribute('data-keep-focus', '');
658
665
  var inner = document.createElement('div'); inner.className = 'box';
659
- var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : 'This room is closed';
660
- var p = document.createElement('p'); p.textContent = state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
666
+ var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : kind === 'full' ? 'This room is full' : 'This room is closed';
667
+ var p = document.createElement('p'); p.textContent = kind === 'full' ? label + ' has no place left, to play or to watch. Another room is one tap away.' : state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
661
668
  var w = document.createElement('p'); w.className = 'when';
662
669
  w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
663
670
  var acts = document.createElement('div'); acts.className = 'acts';
@@ -666,7 +673,7 @@ const SHELL_JS = String.raw`(function () {
666
673
  acts.append(other, back);
667
674
  inner.append(h, p, w, acts); box.appendChild(inner);
668
675
  document.body.appendChild(box);
669
- say(kind === 'kicked' ? 'removed from this room' : 'room closed');
676
+ say(kind === 'kicked' ? 'removed from this room' : kind === 'full' ? 'room full' : 'room closed');
670
677
  }
671
678
 
672
679
  function paint() {
@@ -680,10 +687,12 @@ const SHELL_JS = String.raw`(function () {
680
687
  if (aiN) bits.push(aiN + ' AI');
681
688
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
682
689
  if (state.full) bits.push('waiting for a seat');
683
- if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
684
- // The link (section 22): cut off from the room and knocking again, or playing alone because it never answered.
690
+ // Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
691
+ if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
692
+ // The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
693
+ // playing by itself because the room never answered, or did not come back in time.
685
694
  else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
686
- else if (state.link && state.link.state === 'alone') bits.push('offline, reconnecting');
695
+ else if (state.link && state.link.state === 'alone') bits.push(state.link.why === 'reconnect-timeout' ? 'playing on your own: the room dropped' : 'playing on your own: looking for the room');
687
696
  // The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
688
697
  if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
689
698
  say(bits.join(' · ') || 'joining…');
package/worker/parts.mjs CHANGED
@@ -112,7 +112,13 @@ const CSS = `<style>
112
112
  .pcard h3{margin:0;font:800 19px/1.2 var(--display)}
113
113
  .pcard p{margin:0;color:var(--dim);font-size:14px}
114
114
  .ptag{font:600 11px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
115
- .pframe{width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
115
+ .pframe{display:block;width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
116
+ .pview{container-type:inline-size}
117
+ .pview .pframe{aspect-ratio:var(--pv,16/9)}
118
+ @container (max-width:599.98px){.pview .pframe{min-height:min(320px,70vh);min-height:min(320px,70svh)}.pview.pview-own .pframe{aspect-ratio:var(--pvn,var(--pv,16/9));min-height:0}}
119
+ .popen{display:inline-flex;align-items:center;gap:8px;min-height:44px;margin:8px 0 0;color:inherit;font-weight:600;text-decoration:underline;text-underline-offset:3px}
120
+ .popen svg{width:16px;height:16px}
121
+ .popen:focus-visible{outline:3px solid var(--fg);outline-offset:3px;border-radius:6px}
116
122
  .pfacts{display:grid;grid-template-columns:max-content 1fr;gap:8px 20px;margin:0}
117
123
  .pfacts dt{color:var(--dim);font-size:14px}.pfacts dd{margin:0;overflow-wrap:anywhere}
118
124
  .pband{max-width:calc(1180px + 2 * var(--gutter));margin:0 auto;padding:clamp(40px,6vw,80px) var(--gutter)}
@@ -165,10 +171,45 @@ function costLine(c) {
165
171
  return `${bits.join(' · ')}${c.measuredOn ? ` (measured on ${String(c.measuredOn).slice(0, 80)})` : ''}`;
166
172
  }
167
173
 
174
+ /**
175
+ * A preview's declared shape ("4:3") as two whole numbers, or null. The same reading as previewAspect() in
176
+ * lib/parts.mjs (a Worker cannot import that file: it reads the disk); test/parts.test.mjs holds the two together.
177
+ * The page's CSS is built from these NUMBERS, never from the text in part.json, so nothing a part declares can
178
+ * write CSS or HTML of its own.
179
+ */
180
+ export function aspectOf(value) {
181
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
182
+ if (!m) return null;
183
+ const w = Number(m[1]); const h = Number(m[2]);
184
+ return w > 32 || h > 32 || w > 3 * h || h > 2 * w ? null : { w, h };
185
+ }
186
+
187
+ /**
188
+ * The part's interactive preview: its frame in the shape the part declares, and a plain link out of the frame.
189
+ *
190
+ * THE SHAPE. `preview.aspect` (default 16:9), and `preview.phoneAspect` when the frame is under 600 px wide. With no
191
+ * phone shape declared a narrow frame still gets a minimum height: 16:9 across a 390 px phone is about 197 px,
192
+ * which hides whatever a preview draws under its heading and controls.
193
+ *
194
+ * THE LINK opens the same preview file in a tab of its own, at the size of the screen. That file is this studio's
195
+ * own packed part on this studio's own address, and it is answered with `content-security-policy: sandbox
196
+ * allow-scripts allow-pointer-lock` (above), so a tab of its own gives it nothing the frame did not: no origin, so
197
+ * none of the site's cookies or storage, no forms, no pop-ups, no navigating the page that opened it (and
198
+ * rel="noopener" gives it no handle on that page either). It is a link, so it needs no script and a keyboard reaches it.
199
+ */
200
+ function previewOf(p) {
201
+ const src = esc(`${p.url}${p.preview.page}`);
202
+ const shape = aspectOf(p.preview.aspect) ?? { w: 16, h: 9 };
203
+ const phone = aspectOf(p.preview.phoneAspect);
204
+ const vars = `--pv:${Number(shape.w)}/${Number(shape.h)}${phone ? `;--pvn:${Number(phone.w)}/${Number(phone.h)}` : ''}`;
205
+ return `<div class="pview${phone ? ' pview-own' : ''}" style="${vars}"><iframe class="pframe" title="${esc(p.name)}: try it" src="${src}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe></div>
206
+ <a class="popen" href="${src}" target="_blank" rel="noopener noreferrer">${icon('arrow')}<span>Open the preview in its own tab</span></a>`;
207
+ }
208
+
168
209
  export function partPage(cat, p, url) {
169
210
  const name = studioOf(cat);
170
211
  const preview = p.preview?.page
171
- ? `<iframe class="pframe" title="${esc(p.name)}: try it" src="${esc(`${p.url}${p.preview.page}`)}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe>`
212
+ ? previewOf(p)
172
213
  : p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
173
214
  const facts = [
174
215
  ['Kind', esc(kindOf(p))],
@@ -0,0 +1,34 @@
1
+ /**
2
+ * STANDALONE COPIES OF A GAME (standalone/STANDALONE.md): the same built game in a desktop or phone app
3
+ * (`homie-studio standalone build`). The app's page is not served by this site, so it is another origin to this
4
+ * Worker: `app://game` in the desktop app (Electron), `capacitor://localhost` on iOS and `https://localhost` on
5
+ * Android (Capacitor). Those three are fixed by the shells, never by a game.
6
+ *
7
+ * What an app origin may do here is ONE thing: ask the Lobby which public room to join (`POST /<game>/api/lobby`),
8
+ * and read the answer. The room's socket (`/<game>/__net`) never asked where a page came from, so it needs nothing.
9
+ * Everything else a page of this site does stays this site's own: no credentials are allowed across (there is no
10
+ * `access-control-allow-credentials` anywhere here), the lobby is never opened to `*`, and the POSTs that check
11
+ * `sameOrigin` (players, saves, the shop, chat reports, the office) still refuse an app, as they refuse any other
12
+ * site. So an app has no account, no cloud saves and no shop, and says so.
13
+ */
14
+
15
+ /** The origins of the app shells' own page. */
16
+ export const APP_ORIGINS = ['app://game', 'capacitor://localhost', 'https://localhost'];
17
+
18
+ /** The app origin this request came from, or null (this site's own pages, another site, a tool with no Origin). */
19
+ export function appOriginOf(request) {
20
+ const origin = request.headers.get('origin');
21
+ return origin && APP_ORIGINS.includes(origin) ? origin : null;
22
+ }
23
+
24
+ export const isAppOrigin = (request) => appOriginOf(request) !== null;
25
+
26
+ /** The answer as an app's page may read it: its own origin named, and nothing else. Any other request: unchanged. */
27
+ export function appCors(request, response) {
28
+ const origin = appOriginOf(request);
29
+ if (!origin) return response;
30
+ const out = new Response(response.body, response);
31
+ out.headers.set('access-control-allow-origin', origin);
32
+ out.headers.append('vary', 'origin');
33
+ return out;
34
+ }
@@ -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.31.0';
2
+ export const STUDIO_VERSION_TAG = '0.32.0';