@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
@@ -58,15 +58,19 @@
58
58
  * - The link, said out loud (revision 9, NETPLAY.md section 22): `net.link` and
59
59
  * `on('link')` say whether this browser is in its room (`online`), trying to
60
60
  * get back (`reconnecting`), playing by itself because the room never
61
- * answered (`alone`), or stopped (`closed`); a small "Reconnecting…" line is
62
- * drawn over the game meanwhile (restyle it, or `linkOverlay: false`). A host
63
- * whose frames hitch keeps its role with a heartbeat snapshot from a timer.
61
+ * answered or did not come back (`alone`), or stopped (`closed`); one line
62
+ * over the game says which, in words that are true ("Reconnecting…" only to a
63
+ * page that was connected; restyle it, or `linkOverlay: false`). `reconnecting`
64
+ * is bounded (`reconnectMaxMs`); `net.full` and `net.line()` say a full room
65
+ * and a game's own sentence (section 28). A host whose frames hitch keeps its
66
+ * role with a heartbeat snapshot from a timer.
64
67
  * - Game revisions (section 23): game.json `netplay.version` rides in the hello;
65
68
  * a room runs one build at a time and an older tab is told to reload
66
69
  * (`on('stale')`). `features` are per-peer capability flags the relay keeps,
67
70
  * so a new host knows them without asking again.
68
71
  * - The page around the frame (section 24): `net.prefs` (a few settings kept by
69
- * the play page, because the frame has no storage of its own), `net.params`
72
+ * the play page, because the frame has no storage of its own; read a number
73
+ * with `net.prefs.number(key, fallback)`, never a `null` to multiply by), `net.params`
70
74
  * (the address's switches the page passed in), `net.shell` (where the page's
71
75
  * own buttons sit over the game), and `guardGestures()` for a touch game.
72
76
  *
@@ -135,14 +139,21 @@ export interface NetConfig {
135
139
  params?: Record<string, string>;
136
140
  /** Revision 9: the page around the frame keeps `net.prefs` (a play page from before it does not say so). */
137
141
  prefs?: boolean;
142
+ /**
143
+ * This game runs in a standalone copy (a desktop or phone app, standalone/STANDALONE.md), whose files are the
144
+ * build it was made from: a reload cannot bring a newer one, only an update of the app can. The lines about a newer
145
+ * version say so, and a tap on one reloads nothing. Nothing on the wire changes.
146
+ */
147
+ app?: boolean;
138
148
  }
139
149
 
140
150
  /* ------------------------------------------------- the link, revisions and the page (revision 9, sections 22-24) */
141
151
 
142
152
  /**
143
153
  * Where this browser stands with its room. `connecting`: no welcome yet. `online`: in the room. `reconnecting`: it
144
- * was in the room and its socket went; it is knocking again (its seat token keeps its body). `alone`: the room never
145
- * answered in time, so this browser plays by itself as an offline host (it still knocks, and joins when answered).
154
+ * was in the room and its socket went; it is knocking again (its seat token keeps its body), for `reconnectMaxMs` at
155
+ * most. `alone`: the room never answered in time, or did not come back in that time, so this browser plays by itself
156
+ * as an offline host with its own bots (it still knocks, and joins when answered).
146
157
  * `offline`: there is no room at all (a plain file, a dev server with no shell). `closed`: stopped for good
147
158
  * (`net.closedWhy` says why: another tab took the seat, the room is full, the game was updated…).
148
159
  */
