@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -21
  3. package/README.md +191 -139
  4. package/dist/doctor/cli.mjs +49 -19
  5. package/dist/doctor/cli.mjs.map +1 -1
  6. package/dist/shared/client-version.gen.js +1 -1
  7. package/dist/shared/daily.js +1 -1
  8. package/dist/shared/globals.js +8 -3
  9. package/dist/shared/globals.js.map +1 -1
  10. package/dist/shared/index.d.ts +1 -1
  11. package/package.json +89 -89
  12. package/src/auto.ts +39 -39
  13. package/src/core/connection.ts +299 -299
  14. package/src/core/errors.ts +147 -147
  15. package/src/core/game-data.ts +38 -38
  16. package/src/core/hub-economy.ts +179 -179
  17. package/src/core/hub-progress.ts +205 -205
  18. package/src/core/hub.ts +131 -131
  19. package/src/core/menu-control.ts +61 -61
  20. package/src/core/offline/events.ts +50 -50
  21. package/src/core/offline/favorites.ts +21 -21
  22. package/src/core/offline/local-store.ts +261 -261
  23. package/src/core/offline/queue.ts +171 -171
  24. package/src/core/page.ts +74 -74
  25. package/src/core/rest/endpoint-types.ts +22 -22
  26. package/src/core/rest/rest-client.ts +152 -152
  27. package/src/core/storage/json-guards.ts +74 -74
  28. package/src/core/storage/key-value.ts +66 -66
  29. package/src/core/ticket.ts +43 -43
  30. package/src/core/types.ts +134 -134
  31. package/src/core/version/client-version.gen.ts +1 -1
  32. package/src/core/version/hub-version.ts +62 -62
  33. package/src/core/version/semver.ts +97 -97
  34. package/src/daily/daily-system.ts +161 -161
  35. package/src/daily/page-daily.ts +36 -36
  36. package/src/daily/types.ts +67 -67
  37. package/src/daily.ts +10 -10
  38. package/src/doctor/checks.ts +78 -76
  39. package/src/doctor/cli.ts +98 -106
  40. package/src/doctor/hub-version-fetch.ts +43 -0
  41. package/src/doctor/tsconfig.json +12 -12
  42. package/src/globals.ts +115 -110
  43. package/src/index.ts +50 -50
  44. package/src/input/device-state.ts +146 -146
  45. package/src/input/edges.ts +55 -55
  46. package/src/input/gamepad.ts +61 -61
  47. package/src/input/input-events.ts +32 -32
  48. package/src/input/input-registry.ts +164 -164
  49. package/src/input/input-system.ts +393 -393
  50. package/src/input/legacy-controls.ts +64 -64
  51. package/src/input/nav-state.ts +191 -191
  52. package/src/input/navigator.ts +171 -171
  53. package/src/input/on-screen-controls.ts +101 -101
  54. package/src/input/page-control.ts +43 -43
  55. package/src/input/page-input.ts +193 -193
  56. package/src/input/player-remaps.ts +77 -77
  57. package/src/input/source-values.ts +116 -116
  58. package/src/input/spatial.ts +47 -47
  59. package/src/input/types.ts +331 -331
  60. package/src/input/zero-config.ts +177 -177
  61. package/src/input.ts +22 -22
  62. package/src/mp.ts +10 -10
  63. package/src/overlay/controls-panel.ts +163 -163
  64. package/src/overlay/dom.ts +46 -46
  65. package/src/overlay/layout-clusters.ts +162 -162
  66. package/src/overlay/layout-geometry.ts +136 -136
  67. package/src/overlay/layout.ts +203 -203
  68. package/src/overlay/listener-scope.ts +23 -23
  69. package/src/overlay/on-screen-keyboard.ts +137 -137
  70. package/src/overlay/overlay.ts +208 -208
  71. package/src/overlay/styles.ts +9 -9
  72. package/src/overlay/virtual-controls.ts +262 -262
  73. package/src/race.ts +10 -10
  74. package/src/realtime/bridge.ts +121 -121
  75. package/src/realtime/cross-bundle.ts +29 -29
  76. package/src/realtime/fallback-notice.ts +48 -48
  77. package/src/realtime/hub-link.ts +65 -65
  78. package/src/realtime/latest-state.ts +33 -33
  79. package/src/realtime/multiplayer-forwarder.ts +28 -28
  80. package/src/realtime/multiplayer-types.ts +52 -52
  81. package/src/realtime/page-runtime.ts +60 -60
  82. package/src/realtime/race-forwarder.ts +59 -59
  83. package/src/realtime/race-types.ts +132 -132
  84. package/src/realtime/room-types.ts +125 -125
  85. package/src/realtime/rooms-forwarder.ts +79 -79
  86. package/src/realtime/runtime-api.ts +49 -49
  87. package/src/realtime/runtime-opener.ts +61 -61
  88. package/src/realtime/social-forwarder.ts +72 -72
  89. package/src/realtime/social-types.ts +78 -78
  90. package/src/realtime/types.ts +53 -53
  91. package/src/realtime/wire-types.ts +72 -72
  92. package/src/rooms.ts +10 -10
  93. package/src/social.ts +18 -18
  94. package/src/types.ts +13 -13
  95. package/src/uistack/page-stack.ts +45 -45
  96. package/src/uistack/play-suspension.ts +82 -82
  97. package/src/uistack/types.ts +76 -76
  98. package/src/uistack/ui-stack.ts +144 -144
