@homie-rocks/studio 0.31.0 → 0.31.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.
@@ -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
  *
@@ -141,8 +145,9 @@ export interface NetConfig {
141
145
 
142
146
  /**
143
147
  * 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).
148
+ * was in the room and its socket went; it is knocking again (its seat token keeps its body), for `reconnectMaxMs` at
149
+ * most. `alone`: the room never answered in time, or did not come back in that time, so this browser plays by itself
150
+ * as an offline host with its own bots (it still knocks, and joins when answered).
146
151
  * `offline`: there is no room at all (a plain file, a dev server with no shell). `closed`: stopped for good
147
152
  * (`net.closedWhy` says why: another tab took the seat, the room is full, the game was updated…).
148
153
  */
@@ -150,7 +155,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
150
155
  export interface LinkChange {
151
156
  state: LinkState;
152
157
  prev: LinkState;
153
- /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout`, `no-shell`, or a refusal's code. */
158
+ /** 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
159
  why: string;
155
160
  /** 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
161
  hosting: boolean;
@@ -181,17 +186,38 @@ export interface ShellLayout {
181
186
  * characters a key, 32 keys. Never a secret, never progress that must not be lost (that is cloud saves).
182
187
  */
183
188
  export interface Prefs {
184
- /** The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer). */
189
+ /**
190
+ * The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer, and
191
+ * when nothing or `null` is kept). It is NOT checked against the fallback's type: for a number, a switch or one of
192
+ * a few words, use `number`, `boolean` and `string` below, which cannot hand a game a `null` to multiply by.
193
+ */
185
194
  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). */
195
+ /**
196
+ * A NUMBER that cannot go quiet (section 24). The kept value when it is a finite number, else `fallback`: for a
197
+ * key never set, a `null`, a string, a `NaN`, and before the prefs have arrived (`await net.prefs.ready` first;
198
+ * a read before that returns the fallback and warns once). `min` and `max` hold the answer (and the fallback) to
199
+ * a range; `integer` rounds it. `fallback` must itself be a finite number: anything else throws a TypeError.
200
+ */
201
+ number(key: string, fallback: number, range?: { min?: number; max?: number; integer?: boolean }): number;
202
+ /** A switch: the kept value when it is `true` or `false`, else `fallback` (never `0`, `'false'` or `null` read as a boolean). */
203
+ boolean(key: string, fallback: boolean): boolean;
204
+ /** A word: the kept value when it is a string (and one of `oneOf`, when given), else `fallback`. */
205
+ string<T extends string = string>(key: string, fallback: T, opts?: { oneOf?: readonly T[] }): T;
206
+ /**
207
+ * Keep a value (`null` or `undefined` removes it). Resolves false when it was not kept: too big, no page, or a
208
+ * value JSON cannot hold (`NaN`, `Infinity`, a function, a loop), which is refused with a warning rather than
209
+ * kept as `null`.
210
+ */
187
211
  set(key: string, value: unknown): Promise<boolean>;
188
212
  remove(key: string): Promise<boolean>;
189
213
  /** Everything kept for this game. */
190
214
  all(): Promise<Record<string, unknown>>;
191
- /** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). */
215
+ /** 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
216
  peek<T = unknown>(key: string, fallback?: T): T;
193
- /** Resolves once everything kept was read (so `peek` is complete). */
217
+ /** Resolves once everything kept was read (so `peek`, `number`, `boolean` and `string` are complete). */
194
218
  readonly ready: Promise<void>;
219
+ /** Everything kept has been read: the typed readers answer from what is kept, not from their fallbacks. */
220
+ readonly loaded: boolean;
195
221
  /** Where the values live: `page` (the play page's storage), `local` (this document's own), `memory` (this visit only). */
196
222
  readonly where: 'page' | 'local' | 'memory';
197
223
  }
@@ -563,6 +589,12 @@ export interface NetplayOptions<C = unknown> {
563
589
  * never left without a role). The socket opens at once either way; a welcome that arrives first is used at once.
564
590
  */
565
591
  connectClock?: 'auto' | 'game';
592
+ /**
593
+ * How long a page that WAS in its room keeps `reconnecting` before it plays `alone` (a private round with its own
594
+ * bots; it still knocks, and joins when it is answered). Default 20000; 5000 to 300000. Before this there was no
595
+ * bound: a replica whose room never came back showed a frozen round and "Reconnecting…" for good.
596
+ */
597
+ reconnectMaxMs?: number;
566
598
  /**
567
599
  * A host's heartbeat: when the game has not sent a snapshot for this long while others are present (its frames
568
600
  * hitched: a long load, a shader compile, a throttled tab), the helper sends the last one again from a timer, so
@@ -582,8 +614,9 @@ export interface NetplayOptions<C = unknown> {
582
614
  */
583
615
  features?: string[];
584
616
  /**
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]`.
617
+ * The line drawn over the game while the link is down ("Reconnecting…", "Playing on your own…"), while the room
618
+ * is full, and for `net.line()`. Default true in a room; false turns it off (listen to `link` and draw your own).
619
+ * Restyle it with CSS on `[data-homie-link]`.
587
620
  */
588
621
  linkOverlay?: boolean;
589
622
  /** A socket that delivered nothing for this long is dropped and reopened (pongs come every 2 s). Default 6000. */
@@ -705,6 +738,18 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
705
738
  readonly reconnects: number;
706
739
  /** 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
740
  readonly resumed: boolean;
741
+ /**
742
+ * This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
743
+ * `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
744
+ * says so; `createRoom({ fallback })` decides what the page does meanwhile.
745
+ */
746
+ readonly full: boolean;
747
+ /**
748
+ * The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
749
+ * on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
750
+ * down says its own line first. `createRoom` uses it for its `fallback`.
751
+ */
752
+ line(text: string | null): void;
708
753
  /** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
709
754
  start(): void;
710
755
  /** REVISIONS (section 23): this page's game revision (null: the game names none). */
@@ -1233,6 +1278,8 @@ const CONNECT_CLOCK_MAX_MS = 60_000;
1233
1278
  /** 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
1279
  const HEARTBEAT_MS = 500;
1235
1280
  const HEARTBEAT_MAX_MS = 4000;
1281
+ /** How long a page that was in its room stays `reconnecting` before it plays alone: the default of `reconnectMaxMs`. */
1282
+ const RECONNECT_MAX_MS = 20_000;
1236
1283
  /** The link overlay: a reconnect shorter than this is never drawn. */
1237
1284
  const LINK_OVERLAY_MS = 700;
1238
1285
  /** `net.prefs` (section 24): what the play page keeps for one game on one browser. worker/pages.mjs has the same numbers. */
@@ -1389,6 +1436,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1389
1436
  const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
1390
1437
  const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
1391
1438
  const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
1439
+ const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || RECONNECT_MAX_MS));
1392
1440
  const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