@@ -150,7 +161,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
150
161
  export interface LinkChange {
151
162
  state: LinkState;
152
163
  prev: LinkState;
153
- /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout`, `no-shell`, or a refusal's code. */
164
+ /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
154
165
  why: string;
155
166
  /** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
156
167
  hosting: boolean;
@@ -181,17 +192,38 @@ export interface ShellLayout {
181
192
  * characters a key, 32 keys. Never a secret, never progress that must not be lost (that is cloud saves).
182
193
  */
183
194
  export interface Prefs {
184
- /** The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer). */
195
+ /**
196
+ * The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer, and
197
+ * when nothing or `null` is kept). It is NOT checked against the fallback's type: for a number, a switch or one of
198
+ * a few words, use `number`, `boolean` and `string` below, which cannot hand a game a `null` to multiply by.
199
+ */
185
200
  get<T = unknown>(key: string, fallback?: T): Promise<T>;
186
- /** Keep a value (`null` or `undefined` removes it). Resolves false when it was not kept (too big, no page). */
201
+ /**
202
+ * A NUMBER that cannot go quiet (section 24). The kept value when it is a finite number, else `fallback`: for a
203
+ * key never set, a `null`, a string, a `NaN`, and before the prefs have arrived (`await net.prefs.ready` first;
204
+ * a read before that returns the fallback and warns once). `min` and `max` hold the answer (and the fallback) to
205
+ * a range; `integer` rounds it. `fallback` must itself be a finite number: anything else throws a TypeError.
206
+ */
207
+ number(key: string, fallback: number, range?: { min?: number; max?: number; integer?: boolean }): number;
208
+ /** A switch: the kept value when it is `true` or `false`, else `fallback` (never `0`, `'false'` or `null` read as a boolean). */
209
+ boolean(key: string, fallback: boolean): boolean;
210
+ /** A word: the kept value when it is a string (and one of `oneOf`, when given), else `fallback`. */
211
+ string<T extends string = string>(key: string, fallback: T, opts?: { oneOf?: readonly T[] }): T;
212
+ /**
213
+ * Keep a value (`null` or `undefined` removes it). Resolves false when it was not kept: too big, no page, or a
214
+ * value JSON cannot hold (`NaN`, `Infinity`, a function, a loop), which is refused with a warning rather than
215
+ * kept as `null`.
216
+ */
187
217
  set(key: string, value: unknown): Promise<boolean>;
188
218
  remove(key: string): Promise<boolean>;
189
219
  /** Everything kept for this game. */
190
220
  all(): Promise<Record<string, unknown>>;
191
- /** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). */
221
+ /** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). Before that it is the fallback, and says so once. */
192
222
  peek<T = unknown>(key: string, fallback?: T): T;
193
- /** Resolves once everything kept was read (so `peek` is complete). */
223
+ /** Resolves once everything kept was read (so `peek`, `number`, `boolean` and `string` are complete). */
194
224
  readonly ready: Promise<void>;
225
+ /** Everything kept has been read: the typed readers answer from what is kept, not from their fallbacks. */
226
+ readonly loaded: boolean;
195
227
  /** Where the values live: `page` (the play page's storage), `local` (this document's own), `memory` (this visit only). */
196
228
  readonly where: 'page' | 'local' | 'memory';
197
229
  }
@@ -563,6 +595,12 @@ export interface NetplayOptions<C = unknown> {
563
595
  * never left without a role). The socket opens at once either way; a welcome that arrives first is used at once.
564
596
  */
565
597
  connectClock?: 'auto' | 'game';
598
+ /**
599
+ * How long a page that WAS in its room keeps `reconnecting` before it plays `alone` (a private round with its own
600
+ * bots; it still knocks, and joins when it is answered). Default 20000; 5000 to 300000. Before this there was no
601
+ * bound: a replica whose room never came back showed a frozen round and "Reconnecting…" for good.
602
+ */
603
+ reconnectMaxMs?: number;
566
604
  /**
567
605
  * A host's heartbeat: when the game has not sent a snapshot for this long while others are present (its frames
568
606
  * hitched: a long load, a shader compile, a throttled tab), the helper sends the last one again from a timer, so
@@ -582,8 +620,9 @@ export interface NetplayOptions<C = unknown> {
582
620
  */
583
621
  features?: string[];
584
622
  /**
585
- * The line drawn over the game while the link is down ("Reconnecting…"). Default true in a room; false turns it
586
- * off (listen to `link` and draw your own). Restyle it with CSS on `[data-homie-link]`.
623
+ * The line drawn over the game while the link is down ("Reconnecting…", "Playing on your own…"), while the room
624
+ * is full, and for `net.line()`. Default true in a room; false turns it off (listen to `link` and draw your own).
625
+ * Restyle it with CSS on `[data-homie-link]`.
587
626
  */
588
627
  linkOverlay?: boolean;
589
628
  /** A socket that delivered nothing for this long is dropped and reopened (pongs come every 2 s). Default 6000. */
@@ -705,6 +744,18 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
705
744
  readonly reconnects: number;
706
745
  /** My welcome gave me back the seat my token named: I am the player who was here, not a new one in the same seat number. */
707
746
  readonly resumed: boolean;
747
+ /**
748
+ * This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
749
+ * `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
750
+ * says so; `createRoom({ fallback })` decides what the page does meanwhile.
751
+ */
752
+ readonly full: boolean;
753
+ /**
754
+ * The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
755
+ * on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
756
+ * down says its own line first. `createRoom` uses it for its `fallback`.
757
+ */
758
+ line(text: string | null): void;
708
759
  /** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
709
760
  start(): void;
710
761
  /** REVISIONS (section 23): this page's game revision (null: the game names none). */
@@ -1233,8 +1284,12 @@ const CONNECT_CLOCK_MAX_MS = 60_000;
1233
1284
  /** A host's heartbeat (section 22): the default gap without a snapshot before the timer sends one, and how long it keeps a silent game its role. */
1234
1285
  const HEARTBEAT_MS = 500;
1235
1286
  const HEARTBEAT_MAX_MS = 4000;
1287
+ /** How long a page that was in its room stays `reconnecting` before it plays alone: the default of `reconnectMaxMs`. */
1288
+ const RECONNECT_MAX_MS = 20_000;
1236
1289
  /** The link overlay: a reconnect shorter than this is never drawn. */
1237
1290
  const LINK_OVERLAY_MS = 700;
1291
+ /** What a standalone copy says when a newer build of the game is live: the way to it is the app's own update. */
1292
+ export const APP_STALE_LINE = 'A new version is out. Update the app to play online.';
1238
1293
  /** `net.prefs` (section 24): what the play page keeps for one game on one browser. worker/pages.mjs has the same numbers. */
1239
1294
  export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
1240
1295
  const PREFS_WAIT_MS = 2000;
@@ -1389,6 +1444,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1389
1444
  const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
1390
1445
  const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
1391
1446
  const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
1447
+ const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || RECONNECT_MAX_MS));
1392
1448
  const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
