@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.
- package/CHANGELOG.md +33 -1
- package/README.md +10 -6
- package/bin/homie-studio.mjs +71 -6
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +10 -9
- package/lib/mcp-tools.mjs +13 -9
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/template-history.json +40 -0
- package/netplay/NETPLAY.md +138 -10
- package/netplay/netplay.ts +220 -29
- package/package.json +1 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/version.mjs +1 -1
package/netplay/netplay.ts
CHANGED
|
@@ -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`);
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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)
|
|
145
|
-
* answered in time,
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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`
|
|
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…"
|
|
586
|
-
* off (listen to `link` and draw your own).
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
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
|
-
|
|
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 (
|
|
2539
|
-
|
|
2540
|
-
|
|
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)
|
|
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.
|
|
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';
|