1393
1441
  const staleMs = Math.max(2500, opts.staleMs ?? 6000);
1394
1442
  // Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
@@ -1653,7 +1701,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1653
1701
  } catch { overlay = null; }
1654
1702
  return overlay;
1655
1703
  }
1656
- function showOverlay(kind: 'reconnecting' | 'alone' | 'stale', text: string, forMs = 0): void {
1704
+ /** Asked to play and every seat is taken (the welcome's `full`): in the room, watching, seated when one frees up. */
1705
+ let full = false;
1706
+ /** The game's own sentence for the line (`net.line`), shown while the link is up. */
1707
+ let gameLine: string | null = null;
1708
+ /** Why this page plays alone, and the last refusal that was not final (`room-stale`): the line says the true one. */
1709
+ let aloneWhy = '';
1710
+ let lastRefusal = '';
1711
+ type LineKind = 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
1712
+ function showOverlay(kind: LineKind, text: string, forMs = 0): void {
1657
1713
  const el = overlayEl();
1658
1714
  if (!el) return;
1659
1715
  if (overlayHide) { clearTimeout(overlayHide); overlayHide = null; }
@@ -1662,14 +1718,33 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1662
1718
  el.hidden = false;
1663
1719
  if (forMs > 0) overlayHide = setTimeout(() => { overlayHide = null; if (el.getAttribute('data-homie-link') === kind) el.hidden = true; }, forMs);
1664
1720
  }
1721
+ /** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
1722
+ function aloneText(): string {
1723
+ if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
1724
+ if (lastRefusal === 'room-stale') return 'Playing on your own · this room opens when its players have the new version';
1725
+ return 'Playing on your own · still looking for the room…';
1726
+ }
1727
+ /** A page stopped for good says why in words; the play page around it offers the way out (another room, a reload). */
1728
+ function closedText(): string | null {
1729
+ if (closedWhy === 'room-full' || closedWhy === 'too-many') return offline ? 'This room is full · playing on your own' : 'This room is full.';
1730
+ if (closedWhy === 'replaced') return 'This game is open in another tab.';
1731
+ if (closedWhy === 'kicked' || closedWhy === 'room-closed') return 'This room is closed.';
1732
+ if (closedWhy === 'version') return 'This game needs a reload to play online.';
1733
+ return null;
1734
+ }
1665
1735
  function paintLink(): void {
1666
1736
  if (!overlayOn) return;
1667
1737
  if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
1738
+ const closedSays = link === 'closed' ? closedText() : null;
1668
1739
  if (link === 'reconnecting') {
1669
1740
  // A blip shorter than a blink is never drawn.
1670
1741
  overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
1671
- } else if (link === 'alone') showOverlay('alone', 'Playing offline · reconnecting…');
1742
+ } else if (link === 'alone') showOverlay('alone', aloneText());
1672
1743
  else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', 'This game was updated. Tap to reload.');
1744
+ else if (closedSays) showOverlay('closed', closedSays);
1745
+ // In the room: the game's own sentence about where this player stands, else that the room is full.
1746
+ else if (link === 'online' && gameLine) showOverlay('seat', gameLine);
1747
+ else if (link === 'online' && full) showOverlay('full', 'This room is full · watching until a seat is free');
1673
1748
  else if (overlay && overlay.getAttribute('data-homie-link') !== 'stale') overlay.hidden = true;
1674
1749
  }