1393
1449
  const staleMs = Math.max(2500, opts.staleMs ?? 6000);
1394
1450
  // Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
@@ -1630,6 +1686,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1630
1686
  let link: LinkState = offline ? 'offline' : 'connecting';
1631
1687
  /** The line over the game while the link is down: made on first need, in this document, so a game's CSS reaches it. */
1632
1688
  const overlayOn = opts.linkOverlay !== false && !offline;
1689
+ /** A standalone copy (HOMIE_NET.app): a newer build is reached by updating the app, never by a reload. */
1690
+ const inApp = cfg?.app === true;
1633
1691
  let overlay: HTMLElement | null = null;
1634
1692
  let overlayTimer: ReturnType<typeof setTimeout> | null = null;
1635
1693
  let overlayHide: ReturnType<typeof setTimeout> | null = null;
@@ -1647,13 +1705,21 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1647
1705
  el.setAttribute('role', 'status');
1648
1706
  el.setAttribute('aria-live', 'polite');
1649
1707
  el.hidden = true;
1650
- el.addEventListener('click', () => { if (el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
1708
+ el.addEventListener('click', () => { if (!inApp && el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
1651
1709
  doc.body.appendChild(el);
1652
1710
  overlay = el;
1653
1711
  } catch { overlay = null; }
1654
1712
  return overlay;
1655
1713
  }
1656
- function showOverlay(kind: 'reconnecting' | 'alone' | 'stale', text: string, forMs = 0): void {
1714
+ /** Asked to play and every seat is taken (the welcome's `full`): in the room, watching, seated when one frees up. */
1715
+ let full = false;
1716
+ /** The game's own sentence for the line (`net.line`), shown while the link is up. */
1717
+ let gameLine: string | null = null;
1718
+ /** Why this page plays alone, and the last refusal that was not final (`room-stale`): the line says the true one. */
1719
+ let aloneWhy = '';
1720
+ let lastRefusal = '';
1721
+ type LineKind = 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
1722
+ function showOverlay(kind: LineKind, text: string, forMs = 0): void {
1657
1723
  const el = overlayEl();
1658
1724
  if (!el) return;
1659
1725
  if (overlayHide) { clearTimeout(overlayHide); overlayHide = null; }
@@ -1662,14 +1728,33 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1662
1728
  el.hidden = false;
1663
1729
  if (forMs > 0) overlayHide = setTimeout(() => { overlayHide = null; if (el.getAttribute('data-homie-link') === kind) el.hidden = true; }, forMs);
1664
1730
  }
1731
+ /** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
1732
+ function aloneText(): string {
1733
+ if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
1734
+ if (lastRefusal === 'room-stale') return 'Playing on your own · this room opens when its players have the new version';
1735
+ return 'Playing on your own · still looking for the room…';
1736
+ }
1737
+ /** A page stopped for good says why in words; the play page around it offers the way out (another room, a reload). */
1738
+ function closedText(): string | null {
1739
+ if (closedWhy === 'room-full' || closedWhy === 'too-many') return offline ? 'This room is full · playing on your own' : 'This room is full.';
1740
+ if (closedWhy === 'replaced') return 'This game is open in another tab.';
1741
+ if (closedWhy === 'kicked' || closedWhy === 'room-closed') return 'This room is closed.';
1742
+ if (closedWhy === 'version') return inApp ? APP_STALE_LINE : 'This game needs a reload to play online.';
1743
+ return null;
1744
+ }
1665
1745
  function paintLink(): void {
1666
1746
  if (!overlayOn) return;
1667
1747
  if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
1748
+ const closedSays = link === 'closed' ? closedText() : null;
1668
1749
  if (link === 'reconnecting') {
1669
1750
  // A blip shorter than a blink is never drawn.
1670
1751
  overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
1671
- } else if (link === 'alone') showOverlay('alone', 'Playing offline · reconnecting…');
1672
- else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', 'This game was updated. Tap to reload.');
1752
+ } else if (link === 'alone') showOverlay('alone', aloneText());
1753
+ else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', inApp ? APP_STALE_LINE : 'This game was updated. Tap to reload.');
1754
+ else if (closedSays) showOverlay('closed', closedSays);
1755
+ // In the room: the game's own sentence about where this player stands, else that the room is full.
1756
+ else if (link === 'online' && gameLine) showOverlay('seat', gameLine);
1757
+ else if (link === 'online' && full) showOverlay('full', 'This room is full · watching until a seat is free');
1673
1758
  else if (overlay && overlay.getAttribute('data-homie-link') !== 'stale') overlay.hidden = true;
1674
1759
  }
1675
1760
  function setLink(next: LinkState, why: string): void {
@@ -1688,7 +1773,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1688
1773
  staleVer = next;
1689
1774
  if (!first && !final) return;
1690
1775
  post?.({ what: 'stale', ver: next || null, mine: version, final });
1691
- if (!final) showOverlay('stale', 'A new version is ready. Tap to reload.', 10_000);
1776
+ if (!final) showOverlay('stale', inApp ? APP_STALE_LINE : 'A new version is ready. Tap to reload.', 10_000);
1692
1777
  emit('stale', { ver: next || null, mine: version, final });
1693
1778
  }
1694
1779
 
@@ -1753,11 +1838,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1753
1838
  const take = (all: unknown): void => {
1754
1839
  if (!all || typeof all !== 'object') return;
1755
1840
  // What this visit already set wins over what was read (a set that raced the first read).
1756
- for (const [k, v] of Object.entries(all as Record<string, unknown>)) if (!prefsCache.has(k)) prefsCache.set(k, v);
1841
+ // A `null` on disk is what an earlier build kept for a NaN: it is nothing, not a setting.
1842
+ for (const [k, v] of Object.entries(all as Record<string, unknown>)) if (!prefsCache.has(k) && v !== null && v !== undefined) prefsCache.set(k, v);
1757
1843
  };
1758
- if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => take(m['all']));
1844
+ if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => { take(m['all']); prefsLoaded = true; });
1759
1845
  else {
1760
1846
  if (prefsWhere === 'local') { try { take(JSON.parse(localStore()?.getItem(prefsKey) ?? '{}')); } catch { /* nothing kept */ } }
1847
+ // With no page to ask, everything is read at once: a typed reader never meets "not arrived yet" here.
1848
+ prefsLoaded = true;
1761
1849
  prefsReady = Promise.resolve();
1762
1850
  }
1763
1851
  return prefsReady;
@@ -1770,15 +1858,82 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1770
1858
  try { text = JSON.stringify(next); } catch { return false; }
1771
1859
  return typeof text === 'string' && text.length <= PREFS_LIMITS.bytes && Object.keys(next).length <= PREFS_LIMITS.keys;
1772
1860
  }
1861
+ /** Said once a visit, not once every ten seconds: a mistake in the game's code, for whoever is building it. */
1862
+ const saidOnce = new Set<string>();
1863
+ const sayOnce = (key: string, msg: string): void => { if (saidOnce.has(key)) return; saidOnce.add(key); console.warn('[netplay]', msg); };
1864
+ let prefsLoaded = false;
1865
+ const kindOf = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'an array' : typeof v === 'number' && !Number.isFinite(v) ? String(v) : `a ${typeof v}`);
1866
+ /** Why JSON cannot hold `v` as it is (it would be kept as `null`, or not at all), or null when it can. */
1867
+ function unkeepable(v: unknown, depth = 0, seen: Set<object> = new Set()): string | null {
1868
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return null;
1869
+ if (typeof v === 'number') return Number.isFinite(v) ? null : `${String(v)} (JSON keeps it as null)`;
1870
+ if (typeof v === 'undefined') return depth ? 'undefined inside it' : null;
1871
+ if (typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint') return `a ${typeof v}`;
1872
+ if (depth > 8) return 'nested too deep';
1873
+ const o = v as object;
1874
+ if (seen.has(o)) return 'a loop';
1875
+ seen.add(o);
1876
+ for (const x of Array.isArray(o) ? o : Object.values(o)) { const why = unkeepable(x, depth + 1, seen); if (why) return why; }
1877
+ seen.delete(o);
1878
+ return null;
1879
+ }
1880
+ /**
1881
+ * The kept value for a typed reader, or `undefined` for "use the fallback": nothing kept, a `null`, or the prefs
1882
+ * not read yet. Reading before they arrive is the mistake that made a volume silent, so it is said once, with the fix.
1883
+ */
1884
+ function prefsRaw(key: string, how: string, fallback: unknown): unknown {
1885
+ if (!prefsLoaded) {
1886
+ void prefsLoad();
1887
+ if (!prefsCache.has(key)) {
1888
+ sayOnce('prefs-early', `net.prefs.${how}('${key}') was read before the prefs had arrived, so it answered with its fallback (${JSON.stringify(fallback)}). Wait for them first: await net.prefs.ready`);
1889
+ return undefined;
1890
+ }
1891
+ }
1892
+ const v = prefsCache.get(key);
1893
+ return v === null ? undefined : v;
1894
+ }
1895
+ const wrongType = (key: string, how: string, v: unknown, fallback: unknown): void =>
1896
+ sayOnce(`prefs-type:${key}`, `net.prefs.${how}('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback (${JSON.stringify(fallback)})`);
1773
1897
  const prefs: Prefs = {
1774
- get: <T,>(key: string, fallback?: T): Promise<T> => prefsLoad().then(() => (prefsCache.has(key) ? prefsCache.get(key) as T : fallback as T)),
1775
- peek: <T,>(key: string, fallback?: T): T => (prefsCache.has(key) ? prefsCache.get(key) as T : fallback as T),
1898
+ // A kept `null` is never a value (set(key, null) removes): the fallback, not a null a game multiplies into silence.
1899
+ get: <T,>(key: string, fallback?: T): Promise<T> => prefsLoad().then(() => { const v = prefsCache.get(key); return (v === undefined || v === null ? fallback : v) as T; }),
1900
+ peek: <T,>(key: string, fallback?: T): T => { const v = prefsRaw(key, 'peek', fallback); return (v === undefined ? fallback : v) as T; },
1901
+ number(key: string, fallback: number, range: { min?: number; max?: number; integer?: boolean } = {}): number {
1902
+ if (typeof fallback !== 'number' || !Number.isFinite(fallback)) throw new TypeError(`net.prefs.number('${key}', fallback): the fallback must be a finite number, not ${kindOf(fallback)}`);
1903
+ const min = typeof range.min === 'number' && Number.isFinite(range.min) ? range.min : -Infinity;
1904
+ const max = typeof range.max === 'number' && Number.isFinite(range.max) ? range.max : Infinity;
1905
+ if (min > max) throw new TypeError(`net.prefs.number('${key}'): min (${min}) is above max (${max})`);
1906
+ const v = prefsRaw(key, 'number', fallback);
1907
+ let n = fallback;
1908
+ if (typeof v === 'number' && Number.isFinite(v)) n = v; else if (v !== undefined) wrongType(key, 'number', v, fallback);
1909
+ if (range.integer) n = Math.round(n);
1910
+ return Math.max(min, Math.min(max, n));
1911
+ },
1912
+ boolean(key: string, fallback: boolean): boolean {
1913
+ if (typeof fallback !== 'boolean') throw new TypeError(`net.prefs.boolean('${key}', fallback): the fallback must be true or false, not ${kindOf(fallback)}`);
1914
+ const v = prefsRaw(key, 'boolean', fallback);
1915
+ if (typeof v === 'boolean') return v;
1916
+ if (v !== undefined) wrongType(key, 'boolean', v, fallback);
1917
+ return fallback;
1918
+ },
1919
+ string<T extends string = string>(key: string, fallback: T, o: { oneOf?: readonly T[] } = {}): T {
1920
+ if (typeof fallback !== 'string') throw new TypeError(`net.prefs.string('${key}', fallback): the fallback must be a string, not ${kindOf(fallback)}`);
1921
+ if (o.oneOf && !o.oneOf.includes(fallback)) throw new TypeError(`net.prefs.string('${key}'): the fallback '${fallback}' is not one of ${JSON.stringify(o.oneOf)}`);
1922
+ const v = prefsRaw(key, 'string', fallback);
1923
+ if (typeof v === 'string' && (!o.oneOf || o.oneOf.includes(v as T))) return v as T;
1924
+ if (v !== undefined) sayOnce(`prefs-type:${key}`, typeof v === 'string' ? `net.prefs.string('${key}'): '${v.slice(0, 40)}' is not one of ${JSON.stringify(o.oneOf)}, so it answered with its fallback ('${fallback}')` : `net.prefs.string('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback ('${fallback}')`);
1925
+ return fallback;
1926
+ },
1776
1927
  all: () => prefsLoad().then(() => Object.fromEntries(prefsCache)),
1777
1928
  remove: (key: string) => prefs.set(key, null),
1778
1929
  async set(key: string, value: unknown): Promise<boolean> {
1779
- if (!prefsKeyOk(key)) return false;
1780
- await prefsLoad();
1930
+ if (!prefsKeyOk(key)) { sayOnce('prefs-key', `net.prefs.set: a key is 1 to ${PREFS_LIMITS.key} characters; ${JSON.stringify(String(key as unknown).slice(0, 24))} was not kept`); return false; }
1781
1931
  const del = value === null || value === undefined;
1932
+ // NaN, Infinity, a function, a loop: JSON would keep `null` (or nothing), and the next visit would read that as
1933
+ // a setting. Refused here, in words, instead of a volume that is silent from then on.
1934
+ const bad = del ? null : unkeepable(value);
1935
+ if (bad) { sayOnce(`prefs-value:${key}`, `net.prefs.set('${key}', …): the value is ${bad}, which cannot be kept, so nothing was changed. Keep a finite number, a string, a boolean or plain JSON`); return false; }
1936
+ await prefsLoad();
1782
1937
  if (!del && !prefsFits(key, value)) { warnOnce('prefs-size', `net.prefs: '${key}' was not kept (a game's prefs are ${PREFS_LIMITS.bytes / 1024} KB and ${PREFS_LIMITS.keys} keys in all)`); return false; }
1783
1938
  const had = prefsCache.has(key);
1784
1939
  const before = prefsCache.get(key);
@@ -1793,6 +1948,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1793
1948
  return true;
1794
1949
  },
1795
1950
  get ready() { return prefsLoad(); },
1951
+ get loaded() { return prefsLoaded; },
1796
1952
  get where() { return prefsWhere; },
1797
1953
  };
1798
1954
 
@@ -2088,6 +2244,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2088
2244
  colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
2089
2245
  if (id) { const p = peers.get(id); if (p) { p.seat = seat; p.name = name; p.colour = colour; } }
2090
2246
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
2247
+ if (full) { full = false; post?.({ what: 'full', full: false }); paintLink(); }
2091
2248
  setRole(m['role'] === 'host' ? 'host' : 'replica', 'seated');
2092
2249
  return;
2093
2250
  }
