@diffenderfer-games/hub 0.0.0-stage → 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 (164) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +191 -3
  4. package/dist/auto.d.ts +36 -0
  5. package/dist/auto.d.ts.map +1 -0
  6. package/dist/auto.js +27 -0
  7. package/dist/auto.js.map +1 -0
  8. package/dist/daily.d.ts +2 -0
  9. package/dist/daily.js +1 -0
  10. package/dist/doctor/cli.mjs +268 -0
  11. package/dist/doctor/cli.mjs.map +1 -0
  12. package/dist/index.d.ts +12 -0
  13. package/dist/index.js +6 -0
  14. package/dist/input.d.ts +2 -0
  15. package/dist/input.js +3 -0
  16. package/dist/mp.d.ts +4 -0
  17. package/dist/mp.js +1 -0
  18. package/dist/race.d.ts +4 -0
  19. package/dist/race.js +1 -0
  20. package/dist/rooms.d.ts +4 -0
  21. package/dist/rooms.js +1 -0
  22. package/dist/shared/bridge.js +209 -0
  23. package/dist/shared/bridge.js.map +1 -0
  24. package/dist/shared/client-version.gen.js +7 -0
  25. package/dist/shared/client-version.gen.js.map +1 -0
  26. package/dist/shared/daily.d.ts +123 -0
  27. package/dist/shared/daily.d.ts.map +1 -0
  28. package/dist/shared/daily.js +191 -0
  29. package/dist/shared/daily.js.map +1 -0
  30. package/dist/shared/dom.js +48 -0
  31. package/dist/shared/dom.js.map +1 -0
  32. package/dist/shared/globals.js +102 -0
  33. package/dist/shared/globals.js.map +1 -0
  34. package/dist/shared/index.d.ts +706 -0
  35. package/dist/shared/index.d.ts.map +1 -0
  36. package/dist/shared/input.d.ts +730 -0
  37. package/dist/shared/input.d.ts.map +1 -0
  38. package/dist/shared/input.js +2718 -0
  39. package/dist/shared/input.js.map +1 -0
  40. package/dist/shared/latest-state.js +17 -0
  41. package/dist/shared/latest-state.js.map +1 -0
  42. package/dist/shared/mp.d.ts +9 -0
  43. package/dist/shared/mp.d.ts.map +1 -0
  44. package/dist/shared/mp.js +30 -0
  45. package/dist/shared/mp.js.map +1 -0
  46. package/dist/shared/multiplayer-types.d.ts +47 -0
  47. package/dist/shared/multiplayer-types.d.ts.map +1 -0
  48. package/dist/shared/page-stack.js +207 -0
  49. package/dist/shared/page-stack.js.map +1 -0
  50. package/dist/shared/race-types.d.ts +126 -0
  51. package/dist/shared/race-types.d.ts.map +1 -0
  52. package/dist/shared/race.d.ts +9 -0
  53. package/dist/shared/race.d.ts.map +1 -0
  54. package/dist/shared/race.js +53 -0
  55. package/dist/shared/race.js.map +1 -0
  56. package/dist/shared/room-types.d.ts +125 -0
  57. package/dist/shared/room-types.d.ts.map +1 -0
  58. package/dist/shared/rooms.d.ts +9 -0
  59. package/dist/shared/rooms.d.ts.map +1 -0
  60. package/dist/shared/rooms.js +62 -0
  61. package/dist/shared/rooms.js.map +1 -0
  62. package/dist/shared/runtime-opener.js +84 -0
  63. package/dist/shared/runtime-opener.js.map +1 -0
  64. package/dist/shared/social.d.ts +148 -0
  65. package/dist/shared/social.d.ts.map +1 -0
  66. package/dist/shared/social.js +73 -0
  67. package/dist/shared/social.js.map +1 -0
  68. package/dist/shared/src.js +2164 -0
  69. package/dist/shared/src.js.map +1 -0
  70. package/dist/shared/wire-types.d.ts +51 -0
  71. package/dist/shared/wire-types.d.ts.map +1 -0
  72. package/dist/social.d.ts +6 -0
  73. package/dist/social.js +3 -0
  74. package/dist/types.d.ts +1 -0
  75. package/dist/types.js +0 -0
  76. package/package.json +89 -6
  77. package/src/auto.ts +39 -0
  78. package/src/core/connection.ts +299 -0
  79. package/src/core/errors.ts +147 -0
  80. package/src/core/game-data.ts +38 -0
  81. package/src/core/hub-economy.ts +179 -0
  82. package/src/core/hub-progress.ts +205 -0
  83. package/src/core/hub.ts +131 -0
  84. package/src/core/menu-control.ts +61 -0
  85. package/src/core/offline/events.ts +50 -0
  86. package/src/core/offline/favorites.ts +21 -0
  87. package/src/core/offline/local-store.ts +261 -0
  88. package/src/core/offline/queue.ts +171 -0
  89. package/src/core/page.ts +74 -0
  90. package/src/core/rest/endpoint-types.ts +22 -0
  91. package/src/core/rest/rest-client.ts +152 -0
  92. package/src/core/rest/routes.gen.ts +90 -0
  93. package/src/core/storage/json-guards.ts +74 -0
  94. package/src/core/storage/key-value.ts +66 -0
  95. package/src/core/ticket.ts +43 -0
  96. package/src/core/types.ts +134 -0
  97. package/src/core/version/client-version.gen.ts +3 -0
  98. package/src/core/version/hub-version.ts +62 -0
  99. package/src/core/version/semver.ts +97 -0
  100. package/src/daily/daily-system.ts +161 -0
  101. package/src/daily/page-daily.ts +36 -0
  102. package/src/daily/types.ts +67 -0
  103. package/src/daily.ts +10 -0
  104. package/src/doctor/checks.ts +78 -0
  105. package/src/doctor/cli.ts +98 -0
  106. package/src/doctor/hub-version-fetch.ts +43 -0
  107. package/src/doctor/tsconfig.json +12 -0
  108. package/src/globals.ts +115 -0
  109. package/src/index.ts +50 -0
  110. package/src/input/device-state.ts +146 -0
  111. package/src/input/edges.ts +55 -0
  112. package/src/input/gamepad.ts +61 -0
  113. package/src/input/input-events.ts +32 -0
  114. package/src/input/input-registry.ts +164 -0
  115. package/src/input/input-system.ts +393 -0
  116. package/src/input/legacy-controls.ts +64 -0
  117. package/src/input/nav-state.ts +191 -0
  118. package/src/input/navigator.ts +171 -0
  119. package/src/input/on-screen-controls.ts +101 -0
  120. package/src/input/page-control.ts +43 -0
  121. package/src/input/page-input.ts +193 -0
  122. package/src/input/player-remaps.ts +77 -0
  123. package/src/input/source-values.ts +116 -0
  124. package/src/input/spatial.ts +47 -0
  125. package/src/input/types.ts +331 -0
  126. package/src/input/zero-config.ts +177 -0
  127. package/src/input.ts +22 -0
  128. package/src/mp.ts +10 -0
  129. package/src/overlay/controls-panel.ts +163 -0
  130. package/src/overlay/dom.ts +46 -0
  131. package/src/overlay/layout-clusters.ts +162 -0
  132. package/src/overlay/layout-geometry.ts +136 -0
  133. package/src/overlay/layout.ts +203 -0
  134. package/src/overlay/listener-scope.ts +23 -0
  135. package/src/overlay/on-screen-keyboard.ts +137 -0
  136. package/src/overlay/overlay.ts +208 -0
  137. package/src/overlay/styles.ts +54 -0
  138. package/src/overlay/virtual-controls.ts +262 -0
  139. package/src/race.ts +10 -0
  140. package/src/realtime/bridge.ts +121 -0
  141. package/src/realtime/cross-bundle.ts +29 -0
  142. package/src/realtime/fallback-notice.ts +48 -0
  143. package/src/realtime/hub-link.ts +65 -0
  144. package/src/realtime/latest-state.ts +33 -0
  145. package/src/realtime/multiplayer-forwarder.ts +28 -0
  146. package/src/realtime/multiplayer-types.ts +52 -0
  147. package/src/realtime/page-runtime.ts +60 -0
  148. package/src/realtime/race-forwarder.ts +59 -0
  149. package/src/realtime/race-types.ts +132 -0
  150. package/src/realtime/room-types.ts +125 -0
  151. package/src/realtime/rooms-forwarder.ts +79 -0
  152. package/src/realtime/runtime-api.ts +49 -0
  153. package/src/realtime/runtime-opener.ts +61 -0
  154. package/src/realtime/social-forwarder.ts +72 -0
  155. package/src/realtime/social-types.ts +78 -0
  156. package/src/realtime/types.ts +53 -0
  157. package/src/realtime/wire-types.ts +72 -0
  158. package/src/rooms.ts +10 -0
  159. package/src/social.ts +18 -0
  160. package/src/types.ts +13 -0
  161. package/src/uistack/page-stack.ts +45 -0
  162. package/src/uistack/play-suspension.ts +82 -0
  163. package/src/uistack/types.ts +76 -0
  164. package/src/uistack/ui-stack.ts +144 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Building one on-screen control (button, tap region, joystick or dpad) and