1675
1750
  function setLink(next: LinkState, why: string): void {
@@ -1753,11 +1828,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1753
1828
  const take = (all: unknown): void => {
1754
1829
  if (!all || typeof all !== 'object') return;
1755
1830
  // 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);
1831
+ // A `null` on disk is what an earlier build kept for a NaN: it is nothing, not a setting.
1832
+ 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
1833
  };
1758
- if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => take(m['all']));
1834
+ if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => { take(m['all']); prefsLoaded = true; });
1759
1835
  else {
1760
1836
  if (prefsWhere === 'local') { try { take(JSON.parse(localStore()?.getItem(prefsKey) ?? '{}')); } catch { /* nothing kept */ } }
1837
+ // With no page to ask, everything is read at once: a typed reader never meets "not arrived yet" here.
1838
+ prefsLoaded = true;
1761
1839
  prefsReady = Promise.resolve();
1762
1840
  }
1763
1841
  return prefsReady;
@@ -1770,15 +1848,82 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1770
1848
  try { text = JSON.stringify(next); } catch { return false; }
1771
1849
  return typeof text === 'string' && text.length <= PREFS_LIMITS.bytes && Object.keys(next).length <= PREFS_LIMITS.keys;
1772
1850
  }
1851
+ /** Said once a visit, not once every ten seconds: a mistake in the game's code, for whoever is building it. */
1852
+ const saidOnce = new Set<string>();
1853
+ const sayOnce = (key: string, msg: string): void => { if (saidOnce.has(key)) return; saidOnce.add(key); console.warn('[netplay]', msg); };
1854
+ let prefsLoaded = false;
1855
+ const kindOf = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'an array' : typeof v === 'number' && !Number.isFinite(v) ? String(v) : `a ${typeof v}`);
1856
+ /** Why JSON cannot hold `v` as it is (it would be kept as `null`, or not at all), or null when it can. */
1857
+ function unkeepable(v: unknown, depth = 0, seen: Set<object> = new Set()): string | null {
1858
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return null;
1859
+ if (typeof v === 'number') return Number.isFinite(v) ? null : `${String(v)} (JSON keeps it as null)`;
1860
+ if (typeof v === 'undefined') return depth ? 'undefined inside it' : null;
1861
+ if (typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint') return `a ${typeof v}`;
1862
+ if (depth > 8) return 'nested too deep';
1863
+ const o = v as object;
1864
+ if (seen.has(o)) return 'a loop';
1865
+ seen.add(o);
1866
+ for (const x of Array.isArray(o) ? o : Object.values(o)) { const why = unkeepable(x, depth + 1, seen); if (why) return why; }
1867
+ seen.delete(o);
1868
+ return null;
1869
+ }
1870
+ /**
1871
+ * The kept value for a typed reader, or `undefined` for "use the fallback": nothing kept, a `null`, or the prefs
1872
+ * not read yet. Reading before they arrive is the mistake that made a volume silent, so it is said once, with the fix.
1873
+ */
1874
+ function prefsRaw(key: string, how: string, fallback: unknown): unknown {
1875
+ if (!prefsLoaded) {
1876
+ void prefsLoad();
1877
+ if (!prefsCache.has(key)) {
1878
+ 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`);
1879
+ return undefined;
1880
+ }
1881
+ }
1882
+ const v = prefsCache.get(key);
1883
+ return v === null ? undefined : v;
1884
+ }
1885
+ const wrongType = (key: string, how: string, v: unknown, fallback: unknown): void =>
1886
+ sayOnce(`prefs-type:${key}`, `net.prefs.${how}('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback (${JSON.stringify(fallback)})`);
1773
1887
  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),
1888
+ // A kept `null` is never a value (set(key, null) removes): the fallback, not a null a game multiplies into silence.
1889
+ get: <T,>(key: string, fallback?: T): Promise<T> => prefsLoad().then(() => { const v = prefsCache.get(key); return (v === undefined || v === null ? fallback : v) as T; }),
1890
+ peek: <T,>(key: string, fallback?: T): T => { const v = prefsRaw(key, 'peek', fallback); return (v === undefined ? fallback : v) as T; },
1891
+ number(key: string, fallback: number, range: { min?: number; max?: number; integer?: boolean } = {}): number {
1892
+ 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)}`);
1893
+ const min = typeof range.min === 'number' && Number.isFinite(range.min) ? range.min : -Infinity;
1894
+ const max = typeof range.max === 'number' && Number.isFinite(range.max) ? range.max : Infinity;
1895
+ if (min > max) throw new TypeError(`net.prefs.number('${key}'): min (${min}) is above max (${max})`);
1896
+ const v = prefsRaw(key, 'number', fallback);
1897
+ let n = fallback;
1898
+ if (typeof v === 'number' && Number.isFinite(v)) n = v; else if (v !== undefined) wrongType(key, 'number', v, fallback);
1899
+ if (range.integer) n = Math.round(n);
1900
+ return Math.max(min, Math.min(max, n));
1901
+ },
1902
+ boolean(key: string, fallback: boolean): boolean {
1903
+ if (typeof fallback !== 'boolean') throw new TypeError(`net.prefs.boolean('${key}', fallback): the fallback must be true or false, not ${kindOf(fallback)}`);
1904
+ const v = prefsRaw(key, 'boolean', fallback);
1905
+ if (typeof v === 'boolean') return v;
1906
+ if (v !== undefined) wrongType(key, 'boolean', v, fallback);
1907
+ return fallback;
1908
+ },
1909
+ string<T extends string = string>(key: string, fallback: T, o: { oneOf?: readonly T[] } = {}): T {
1910
+ if (typeof fallback !== 'string') throw new TypeError(`net.prefs.string('${key}', fallback): the fallback must be a string, not ${kindOf(fallback)}`);
1911
+ 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)}`);
1912
+ const v = prefsRaw(key, 'string', fallback);
1913
+ if (typeof v === 'string' && (!o.oneOf || o.oneOf.includes(v as T))) return v as T;
1914
+ 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}')`);
1915
+ return fallback;
1916
+ },
1776
1917
  all: () => prefsLoad().then(() => Object.fromEntries(prefsCache)),