@@ -2152,14 +2309,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2152
2309
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2153
2310
  // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
2154
2311
  // browser keeps knocking and is let in when they have. Said once, not at every knock.
2155
- if (code === 'room-stale') { warnOnce('room-stale', m['message']); return; }
2312
+ if (code === 'room-stale') { lastRefusal = code; warnOnce('room-stale', m['message']); if (link === 'alone') paintLink(); return; }
2156
2313
  console.warn('[netplay] relay refused:', code, m['message']);
2157
2314
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
2158
2315
  // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
2159
2316
  if (FINAL_ERRORS.has(code)) {
2160
2317
  closed = true;
2161
2318
  closedWhy = code;
2162
- post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2319
+ post?.({ what: 'closed', why: code, room: cfg?.room ?? null, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2163
2320
  // Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
2164
2321
  if (code === 'stale') noteStale(m['ver'], true);
2165
2322
  setLink('closed', code);
@@ -2224,6 +2381,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2224
2381
  connected = true;
2225
2382
  downSince = 0;
2226
2383
  attempt = 0;
2384
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2385
+ lastRefusal = '';
2386
+ // Asked to play and every seat is taken: in the room with no seat, and seated when one frees up (a `seat` frame).
2387
+ const nowFull = m['full'] === true && seat === null && !watching;
2388
+ if (nowFull !== full) { full = nowFull; post?.({ what: 'full', full }); }
2227
2389
  emit('status', true);
2228
2390
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
2229
2391
  // First welcome, a reconnect, or coming back from an offline fallback: only a CHANGE is a role event,
@@ -2233,7 +2395,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2233
2395
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
2234
2396
  }
2235
2397
  if (continuing) { reannounce(); catchUp(had, hadSeat); }
2236
- setLink('online', 'welcome');
2398
+ if (link === 'online') paintLink(); else setLink('online', 'welcome');
2237
2399
  // A build older than the live one, let in to its own room (section 23).
2238
2400
  if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
2239
2401
  resolveView('seat');
@@ -2406,6 +2568,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2406
2568
  let statsTimer: ReturnType<typeof setInterval> | null = null;
2407
2569
  let connectTimer: ReturnType<typeof setTimeout> | undefined;
2408
2570
  let hbTimer: ReturnType<typeof setInterval> | null = null;
2571
+ /** `reconnecting` is bounded (`reconnectMaxMs`): when this fires and the room is still not back, the page plays alone. */
2572
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2573
+ /** When `ping()` last ran: one that runs seconds late (the page was blocked) says nothing about the socket. */
2574
+ let lastPingTick = 0;
2409
2575
  /** `connectClock: 'game'`: the wait for the welcome is not counted until the game says it has booted. */
2410
2576
  let clockRunning = opts.connectClock !== 'game';
2411
2577
  let lastStatsPost = 0;
@@ -2440,14 +2606,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2440
2606
 
2441
2607
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
2442
2608
  function ping(): void {
2609
+ // This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
2610
+ const beat = wall();
2611
+ const pingHeld = beat - lastPingTick > 2000 + 1500;
2612
+ lastPingTick = beat;
2443
2613
  if (!connected) {
2444
2614
  // A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
2445
2615
  // it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
2446
2616
  if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
2447
2617
  return;
2448
2618
  }
2449
- // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect.
2450
- if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) { lost(ws, 'stale'); return; }
2619
+ // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect. But not on
2620
+ // the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
2621
+ // could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
2622
+ // Dropping a healthy socket there turned every long hitch into a reconnect.
2623
+ if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
2624
+ if (!pingHeld) { lost(ws, 'stale'); return; }
2625
+ // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
2626
+ // still dropped, two beats from now.
2627
+ lastMsgAt = wall() - staleMs + 2500;
2628
+ }
2451
2629
  const c = Date.now();
2452
2630
  pingsOut.set(c, c);
2453
2631
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
@@ -2535,9 +2713,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2535
2713
  offline = true;
2536
2714
  connected = false;
2537
2715
  seat = null;
2538
- if (!roleKnown || role !== 'host') setRole('host', why);
2539
- // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at).
2540
- if (why !== 'offline') setLink('alone', why);
2716
+ if (full) { full = false; post?.({ what: 'full', full: false }); }
2717
+ aloneWhy = why;
2718
+ // A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
2719
+ // gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
2720
+ if (!roleKnown || role !== 'host' || why === 'reconnect-timeout') setRole('host', why);
2721
+ // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
2722
+ // page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
2723
+ if (closed) paintLink();
2724
+ else if (why !== 'offline') setLink('alone', why);
2541
2725
  }