3
+ * wiring its touch and mouse handling, plus drag-to-move while the player
4
+ * edits the layout.
5
+ */
6
+ import type { VirtualDef } from '../input/types';
7
+ import { placeAt, requireElement } from './dom';
8
+ import { declaredSize, isStickType } from './layout-geometry';
9
+ import type { ListenerScope } from './listener-scope';
10
+ import { ACCENT_COLOR } from './styles';
11
+
12
+ /** What a control reports. */
13
+ export interface VirtualControlCallbacks {
14
+ /** A button went down. */
15
+ onButtonDown(id: string): void;
16
+ /** A button came up. */
17
+ onButtonUp(id: string): void;
18
+ /** A stick moved (-1..1 per axis; 0, 0 on release). */
19
+ onStick(id: string, outX: number, outY: number): void;
20
+ /** A tap region was tapped. */
21
+ onTap(): void;
22
+ /** The player dropped a dragged control: its top-left corner as % of the viewport. */
23
+ onDragged(id: string, spot: { x: number; y: number }): void;
24
+ }
25
+
26
+ /** What controls need from the overlay. */
27
+ export interface VirtualControlEnvironment {
28
+ readonly callbacks: VirtualControlCallbacks;
29
+ /** Window listeners, removed when the controls are rebuilt. */
30
+ readonly scope: ListenerScope;
31
+ /** Whether the player is editing the layout (controls drag instead of acting). */
32
+ isEditing(): boolean;
33
+ }
34
+
35
+ /** A dpad snaps past this deflection. */
36
+ const DPAD_THRESHOLD = 0.4;
37
+
38
+ const RADIUS_BY_SHAPE = { circle: '50%', pill: '999px', square: '0', round: '16px' } as const;
39
+
40
+ /** The first touch of a touch event. */
41
+ function firstTouch(event: TouchEvent): Touch | undefined {
42
+ return event.changedTouches[0];
43
+ }
44
+
45
+ function bindButton(id: string, element: HTMLElement, environment: VirtualControlEnvironment): void {
46
+ const { callbacks } = environment;
47
+ const isTapRegion = element.classList.contains('vc-transparent');
48
+ const press = (event: Event): void => {
49
+ if (environment.isEditing()) {
50
+ return;
51
+ }
52
+ event.preventDefault();
53
+ element.classList.add('pressed');
54
+ callbacks.onButtonDown(id);
55
+ if (isTapRegion) {
56
+ callbacks.onTap();
57
+ }
58
+ };
59
+ const release = (event: Event): void => {
60
+ event.preventDefault();
61
+ element.classList.remove('pressed');
62
+ callbacks.onButtonUp(id);
63
+ };
64
+ element.addEventListener('touchstart', press, { passive: false });
65
+ element.addEventListener('touchend', release, { passive: false });
66
+ element.addEventListener('touchcancel', release, { passive: false });
67
+ element.addEventListener('mousedown', press);
68
+ environment.scope.listenWindow('mouseup', release);
69
+ }
70
+
71
+ function bindStick(id: string, wrapper: HTMLElement, knob: HTMLElement, definition: VirtualDef, environment: VirtualControlEnvironment): void {
72
+ const isDpad = definition.type === 'dpad';
73
+ const axisMode = definition.axis ?? 'both';
74
+ const { callbacks } = environment;
75
+ let activeTouchId = -1;
76
+ let centreX = 0;
77
+ let centreY = 0;
78
+ let travelX = 1;
79
+ let travelY = 1;
80
+ const measure = (): void => {
81
+ const bounds = wrapper.getBoundingClientRect();
82
+ centreX = bounds.left + bounds.width / 2;
83
+ centreY = bounds.top + bounds.height / 2;
84
+ const knobRadius = knob.getBoundingClientRect().width / 2 || Math.min(bounds.width, bounds.height) * 0.25;
85
+ travelX = Math.max(1, bounds.width / 2 - knobRadius);
86
+ travelY = Math.max(1, bounds.height / 2 - knobRadius);
87
+ };
88
+ const moveTo = (pointerX: number, pointerY: number): void => {
89
+ let dx = axisMode === 'y' ? 0 : pointerX - centreX;
90
+ let dy = axisMode === 'x' ? 0 : pointerY - centreY;
91
+ if (axisMode === 'both') {
92
+ const limit = Math.min(travelX, travelY);
93
+ const distance = Math.hypot(dx, dy);
94
+ if (distance > limit) {
95
+ dx = (dx * limit) / distance;
96
+ dy = (dy * limit) / distance;
97
+ }
98
+ } else {
99
+ dx = Math.max(-travelX, Math.min(travelX, dx));
100
+ dy = Math.max(-travelY, Math.min(travelY, dy));
101
+ }
102
+ let outX = dx / travelX;
103
+ let outY = dy / travelY;
104
+ if (isDpad) {
105
+ outX = Math.abs(outX) > DPAD_THRESHOLD ? Math.sign(outX) : 0;
106
+ outY = Math.abs(outY) > DPAD_THRESHOLD ? Math.sign(outY) : 0;
107
+ }
108
+ knob.style.transform = `translate(${String(dx)}px, ${String(dy)}px)`;
109
+ callbacks.onStick(id, outX, outY);
110
+ };
111
+ const release = (): void => {
112
+ activeTouchId = -1;
113
+ knob.style.transform = 'translate(0,0)';
114
+ callbacks.onStick(id, 0, 0);
115
+ };
116
+ wrapper.addEventListener('touchstart', (event) => {
117
+ const touch = firstTouch(event);
118
+ if (environment.isEditing() || touch === undefined) {
119
+ return;
120
+ }
121
+ event.preventDefault();
122
+ measure();
123
+ activeTouchId = touch.identifier;
124
+ moveTo(touch.clientX, touch.clientY);
125
+ }, { passive: false });
126
+ wrapper.addEventListener('touchmove', (event) => {
127
+ event.preventDefault();
128
+ for (const touch of event.changedTouches) {
129
+ if (touch.identifier === activeTouchId) {
130
+ moveTo(touch.clientX, touch.clientY);
131
+ }
132
+ }
133
+ }, { passive: false });
134
+ wrapper.addEventListener('touchend', release, { passive: false });
135
+ wrapper.addEventListener('touchcancel', release, { passive: false });
136
+ // Mouse drag (desktop testing).
137
+ let isMouseHeld = false;
138
+ wrapper.addEventListener('mousedown', (event) => {
139
+ if (environment.isEditing()) {
140
+ return;
141
+ }
142
+ measure();
143
+ isMouseHeld = true;
144
+ moveTo(event.clientX, event.clientY);
145
+ });
146
+ environment.scope.listenWindow('mousemove', (event) => {
147
+ if (isMouseHeld) {
148
+ moveTo(event.clientX, event.clientY);
149
+ }
150
+ });
151
+ environment.scope.listenWindow('mouseup', () => {
152
+ if (isMouseHeld) {
153
+ isMouseHeld = false;
154
+ release();
155
+ }
156
+ });
157
+ }
158
+
159
+ /** Drag-to-move while editing: the control keeps its grab point under the finger and stays fully on-screen. */
160
+ function bindEditDrag(id: string, element: HTMLElement, environment: VirtualControlEnvironment): void {
161
+ let isDragging = false;
162
+ let grabX = 0;
163
+ let grabY = 0;
164
+ let droppedSpot: { x: number; y: number } | null = null;
165
+ const start = (pointerX: number, pointerY: number): void => {
166
+ if (!environment.isEditing()) {
167
+ return;
168
+ }
169
+ isDragging = true;
170
+ const bounds = element.getBoundingClientRect();
171
+ grabX = pointerX - bounds.left;
172
+ grabY = pointerY - bounds.top;
173
+ droppedSpot = null;
174
+ };
175
+ const move = (pointerX: number, pointerY: number): void => {
176
+ if (!isDragging) {
177
+ return;
178
+ }
179
+ const viewportWidth = window.innerWidth;
180
+ const viewportHeight = window.innerHeight;
181
+ const left = Math.max(0, Math.min(viewportWidth - element.offsetWidth, pointerX - grabX));
182
+ const top = Math.max(0, Math.min(viewportHeight - element.offsetHeight, pointerY - grabY));
183
+ placeAt(element, left, top);
184
+ droppedSpot = { x: (left / viewportWidth) * 100, y: (top / viewportHeight) * 100 };
185
+ };
186
+ const end = (): void => {
187
+ if (!isDragging) {
188
+ return;
189
+ }
190
+ isDragging = false;
191
+ if (droppedSpot !== null) {
192
+ environment.callbacks.onDragged(id, droppedSpot);
193
+ }
194
+ };
195
+ element.addEventListener('mousedown', (event) => {
196
+ start(event.clientX, event.clientY);
197
+ });
198
+ element.addEventListener('touchstart', (event) => {
199
+ const touch = firstTouch(event);
200
+ if (touch !== undefined) {
201
+ start(touch.clientX, touch.clientY);
202
+ }
203
+ }, { passive: true });
204
+ environment.scope.listenWindow('mousemove', (event) => {
205
+ move(event.clientX, event.clientY);
206
+ });
207
+ environment.scope.listenWindow('touchmove', (event) => {
208
+ const touch = firstTouch(event);
209
+ if (isDragging && touch !== undefined) {
210
+ move(touch.clientX, touch.clientY);
211
+ }
212
+ });
213
+ environment.scope.listenWindow('mouseup', end);
214
+ environment.scope.listenWindow('touchend', end);
215
+ }
216
+
217
+ /** Build one on-screen control element, wired up. */
218
+ export function createVirtualControl(definition: VirtualDef, environment: VirtualControlEnvironment): HTMLElement {
219
+ const isStick = isStickType(definition.type);
220
+ const { w: width, h: height } = declaredSize(definition);
221
+ const wrapper = document.createElement('div');
222
+ wrapper.className = `vc vc-${definition.type}`;
223
+ wrapper.dataset['controlId'] = definition.id;
224
+ // The v1 expando tests and tools read the control id from.
225
+ Reflect.set(wrapper, '__id', definition.id);
226
+ wrapper.style.width = `${String(width)}px`;
227
+ wrapper.style.height = `${String(height)}px`;
228
+ if (definition.opacity !== undefined) {
229
+ wrapper.style.opacity = String(definition.opacity);
230
+ }
231
+ wrapper.style.setProperty('--vc-color', definition.color ?? ACCENT_COLOR);
232
+ if (definition.bg !== undefined) {
233
+ wrapper.style.setProperty('--vc-bg', definition.bg);
234
+ }
235
+ if (definition.text !== undefined) {
236
+ wrapper.style.color = definition.text;
237
+ }
238
+ const radius = RADIUS_BY_SHAPE[definition.shape ?? (isStick ? 'circle' : 'round')];
239
+ wrapper.style.borderRadius = radius;
240
+ if (isStick) {
241
+ wrapper.innerHTML = '<div class="base"></div><div class="knob"></div>';
242
+ requireElement(wrapper, '.base', HTMLElement).style.borderRadius = radius;
243
+ // The knob stays a circle sized to the short edge (a wide pill stick gets a
244
+ // round knob that slides). Single-axis sticks get a knob that mostly fills
245
+ // the short edge; 2D sticks a smaller one with room to travel.
246
+ const knob = requireElement(wrapper, '.knob', HTMLElement);
247
+ const isSingleAxis = definition.axis === 'x' || definition.axis === 'y';
248
+ const knobDiameter = Math.round(Math.min(width, height) * (isSingleAxis ? 0.92 : 0.5));
249
+ knob.style.width = `${String(knobDiameter)}px`;
250
+ knob.style.height = `${String(knobDiameter)}px`;
251
+ bindStick(definition.id, wrapper, knob, definition, environment);
252
+ } else {
253
+ // The game's own markup (an SVG icon, an entity in a label), inserted as given.
254
+ wrapper.innerHTML = definition.html ?? `<span>${definition.label ?? ''}</span>`;
255
+ if (definition.type === 'tap') {
256
+ wrapper.classList.add('vc-transparent');
257
+ }
258
+ bindButton(definition.id, wrapper, environment);
259
+ }
260
+ bindEditDrag(definition.id, wrapper, environment);
261
+ return wrapper;
262
+ }
package/src/race.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/race`: the race forwarder (`hub.race`), for games
3
+ * that want only this namespace.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export { raceForwarder as race } from './realtime/race-forwarder';
8
+ export type * from './realtime/race-types';
9
+ export type { RaceHistory, RaceRecordPlayer, RaceResultPlayer, RaceStatusEvent } from './realtime/wire-types';
10
+ export type { RaceOutcome, RaceParams, RaceRecord, RaceResult, RaceState, RaceStats, RaceStatValue, Racer, RacerResult, RacerState, RoomInfo } from '@diffenderfer-games/hub-contract';
@@ -0,0 +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();
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ });
@@ -0,0 +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
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The multiplayer lifecycle as games see it through `hub.mp`: invites,
3
+ * launches, the leave guard and own-server tickets.
4
+ */
5
+ import type { Invite, JoinInfo, Launch, Party } from '@diffenderfer-games/hub-contract';
6
+
7
+ /** `hub.mp.onJoinInfo` payload: the party leader published where to connect. */
8
+ export interface JoinInfoEvent {
9
+ /** The party it is for. */
10
+ partyId: string;
11
+ /** Opaque connection details (a room code, a server address…). */
12
+ info: JoinInfo;
13
+ }
14
+
15
+ /** What `hub.mp.invite` resolves with. */
16
+ export interface InviteResult {
17
+ /** The invite as sent. */
18
+ invite: Invite;
19
+ }
20
+
21
+ /** Options for `hub.mp.invite`. */
22
+ export interface InviteOptions {
23
+ /** The game mode to invite to. */
24
+ mode?: string;
25
+ }
26
+
27
+ /** Options for `hub.mp.setBusy`. */
28
+ export interface BusyOptions {
29
+ /** What the leave-confirmation calls the match ("a duel"). */
30
+ label?: string;
31
+ }
32
+
33
+ /** `hub.mp`: the multiplayer lifecycle (docs/multiplayer.md §7). */
34
+ export interface HubMpApi {
35
+ /** Invite, join and watch arrivals. Call at boot; launches that arrived earlier are buffered. */
36
+ onLaunch(listener: (launch: Launch) => void): () => void;
37
+ /** The party leader published join info (e.g. a room code). */
38
+ onJoinInfo(listener: (event: JoinInfoEvent) => void): () => void;
39
+ /** Invite a friend to this game. Rejects with `HubError` or `OfflineError`. */
40
+ invite(userId: number, options?: InviteOptions): Promise<InviteResult>;
41
+ /**
42
+ * A live match is in progress: the hub confirms before navigating away and
43
+ * overlay events carry `canPause: false`.
44
+ */
45
+ setBusy(isBusy: boolean, options?: BusyOptions): void;
46
+ /** A 60-second identity ticket for your own game server. */
47
+ ticket(): Promise<string>;
48
+ /** Host side, own-server games: tell the party where to connect. */
49
+ setJoinInfo(partyId: string, joinInfo: JoinInfo): Promise<void>;
50
+ /** My current party, or `null` (also `null` until the runtime is up). */
51
+ party(): Party | null;
52
+ }