1777
1918
  remove: (key: string) => prefs.set(key, null),
1778
1919
  async set(key: string, value: unknown): Promise<boolean> {
1779
- if (!prefsKeyOk(key)) return false;
1780
- await prefsLoad();
1920
+ 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
1921
  const del = value === null || value === undefined;
1922
+ // NaN, Infinity, a function, a loop: JSON would keep `null` (or nothing), and the next visit would read that as
1923
+ // a setting. Refused here, in words, instead of a volume that is silent from then on.
1924
+ const bad = del ? null : unkeepable(value);
1925
+ 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; }
1926
+ await prefsLoad();
1782
1927
  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
1928
  const had = prefsCache.has(key);
1784
1929
  const before = prefsCache.get(key);
@@ -1793,6 +1938,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1793
1938
  return true;
1794
1939
  },
1795
1940
  get ready() { return prefsLoad(); },
1941
+ get loaded() { return prefsLoaded; },
1796
1942
  get where() { return prefsWhere; },
1797
1943
  };
1798
1944
 
@@ -2088,6 +2234,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2088
2234
  colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
2089
2235
  if (id) { const p = peers.get(id); if (p) { p.seat = seat; p.name = name; p.colour = colour; } }
2090
2236
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
2237
+ if (full) { full = false; post?.({ what: 'full', full: false }); paintLink(); }
2091
2238
  setRole(m['role'] === 'host' ? 'host' : 'replica', 'seated');