2542
2726
 
2543
2727
  function lost(sock: WebSocketLike, why: string): void {
@@ -2551,7 +2735,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2551
2735
  if (was) emit('status', false);
2552
2736
  // It was in its room and the socket went: it knocks again with its token. (Never welcomed yet: still `connecting`,
2553
2737
  // or `alone` once it gave up waiting; stopped for good: `closed`.)
2554
- if (was && !closed) setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
2738
+ if (was && !closed) {
2739
+ setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
2740
+ // BOUNDED (section 22): a room that does not come back in reconnectMaxMs is not waited for with a frozen round
2741
+ // on screen. The page plays alone with its own bots, says so, and keeps knocking.
2742
+ if (reconnectTimer) clearTimeout(reconnectTimer);
2743
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; if (link === 'reconnecting' && !closed) goOfflineHost('reconnect-timeout'); }, reconnectMaxMs);
2744
+ }
2555
2745
  notifyStats(true);
2556
2746
  retry();
2557
2747
  }
@@ -2614,6 +2804,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2614
2804
  };
2615
2805
  connectTimer = setTimeout(connectCheck, 250);
2616
2806
  if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
2807
+ lastPingTick = wall();
2617
2808
  pingTimer = setInterval(ping, 2000);
2618
2809
  ckptTimer = setInterval(sendCheckpoint, checkpointMs);