@@ -1,121 +1,121 @@
1
- /**
2
- * The bridge from the forwarders to the social runtime, which the menu loads
3
- * after the page (and which may never load). The forwarders stay thin so a
4
- * game's bundled copy never goes stale:
5
- *
6
- * - listeners are kept here and attached whenever the runtime appears;
7
- * - "latest state" calls are remembered and replayed when it appears;
8
- * - calls that return data wait up to {@link RUNTIME_WAIT_MS}, then reject
9
- * with `OfflineError`;
10
- * - with no `window.__HUB__` (a page the hub didn't inject) waits end at once.
11
- */
12
- import { hubLink } from './hub-link';
13
- import { findRealtimeApi, isHubInjectedPage } from './page-runtime';
14
- import type { RealtimeApi } from './runtime-api';
15
- import { REALTIME_READY_EVENT } from './runtime-api';
16
-
17
- /** How long data and UI calls wait for the social runtime before giving up. */
18
- export const RUNTIME_WAIT_MS = 15_000;
19
-
20
- /** Something to run with the runtime once it exists. */
21
- type RuntimeCallback = (realtime: RealtimeApi) => void;
22
-
23
- /** Resolves the runtime's realtime API, waiting for it to load when necessary. */
24
- export class RealtimeBridge {
25
- private pendingCallbacks: RuntimeCallback[] = [];
26
-
27
- constructor() {
28
- if (typeof window !== 'undefined') {
29
- window.addEventListener(REALTIME_READY_EVENT, () => {
30
- this.runPendingCallbacks();
31
- });
32
- }
33
- }
34
-
35
- /** The runtime's API if it has loaded. */
36
- current(): RealtimeApi | null {
37
- return findRealtimeApi();
38
- }
39
-
40
- /** Run `callback` as soon as the runtime exists (now, or whenever it loads). */
41
- onReady(callback: RuntimeCallback): void {
42
- const realtime = this.current();
43
- if (realtime === null) {
44
- this.pendingCallbacks.push(callback);
45
- } else {
46
- callback(realtime);
47
- }
48
- }
49
-
50
- /** The runtime, waiting up to {@link RUNTIME_WAIT_MS}; rejects with `OfflineError`. */
51
- whenReady(): Promise<RealtimeApi> {
52
- const realtime = this.current();
53
- if (realtime !== null) {
54
- return Promise.resolve(realtime);
55
- }
56
- if (!isHubInjectedPage()) {
57
- return Promise.reject(hubLink().createOfflineError('Online features are not available on this page.'));
58
- }
59
- return new Promise((resolve, reject) => {
60
- const resolveWhenReady: RuntimeCallback = (loaded) => {
61
- clearTimeout(timer);
62
- resolve(loaded);
63
- };
64
- const timer = setTimeout(() => {
65
- this.pendingCallbacks = this.pendingCallbacks.filter((callback) => callback !== resolveWhenReady);
66
- reject(hubLink().createOfflineError('Online features did not load.'));
67
- }, RUNTIME_WAIT_MS);
68
- this.pendingCallbacks.push(resolveWhenReady);
69
- });
70
- }
71
-
72
- /** Attach a listener now or on load; the returned function detaches it. */
73
- subscribe(attach: (realtime: RealtimeApi) => () => void): () => void {
74
- let detach: (() => void) | null = null;
75
- let isCancelled = false;
76
- this.onReady((realtime) => {
77
- if (!isCancelled) {
78
- detach = attach(realtime);
79
- }
80
- });
81
- return () => {
82
- isCancelled = true;
83
- detach?.();
84
- };
85
- }
86
-
87
- /** Call the runtime (waiting for it) and re-create its errors as the core client's classes. */
88
- async call<Result>(request: (realtime: RealtimeApi) => Promise<Result>): Promise<Result> {
89
- const realtime = await this.whenReady();
90
- return rethrowAsHubError(request(realtime));
91
- }
92
-
93
- private runPendingCallbacks(): void {
94
- const realtime = this.current();
95
- if (realtime === null) {
96
- return;
97
- }
98
- const callbacks = this.pendingCallbacks;
99
- this.pendingCallbacks = [];
100
- for (const callback of callbacks) {
101
- try {
102
- callback(realtime);
103
- } catch (error) {
104
- // eslint-disable-next-line no-console -- one runtime callback failing must not stop the others; surface it
105
- console.error('[hub] social runtime callback failed', error);
106
- }
107
- }
108
- }
109
- }
110
-
111
- /** The same promise, with rejections re-created as the core client's error classes. */
112
- export async function rethrowAsHubError<Result>(pending: Promise<Result>): Promise<Result> {
113
- try {
114
- return await pending;
115
- } catch (error) {
116
- throw hubLink().toHubError(error);
117
- }
118
- }
119
-
120
- /** The page's one bridge (per client bundle). */
121
- export const pageBridge = new RealtimeBridge();
1
+ /**
2
+ * The bridge from the forwarders to the social runtime, which the menu loads
3
+ * after the page (and which may never load). The forwarders stay thin so a
4
+ * game's bundled copy never goes stale:
5
+ *
6
+ * - listeners are kept here and attached whenever the runtime appears;
7
+ * - "latest state" calls are remembered and replayed when it appears;
8
+ * - calls that return data wait up to {@link RUNTIME_WAIT_MS}, then reject
9
+ * with `OfflineError`;
10
+ * - with no `window.__HUB__` (a page the hub didn't inject) waits end at once.
11
+ */
12
+ import { hubLink } from './hub-link';
13
+ import { findRealtimeApi, isHubInjectedPage } from './page-runtime';
14
+ import type { RealtimeApi } from './runtime-api';
15
+ import { REALTIME_READY_EVENT } from './runtime-api';
16
+
17
+ /** How long data and UI calls wait for the social runtime before giving up. */
18
+ export const RUNTIME_WAIT_MS = 15_000;
19
+
20
+ /** Something to run with the runtime once it exists. */
21
+ type RuntimeCallback = (realtime: RealtimeApi) => void;
22
+
23
+ /** Resolves the runtime's realtime API, waiting for it to load when necessary. */
24
+ export class RealtimeBridge {
25
+ private pendingCallbacks: RuntimeCallback[] = [];
26
+
27
+ constructor() {
28
+ if (typeof window !== 'undefined') {
29
+ window.addEventListener(REALTIME_READY_EVENT, () => {
30
+ this.runPendingCallbacks();
31
+ });
32
+ }
33
+ }
34
+
35
+ /** The runtime's API if it has loaded. */
36
+ current(): RealtimeApi | null {
37
+ return findRealtimeApi();
38
+ }
39
+
40
+ /** Run `callback` as soon as the runtime exists (now, or whenever it loads). */
41
+ onReady(callback: RuntimeCallback): void {
42
+ const realtime = this.current();
43
+ if (realtime === null) {
44
+ this.pendingCallbacks.push(callback);
45
+ } else {
46
+ callback(realtime);
47
+ }
48
+ }
49
+
50
+ /** The runtime, waiting up to {@link RUNTIME_WAIT_MS}; rejects with `OfflineError`. */
51
+ whenReady(): Promise<RealtimeApi> {
52
+ const realtime = this.current();
53
+ if (realtime !== null) {
54
+ return Promise.resolve(realtime);
55
+ }
56
+ if (!isHubInjectedPage()) {
57
+ return Promise.reject(hubLink().createOfflineError('Online features are not available on this page.'));
58
+ }
59
+ return new Promise((resolve, reject) => {
60
+ const resolveWhenReady: RuntimeCallback = (loaded) => {
61
+ clearTimeout(timer);
62
+ resolve(loaded);
63
+ };
64
+ const timer = setTimeout(() => {
65
+ this.pendingCallbacks = this.pendingCallbacks.filter((callback) => callback !== resolveWhenReady);
66
+ reject(hubLink().createOfflineError('Online features did not load.'));
67
+ }, RUNTIME_WAIT_MS);
68
+ this.pendingCallbacks.push(resolveWhenReady);
69
+ });
70
+ }
71
+
72
+ /** Attach a listener now or on load; the returned function detaches it. */
73
+ subscribe(attach: (realtime: RealtimeApi) => () => void): () => void {
74
+ let detach: (() => void) | null = null;
75
+ let isCancelled = false;
76
+ this.onReady((realtime) => {
77
+ if (!isCancelled) {
78
+ detach = attach(realtime);
79
+ }
80
+ });
81
+ return () => {
82
+ isCancelled = true;
83
+ detach?.();
84
+ };
85
+ }
86
+
87
+ /** Call the runtime (waiting for it) and re-create its errors as the core client's classes. */
88
+ async call<Result>(request: (realtime: RealtimeApi) => Promise<Result>): Promise<Result> {
89
+ const realtime = await this.whenReady();
90
+ return rethrowAsHubError(request(realtime));
91
+ }
92
+
93
+ private runPendingCallbacks(): void {
94
+ const realtime = this.current();
95
+ if (realtime === null) {
96
+ return;
97
+ }
98
+ const callbacks = this.pendingCallbacks;
99
+ this.pendingCallbacks = [];
100
+ for (const callback of callbacks) {
101
+ try {
102
+ callback(realtime);
103
+ } catch (error) {
104
+ // eslint-disable-next-line no-console -- one runtime callback failing must not stop the others; surface it
105
+ console.error('[hub] social runtime callback failed', error);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /** The same promise, with rejections re-created as the core client's error classes. */
112
+ export async function rethrowAsHubError<Result>(pending: Promise<Result>): Promise<Result> {
113
+ try {
114
+ return await pending;
115
+ } catch (error) {
116
+ throw hubLink().toHubError(error);
117
+ }
118
+ }
119
+
120
+ /** The page's one bridge (per client bundle). */
121
+ export const pageBridge = new RealtimeBridge();
@@ -1,29 +1,29 @@
1
- /**
2
- * Checks for objects another bundle put on the page (a game's copy of the
3
- * client, the host's `/_hub/hub.js`, the social runtime, a v1 vendored copy).
4
- * Their classes are not ours, so they are recognised by shape.
5
- */
6
-
7
- /** A function of any arity, as seen from outside. */
8
- type AnyMethod = (...args: never[]) => void;
9
-
10
- /** Whether `candidate` is a non-null object. */
11
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: an object from another bundle on the page
12
- export function isObject(candidate: unknown): candidate is object {
13
- return typeof candidate === 'object' && candidate !== null;
14
- }
15
-
16
- /** Whether `candidate` is an object with a function under every one of `names`. */
17
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: an object from another bundle on the page
18
- export function hasMethods<Name extends string>(candidate: unknown, names: readonly Name[]): candidate is Record<Name, AnyMethod> {
19
- if (!isObject(candidate)) {
20
- return false;
21
- }
22
- return names.every((name) => name in candidate && typeof Reflect.get(candidate, name) === 'function');
23
- }
24
-
25
- /** The value under `key` on another bundle's object, or `undefined`. */
26
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: reads a field of another bundle's object, checked by the caller
27
- export function readField(candidate: unknown, key: string): unknown {
28
- return isObject(candidate) ? Reflect.get(candidate, key) : undefined;
29
- }
1
+ /**
2
+ * Checks for objects another bundle put on the page (a game's copy of the
3
+ * client, the host's `/_hub/hub.js`, the social runtime, a v1 vendored copy).
4
+ * Their classes are not ours, so they are recognised by shape.
5
+ */
6
+
7
+ /** A function of any arity, as seen from outside. */
8
+ type AnyMethod = (...args: never[]) => void;
9
+
10
+ /** Whether `candidate` is a non-null object. */
11
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an object from another bundle on the page
12
+ export function isObject(candidate: unknown): candidate is object {
13
+ return typeof candidate === 'object' && candidate !== null;
14
+ }
15
+
16
+ /** Whether `candidate` is an object with a function under every one of `names`. */
17
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an object from another bundle on the page
18
+ export function hasMethods<Name extends string>(candidate: unknown, names: readonly Name[]): candidate is Record<Name, AnyMethod> {
19
+ if (!isObject(candidate)) {
20
+ return false;
21
+ }
22
+ return names.every((name) => name in candidate && typeof Reflect.get(candidate, name) === 'function');
23
+ }
24
+
25
+ /** The value under `key` on another bundle's object, or `undefined`. */
26
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: reads a field of another bundle's object, checked by the caller
27
+ export function readField(candidate: unknown, key: string): unknown {
28
+ return isObject(candidate) ? Reflect.get(candidate, key) : undefined;
29
+ }
@@ -1,48 +1,48 @@
1
- /**
2
- * A minimal top-centre notice for when the social runtime (which owns the
3
- * real toasts) isn't available: plain DOM with inline styles in the hub
4
- * palette, one at a time.
5
- */
6
- import { pageBody } from '../overlay/dom';
7
-
8
- const NOTICE_ELEMENT_ID = 'hub-fallback-notice';
9
- const NOTICE_STYLE = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;'
10
- + 'max-width:min(92vw,420px);padding:10px 14px;border-radius:10px;font:13px/1.4 "Space Mono",ui-monospace,monospace;'
11
- + 'background:#11141c;color:#e8e6df;border:1px solid #2a3142;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:none;';
12
-
13
- let hideTimer: ReturnType<typeof setTimeout> | null = null;
14
-
15
- /** Show `text`; it hides itself after `visibleMs` (0 = until replaced or hidden). */
16
- export function showFallbackNotice(text: string, visibleMs = 0): void {
17
- if (typeof document === 'undefined') {
18
- return;
19
- }
20
- const body = pageBody();
21
- if (body === null) {
22
- return;
23
- }
24
- let notice = document.getElementById(NOTICE_ELEMENT_ID);
25
- if (notice === null) {
26
- notice = document.createElement('div');
27
- notice.id = NOTICE_ELEMENT_ID;
28
- notice.setAttribute('role', 'status');
29
- notice.style.cssText = NOTICE_STYLE;
30
- body.appendChild(notice);
31
- }
32
- notice.textContent = text;
33
- if (hideTimer !== null) {
34
- clearTimeout(hideTimer);
35
- }
36
- hideTimer = visibleMs > 0 ? setTimeout(hideFallbackNotice, visibleMs) : null;
37
- }
38
-
39
- /** Remove the notice. */
40
- export function hideFallbackNotice(): void {
41
- if (hideTimer !== null) {
42
- clearTimeout(hideTimer);
43
- hideTimer = null;
44
- }
45
- if (typeof document !== 'undefined') {
46
- document.getElementById(NOTICE_ELEMENT_ID)?.remove();
47
- }
48
- }
1
+ /**
2
+ * A minimal top-centre notice for when the social runtime (which owns the
3
+ * real toasts) isn't available: plain DOM with inline styles in the hub
4
+ * palette, one at a time.
5
+ */
6
+ import { pageBody } from '../overlay/dom';
7
+
8
+ const NOTICE_ELEMENT_ID = 'hub-fallback-notice';
9
+ const NOTICE_STYLE = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;'
10
+ + 'max-width:min(92vw,420px);padding:10px 14px;border-radius:10px;font:13px/1.4 "Space Mono",ui-monospace,monospace;'
11
+ + 'background:#11141c;color:#e8e6df;border:1px solid #2a3142;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:none;';
12
+
13
+ let hideTimer: ReturnType<typeof setTimeout> | null = null;
14
+
15
+ /** Show `text`; it hides itself after `visibleMs` (0 = until replaced or hidden). */
16
+ export function showFallbackNotice(text: string, visibleMs = 0): void {
17
+ if (typeof document === 'undefined') {
18
+ return;
19
+ }
20
+ const body = pageBody();
21
+ if (body === null) {
22
+ return;
23
+ }
24
+ let notice = document.getElementById(NOTICE_ELEMENT_ID);
25
+ if (notice === null) {
26
+ notice = document.createElement('div');
27
+ notice.id = NOTICE_ELEMENT_ID;
28
+ notice.setAttribute('role', 'status');
29
+ notice.style.cssText = NOTICE_STYLE;
30
+ body.appendChild(notice);
31
+ }
32
+ notice.textContent = text;
33
+ if (hideTimer !== null) {
34
+ clearTimeout(hideTimer);
35
+ }
36
+ hideTimer = visibleMs > 0 ? setTimeout(hideFallbackNotice, visibleMs) : null;
37
+ }
38
+
39
+ /** Remove the notice. */
40
+ export function hideFallbackNotice(): void {
41
+ if (hideTimer !== null) {
42
+ clearTimeout(hideTimer);
43
+ hideTimer = null;
44
+ }
45
+ if (typeof document !== 'undefined') {
46
+ document.getElementById(NOTICE_ELEMENT_ID)?.remove();
47
+ }
48
+ }
@@ -1,65 +1,65 @@
1
- /**
2
- * What the forwarders need from the core client: its error classes (so a
3
- * runtime rejection is an `instanceof HubError` in the game) and the direct
4
- * own-server ticket request. The core client links itself at load with
5
- * {@link linkRealtimeToHub}; until then plain errors with the same fields are
6
- * used, so a forwarder imported on its own still works.
7
- */
8
- import { readField } from './cross-bundle';
9
- import type { RealtimeErrorShape } from './wire-types';
10
-
11
- /** The core client's side of the link. */
12
- export interface RealtimeHubLink {
13
- /** Re-create an error from the runtime (another bundle) as this client's `HubError` family. */
14
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a rejection from another bundle
15
- toHubError(error: unknown): Error;
16
- /** This client's `OfflineError` with a message. */
17
- createOfflineError(message: string): Error;
18
- /** A 60-second identity ticket for this game's own server (`POST /mp/ticket`), straight from the hub. */
19
- getGameServerTicket(): Promise<string>;
20
- }
21
-
22
- /** Copy the error-body fields of another bundle's error onto a plain `Error`. */
23
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a rejection from another bundle
24
- function copyAsPlainError(error: unknown): RealtimeErrorShape {
25
- if (error instanceof Error) {
26
- return error;
27
- }
28
- const message = readField(error, 'message');
29
- const copy: RealtimeErrorShape = new Error(typeof message === 'string' ? message : String(error));
30
- for (const key of ['status', 'retryAfterMs'] as const) {
31
- const field = readField(error, key);
32
- if (typeof field === 'number') {
33
- copy[key] = field;
34
- }
35
- }
36
- for (const key of ['code', 'reason', 'hint'] as const) {
37
- const field = readField(error, key);
38
- if (typeof field === 'string') {
39
- copy[key] = field;
40
- }
41
- }
42
- return copy;
43
- }
44
-
45
- const standaloneLink: RealtimeHubLink = {
46
- toHubError: copyAsPlainError,
47
- createOfflineError(message) {
48
- const offline: RealtimeErrorShape = new Error(message);
49
- offline.code = 'offline';
50
- return offline;
51
- },
52
- getGameServerTicket: () => Promise.reject(new Error('Hub: import @diffenderfer-games/hub to request game-server tickets.')),
53
- };
54
-
55
- let currentLink: RealtimeHubLink = standaloneLink;
56
-
57
- /** Called once by the core client so forwarder errors use its classes and tickets use its REST client. */
58
- export function linkRealtimeToHub(link: RealtimeHubLink): void {
59
- currentLink = link;
60
- }
61
-
62
- /** The link in effect. */
63
- export function hubLink(): RealtimeHubLink {
64
- return currentLink;
65
- }
1
+ /**
2
+ * What the forwarders need from the core client: its error classes (so a
3
+ * runtime rejection is an `instanceof HubError` in the game) and the direct
4
+ * own-server ticket request. The core client links itself at load with
5
+ * {@link linkRealtimeToHub}; until then plain errors with the same fields are
6
+ * used, so a forwarder imported on its own still works.
7
+ */
8
+ import { readField } from './cross-bundle';
9
+ import type { RealtimeErrorShape } from './wire-types';
10
+
11
+ /** The core client's side of the link. */
12
+ export interface RealtimeHubLink {
13
+ /** Re-create an error from the runtime (another bundle) as this client's `HubError` family. */
14
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a rejection from another bundle
15
+ toHubError(error: unknown): Error;
16
+ /** This client's `OfflineError` with a message. */
17
+ createOfflineError(message: string): Error;
18
+ /** A 60-second identity ticket for this game's own server (`POST /mp/ticket`), straight from the hub. */
19
+ getGameServerTicket(): Promise<string>;
20
+ }
21
+
22
+ /** Copy the error-body fields of another bundle's error onto a plain `Error`. */
23
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a rejection from another bundle
24
+ function copyAsPlainError(error: unknown): RealtimeErrorShape {
25
+ if (error instanceof Error) {
26
+ return error;
27
+ }
28
+ const message = readField(error, 'message');
29
+ const copy: RealtimeErrorShape = new Error(typeof message === 'string' ? message : String(error));
30
+ for (const key of ['status', 'retryAfterMs'] as const) {
31
+ const field = readField(error, key);
32
+ if (typeof field === 'number') {
33
+ copy[key] = field;
34
+ }
35
+ }
36
+ for (const key of ['code', 'reason', 'hint'] as const) {
37
+ const field = readField(error, key);
38
+ if (typeof field === 'string') {
39
+ copy[key] = field;
40
+ }
41
+ }
42
+ return copy;
43
+ }
44
+
45
+ const standaloneLink: RealtimeHubLink = {
46
+ toHubError: copyAsPlainError,
47
+ createOfflineError(message) {
48
+ const offline: RealtimeErrorShape = new Error(message);
49
+ offline.code = 'offline';
50
+ return offline;
51
+ },
52
+ getGameServerTicket: () => Promise.reject(new Error('Hub: import @diffenderfer-games/hub to request game-server tickets.')),
53
+ };
54
+
55
+ let currentLink: RealtimeHubLink = standaloneLink;
56
+
57
+ /** Called once by the core client so forwarder errors use its classes and tickets use its REST client. */
58
+ export function linkRealtimeToHub(link: RealtimeHubLink): void {
59
+ currentLink = link;
60
+ }
61
+
62
+ /** The link in effect. */
63
+ export function hubLink(): RealtimeHubLink {
64
+ return currentLink;
65
+ }
@@ -1,33 +1,33 @@
1
- /**
2
- * The "latest state" calls (presence, the busy flag, quiet mode): only the
3
- * last value matters, so each is remembered and replayed, in that order,
4
- * when the social runtime loads.
5
- */
6
- import type { PresenceInput } from '@diffenderfer-games/hub-contract';
7
- import { pageBridge } from './bridge';
8
- import type { BusyOptions } from './multiplayer-types';
9
-
10
- /** The busy flag as last set. */
11
- interface BusyState {
12
- readonly isBusy: boolean;
13
- readonly options: BusyOptions;
14
- }
15
-
16
- /** The latest state calls, as last made on this page. */
17
- export const latestState: { presence: PresenceInput | null; busy: BusyState | null; isQuiet: boolean | null } = {
18
- presence: null,
19
- busy: null,
20
- isQuiet: null,
21
- };
22
-
23
- pageBridge.onReady((realtime) => {
24
- if (latestState.presence !== null) {
25
- realtime.presence.set(latestState.presence);
26
- }
27
- if (latestState.busy !== null) {
28
- realtime.mp.setBusy(latestState.busy.isBusy, latestState.busy.options);
29
- }
30
- if (latestState.isQuiet !== null) {
31
- realtime.notify.setQuiet(latestState.isQuiet);
32
- }
33
- });
1
+ /**
2
+ * The "latest state" calls (presence, the busy flag, quiet mode): only the
3
+ * last value matters, so each is remembered and replayed, in that order,
4
+ * when the social runtime loads.
5
+ */
6
+ import type { PresenceInput } from '@diffenderfer-games/hub-contract';
7
+ import { pageBridge } from './bridge';
8
+ import type { BusyOptions } from './multiplayer-types';
9
+
10
+ /** The busy flag as last set. */
11
+ interface BusyState {
12
+ readonly isBusy: boolean;
13
+ readonly options: BusyOptions;
14
+ }
15
+
16
+ /** The latest state calls, as last made on this page. */
17
+ export const latestState: { presence: PresenceInput | null; busy: BusyState | null; isQuiet: boolean | null } = {
18
+ presence: null,
19
+ busy: null,
20
+ isQuiet: null,
21
+ };
22
+
23
+ pageBridge.onReady((realtime) => {
24
+ if (latestState.presence !== null) {
25
+ realtime.presence.set(latestState.presence);
26
+ }
27
+ if (latestState.busy !== null) {
28
+ realtime.mp.setBusy(latestState.busy.isBusy, latestState.busy.options);
29
+ }
30
+ if (latestState.isQuiet !== null) {
31
+ realtime.notify.setQuiet(latestState.isQuiet);
32
+ }
33
+ });
@@ -1,28 +1,28 @@
1
- /**
2
- * `hub.mp`: invites, launches, the leave guard and own-server tickets,
3
- * forwarded to the social runtime.
4
- */
5
- import { getUiStack } from '../uistack/page-stack';
6
- import { pageBridge } from './bridge';
7
- import { hubLink } from './hub-link';
8
- import { latestState } from './latest-state';
9
- import type { HubMpApi } from './multiplayer-types';
10
-
11
- /** `hub.mp`: the multiplayer lifecycle. */
12
- export const multiplayerForwarder: HubMpApi = {
13
- onLaunch: (listener) => pageBridge.subscribe((realtime) => realtime.mp.onLaunch(listener)),
14
- onJoinInfo: (listener) => pageBridge.subscribe((realtime) => realtime.mp.onJoinInfo(listener)),
15
- invite: (userId, options) => pageBridge.call((realtime) => realtime.mp.invite(userId, options)),
16
- setBusy(isBusy, options) {
17
- const busyOptions = options?.label === undefined ? {} : { label: options.label };
18
- latestState.busy = { isBusy, options: busyOptions };
19
- // The overlay stack is local, so "can't pause" works even without the runtime.
20
- getUiStack().setCanPause(!isBusy);
21
- pageBridge.current()?.mp.setBusy(isBusy, busyOptions);
22
- },
23
- // A direct request, not a runtime call: "Play online" must not depend on the
24
- // social runtime having loaded (an old cached menu may never load it).
25
- ticket: () => hubLink().getGameServerTicket(),
26
- setJoinInfo: (partyId, joinInfo) => pageBridge.call((realtime) => realtime.mp.setJoinInfo(partyId, joinInfo)),
27
- party: () => pageBridge.current()?.mp.party() ?? null,
28
- };
1
+ /**
2
+ * `hub.mp`: invites, launches, the leave guard and own-server tickets,
3
+ * forwarded to the social runtime.
4
+ */
5
+ import { getUiStack } from '../uistack/page-stack';
6
+ import { pageBridge } from './bridge';
7
+ import { hubLink } from './hub-link';
8
+ import { latestState } from './latest-state';
9
+ import type { HubMpApi } from './multiplayer-types';
10
+
11
+ /** `hub.mp`: the multiplayer lifecycle. */
12
+ export const multiplayerForwarder: HubMpApi = {
13
+ onLaunch: (listener) => pageBridge.subscribe((realtime) => realtime.mp.onLaunch(listener)),
14
+ onJoinInfo: (listener) => pageBridge.subscribe((realtime) => realtime.mp.onJoinInfo(listener)),
15
+ invite: (userId, options) => pageBridge.call((realtime) => realtime.mp.invite(userId, options)),
16
+ setBusy(isBusy, options) {
17
+ const busyOptions = options?.label === undefined ? {} : { label: options.label };
18
+ latestState.busy = { isBusy, options: busyOptions };
19
+ // The overlay stack is local, so "can't pause" works even without the runtime.
20
+ getUiStack().setCanPause(!isBusy);
21
+ pageBridge.current()?.mp.setBusy(isBusy, busyOptions);
22
+ },
23
+ // A direct request, not a runtime call: "Play online" must not depend on the
24
+ // social runtime having loaded (an old cached menu may never load it).
25
+ ticket: () => hubLink().getGameServerTicket(),
26
+ setJoinInfo: (partyId, joinInfo) => pageBridge.call((realtime) => realtime.mp.setJoinInfo(partyId, joinInfo)),
27
+ party: () => pageBridge.current()?.mp.party() ?? null,
28
+ };