2092
2239
  return;
2093
2240
  }
@@ -2152,7 +2299,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2152
2299
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2153
2300
  // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
2154
2301
  // 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; }
2302
+ if (code === 'room-stale') { lastRefusal = code; warnOnce('room-stale', m['message']); if (link === 'alone') paintLink(); return; }
2156
2303
  console.warn('[netplay] relay refused:', code, m['message']);
2157
2304
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
2158
2305
  // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
@@ -2224,6 +2371,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2224
2371
  connected = true;
2225
2372
  downSince = 0;
2226
2373
  attempt = 0;
2374
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2375
+ lastRefusal = '';
2376
+ // Asked to play and every seat is taken: in the room with no seat, and seated when one frees up (a `seat` frame).
2377
+ const nowFull = m['full'] === true && seat === null && !watching;
2378
+ if (nowFull !== full) { full = nowFull; post?.({ what: 'full', full }); }
2227
2379
  emit('status', true);
2228
2380
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
2229
2381
  // First welcome, a reconnect, or coming back from an offline fallback: only a CHANGE is a role event,
@@ -2233,7 +2385,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2233
2385
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
2234
2386
  }
2235
2387
  if (continuing) { reannounce(); catchUp(had, hadSeat); }
2236
- setLink('online', 'welcome');
2388
+ if (link === 'online') paintLink(); else setLink('online', 'welcome');
2237
2389
  // A build older than the live one, let in to its own room (section 23).
2238
2390
  if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
2239
2391
  resolveView('seat');
@@ -2406,6 +2558,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2406
2558
  let statsTimer: ReturnType<typeof setInterval> | null = null;
2407
2559
  let connectTimer: ReturnType<typeof setTimeout> | undefined;
2408
2560
  let hbTimer: ReturnType<typeof setInterval> | null = null;
2561
+ /** `reconnecting` is bounded (`reconnectMaxMs`): when this fires and the room is still not back, the page plays alone. */
2562
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2563
+ /** When `ping()` last ran: one that runs seconds late (the page was blocked) says nothing about the socket. */
2564
+ let lastPingTick = 0;
2409
2565
  /** `connectClock: 'game'`: the wait for the welcome is not counted until the game says it has booted. */
2410
2566
  let clockRunning = opts.connectClock !== 'game';
2411
2567
  let lastStatsPost = 0;
@@ -2440,14 +2596,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2440
2596
 
2441
2597
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
2442
2598
  function ping(): void {
2599
+ // This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
2600
+ const beat = wall();
2601
+ const pingHeld = beat - lastPingTick > 2000 + 1500;
2602
+ lastPingTick = beat;
2443
2603
  if (!connected) {
2444
2604
  // A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
2445
2605
  // it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
2446
2606
  if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
2447
2607
  return;
2448
2608
  }
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; }
2609
+ // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect. But not on
2610
+ // the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
2611
+ // could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
2612
+ // Dropping a healthy socket there turned every long hitch into a reconnect.
2613
+ if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
2614
+ if (!pingHeld) { lost(ws, 'stale'); return; }
2615
+ // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
2616
+ // still dropped, two beats from now.
2617
+ lastMsgAt = wall() - staleMs + 2500;
2618
+ }
2451
2619
  const c = Date.now();