2619
2810
  statsTimer = setInterval(() => notifyStats(true), 500);
@@ -2761,6 +2952,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2761
2952
  get link() { return link; },
2762
2953
  get reconnects() { return reconnects; },
2763
2954
  get resumed() { return resumed; },
2955
+ get full() { return full; },
2956
+ line(text: string | null): void {
2957
+ const next = typeof text === 'string' && text.trim() ? text.trim().slice(0, 120) : null;
2958
+ if (next === gameLine) return;
2959
+ gameLine = next;
2960
+ paintLink();
2961
+ },
2764
2962
  start(): void { clockRunning = true; },
2765
2963
  get version() { return version; },
2766
2964
  get stale() { return staleVer; },
@@ -3033,6 +3231,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3033
3231
  if (statsTimer) clearInterval(statsTimer);
3034
3232
  if (watchTimer) clearInterval(watchTimer);
3035
3233
  if (hbTimer) clearInterval(hbTimer);
3234
+ if (reconnectTimer) clearTimeout(reconnectTimer);
3036
3235
  if (spotTimer) clearTimeout(spotTimer);
3037
3236
  if (overlayTimer) clearTimeout(overlayTimer);
3038
3237
  if (overlayHide) clearTimeout(overlayHide);
@@ -3062,6 +3261,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3062
3261
  get revision() { return NETPLAY_REVISION; },