2452
2620
  pingsOut.set(c, c);
2453
2621
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
@@ -2535,9 +2703,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2535
2703
  offline = true;
2536
2704
  connected = false;
2537
2705
  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);
2706
+ if (full) { full = false; post?.({ what: 'full', full: false }); }
2707
+ aloneWhy = why;
2708
+ // 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
2709
+ // gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
2710
+ if (!roleKnown || role !== 'host' || why === 'reconnect-timeout') setRole('host', why);
2711
+ // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
2712
+ // page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
2713
+ if (closed) paintLink();
2714
+ else if (why !== 'offline') setLink('alone', why);
2541
2715
  }
2542
2716
 
2543
2717
  function lost(sock: WebSocketLike, why: string): void {
@@ -2551,7 +2725,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2551
2725
  if (was) emit('status', false);
2552
2726
  // It was in its room and the socket went: it knocks again with its token. (Never welcomed yet: still `connecting`,
2553
2727
  // or `alone` once it gave up waiting; stopped for good: `closed`.)
2554
- if (was && !closed) setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
2728
+ if (was && !closed) {
2729
+ setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
2730
+ // BOUNDED (section 22): a room that does not come back in reconnectMaxMs is not waited for with a frozen round
2731
+ // on screen. The page plays alone with its own bots, says so, and keeps knocking.
2732
+ if (reconnectTimer) clearTimeout(reconnectTimer);
2733
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; if (link === 'reconnecting' && !closed) goOfflineHost('reconnect-timeout'); }, reconnectMaxMs);
2734
+ }
2555
2735
  notifyStats(true);
2556
2736
  retry();
2557
2737
  }
@@ -2614,6 +2794,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2614
2794
  };
2615
2795
  connectTimer = setTimeout(connectCheck, 250);
2616
2796
  if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
2797
+ lastPingTick = wall();
2617
2798
  pingTimer = setInterval(ping, 2000);
2618
2799
  ckptTimer = setInterval(sendCheckpoint, checkpointMs);
2619
2800
  statsTimer = setInterval(() => notifyStats(true), 500);
@@ -2761,6 +2942,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2761
2942
  get link() { return link; },
2762
2943
  get reconnects() { return reconnects; },
2763
2944
  get resumed() { return resumed; },
2945
+ get full() { return full; },
2946
+ line(text: string | null): void {
2947
+ const next = typeof text === 'string' && text.trim() ? text.trim().slice(0, 120) : null;
2948
+ if (next === gameLine) return;
2949
+ gameLine = next;
2950
+ paintLink();
2951
+ },
2764
2952
  start(): void { clockRunning = true; },
2765
2953
  get version() { return version; },
2766
2954
  get stale() { return staleVer; },
@@ -3033,6 +3221,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3033
3221
  if (statsTimer) clearInterval(statsTimer);
3034
3222
  if (watchTimer) clearInterval(watchTimer);
3035
3223
  if (hbTimer) clearInterval(hbTimer);
3224
+ if (reconnectTimer) clearTimeout(reconnectTimer);
3036
3225
  if (spotTimer) clearTimeout(spotTimer);
3037
3226
  if (overlayTimer) clearTimeout(overlayTimer);
3038
3227
  if (overlayHide) clearTimeout(overlayHide);
@@ -3062,6 +3251,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3062
3251
  get revision() { return NETPLAY_REVISION; },
3063
3252
  /** Revision 9: the link, how often it was interrupted, the game's revision, and the arrival's facts (for a probe). */
3064
3253
  get link() { return link; },
3254
+ get full() { return full; },
3255
+ get line() { return overlay && !overlay.hidden ? { kind: overlay.getAttribute('data-homie-link'), text: overlay.textContent } : null; },
3065
3256
  get reconnects() { return reconnects; },
3066
3257
  get drops() { return drops; },
3067
3258
  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.31.1",
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",
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';