3063
3262
  /** Revision 9: the link, how often it was interrupted, the game's revision, and the arrival's facts (for a probe). */
3064
3263
  get link() { return link; },
3264
+ get full() { return full; },
3265
+ get line() { return overlay && !overlay.hidden ? { kind: overlay.getAttribute('data-homie-link'), text: overlay.textContent } : null; },
3065
3266
  get reconnects() { return reconnects; },
3066
3267
  get drops() { return drops; },
3067
3268
  get version() { return version; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -78,6 +78,7 @@
78
78
  "site/",
79
79
  "chat/",
80
80
  "parts/",
81
+ "standalone/",
81
82
  "README.md",
82
83
  "CHANGELOG.md",
83
84
  "LICENSE",
package/parts/PARTS.md CHANGED
@@ -76,7 +76,7 @@ named after it):
76
76
  "from": { "game": "gem-cave", "studio": "Night Owls" },
77
77
  "entry": "src/index.ts",
78
78
  "files": [{ "path": "src/index.ts", "sha256": "…", "bytes": 1234 }],
79
- "preview": { "page": "preview/index.html", "image": "preview/cover.jpg" },
79
+ "preview": { "page": "preview/index.html", "image": "preview/cover.jpg", "aspect": "4:3", "phoneAspect": "3:4" },
80
80
  "requires": { "packages": { "@homie-rocks/camera": "^0.2.0" }, "parts": ["other-studio.example/some-part"] },
81
81
  "physical": { "units": "metres", "scale": 1, "pivot": "feet", "collision": "collision.json" },
82
82
  "skeleton": { "rig": "humanoid-v1", "clips": ["idle", "run"] },
@@ -95,7 +95,7 @@ named after it):
95
95
  | `from` | The game and studio the part came out of. Written when a part is lifted out of a game. |
96
96
  | `entry` | For a part with code: the module a game imports. A part of assets or data has none. |
97
97
  | `files` | Every file with its SHA-256 and size, **written by the tool, never by hand**. A file may carry `"rights"` (below). |
98
- | `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. |
98
+ | `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. `aspect`: the frame's shape as `"W:H"` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; default `"16:9"`). `phoneAspect`: the shape when the frame is narrower than 600 px. |
99
99
  | `requires.packages` | The npm packages it builds on, as `package.json` would name them. npm reads them; nothing else does. |
100
100
  | `requires.parts` | Other parts it needs: a plain list of `"<host>/<id>"`. Named to the person, never solved. |
101
101
  | `physical`, `skeleton`, `contract`, `cost` | Optional descriptions for whoever uses the piece: its scale, pivot and collision data; its rig and clips; its inputs, state and who decides in a room (`host-authoritative`, `replicated`, `local`); device costs that were measured (never guessed). |
@@ -135,6 +135,12 @@ well-known file and through them the hub read nothing else.
135
135
  attribution, preview, what it builds on and costs, and what to say in chat to add it), and a **Parts from this
136
136
  game** band on a game's landing page.
137
137
 
138
+ **Declare the shape of an interactive preview.** The default frame is 16:9, and across a 390 px phone that is
139
+ about 197 px tall: a heading and two buttons fill it, and what the preview draws below them is cut off with no
140
+ error to notice. Say `"aspect"` and `"phoneAspect"` in `preview` for a preview somebody plays with. With neither, a
141
+ narrow frame is at least 320 px tall (70% of a short screen), and every preview has an **Open the preview** link
142
+ that shows it in a tab of its own.
143
+
138
144
  Sharing packs the part: the exact bytes of that version are kept in `parts/_packed/<id>/<version>/`, and every
139
145
  packed version of a shared part is served, so "one exact version" stays true after a newer one ships.
140
146
 
package/port/index.ts CHANGED
@@ -37,7 +37,7 @@ export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLi
37
37
  export { fitView, easeView, toScreen, createLabels, type Fit, type FitOptions, type LabelIn, type LabelOut, type LabelOptions, type LabelBox } from './view';
38
38
  export { createBubbles, paintBubbles, wrapText, BUBBLE_FONT, type Bubbles, type BubbleOptions, type BubbleIn, type BubbleOut, type BubbleStyle } from './view';
39
39
  export { exposePort, PORT_EXTRA_NAMES, type PortExtra, type PortProbeOptions, type View } from './probe';
40
- export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt, type TakeoverInfo } from './room';
40
+ export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt, type TakeoverInfo, type Fallback, type Standing } from './room';
41
41
  export { createHud, type HudOptions } from './hud';
42
42
  export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
43
43
  export { jitter, engages, standoff } from './skill';