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

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 (154) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -21
  3. package/README.md +206 -139
  4. package/dist/auto.d.ts +6 -6
  5. package/dist/auto.js +2 -2
  6. package/dist/doctor/cli.mjs +62 -25
  7. package/dist/doctor/cli.mjs.map +1 -1
  8. package/dist/index.d.ts +6 -6
  9. package/dist/index.js +2 -2
  10. package/dist/mp.d.ts +3 -3
  11. package/dist/race.d.ts +1 -1
  12. package/dist/rooms.d.ts +3 -3
  13. package/dist/shared/bridge.js +9 -3
  14. package/dist/shared/bridge.js.map +1 -1
  15. package/dist/shared/client-version.gen.js +1 -1
  16. package/dist/shared/daily.d.ts +10 -4
  17. package/dist/shared/daily.d.ts.map +1 -1
  18. package/dist/shared/daily.js +12 -2
  19. package/dist/shared/daily.js.map +1 -1
  20. package/dist/shared/globals.js +8 -3
  21. package/dist/shared/globals.js.map +1 -1
  22. package/dist/shared/index.d.ts +314 -35
  23. package/dist/shared/index.d.ts.map +1 -1
  24. package/dist/shared/input.js +7 -2
  25. package/dist/shared/input.js.map +1 -1
  26. package/dist/shared/latest-state.js +1 -1
  27. package/dist/shared/mp.js +10 -2
  28. package/dist/shared/mp.js.map +1 -1
  29. package/dist/shared/multiplayer-types.d.ts +15 -2
  30. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  31. package/dist/shared/race-types.d.ts +1 -1
  32. package/dist/shared/race.js +1 -1
  33. package/dist/shared/room-types.d.ts +28 -3
  34. package/dist/shared/room-types.d.ts.map +1 -1
  35. package/dist/shared/rooms.d.ts.map +1 -1
  36. package/dist/shared/rooms.js +10 -2
  37. package/dist/shared/rooms.js.map +1 -1
  38. package/dist/shared/runtime-opener.js +1 -1
  39. package/dist/shared/social.d.ts +23 -8
  40. package/dist/shared/social.d.ts.map +1 -1
  41. package/dist/shared/social.js +6 -1
  42. package/dist/shared/social.js.map +1 -1
  43. package/dist/shared/src.js +1588 -388
  44. package/dist/shared/src.js.map +1 -1
  45. package/dist/shared/wire-types.d.ts +6 -2
  46. package/dist/shared/wire-types.d.ts.map +1 -1
  47. package/dist/social.d.ts +5 -5
  48. package/dist/social.js +1 -1
  49. package/package.json +89 -89
  50. package/src/auto.ts +39 -39
  51. package/src/core/connection.ts +394 -299
  52. package/src/core/errors.ts +147 -147
  53. package/src/core/game-data.ts +40 -38
  54. package/src/core/hub-economy.ts +216 -179
  55. package/src/core/hub-progress.ts +202 -205
  56. package/src/core/hub.ts +133 -131
  57. package/src/core/menu-control.ts +61 -61
  58. package/src/core/offline/events.ts +50 -50
  59. package/src/core/offline/favorites.ts +21 -21
  60. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  61. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  62. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  63. package/src/core/offline/hub-kinds/index.ts +35 -0
  64. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  65. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  66. package/src/core/offline/kind-adapters.ts +48 -0
  67. package/src/core/offline/local-inventory.ts +64 -0
  68. package/src/core/offline/local-store.ts +259 -261
  69. package/src/core/offline/offline-api.ts +65 -0
  70. package/src/core/offline/outbox-entry.ts +59 -0
  71. package/src/core/offline/outbox-kinds.ts +78 -0
  72. package/src/core/offline/outbox-merge.ts +39 -0
  73. package/src/core/offline/outbox-replayer.ts +141 -0
  74. package/src/core/offline/outbox-store.ts +85 -0
  75. package/src/core/offline/outbox.ts +232 -0
  76. package/src/core/offline/queue.ts +126 -171
  77. package/src/core/offline/replay-lease.ts +87 -0
  78. package/src/core/offline/replay-policy.ts +52 -0
  79. package/src/core/page.ts +74 -74
  80. package/src/core/rest/endpoint-types.ts +22 -22
  81. package/src/core/rest/rest-client.ts +163 -152
  82. package/src/core/rest/routes.gen.ts +108 -87
  83. package/src/core/storage/json-guards.ts +80 -74
  84. package/src/core/storage/key-value.ts +66 -66
  85. package/src/core/ticket.ts +43 -43
  86. package/src/core/types.ts +134 -134
  87. package/src/core/version/client-version.gen.ts +1 -1
  88. package/src/core/version/hub-version.ts +62 -62
  89. package/src/core/version/semver.ts +97 -97
  90. package/src/daily/daily-system.ts +168 -161
  91. package/src/daily/page-daily.ts +36 -36
  92. package/src/daily/types.ts +68 -67
  93. package/src/daily.ts +10 -10
  94. package/src/doctor/checks.ts +86 -76
  95. package/src/doctor/cli.ts +98 -106
  96. package/src/doctor/hub-version-fetch.ts +43 -0
  97. package/src/doctor/tsconfig.json +12 -12
  98. package/src/globals.ts +115 -110
  99. package/src/index.ts +55 -50
  100. package/src/input/device-state.ts +146 -146
  101. package/src/input/edges.ts +55 -55
  102. package/src/input/gamepad.ts +61 -61
  103. package/src/input/input-events.ts +32 -32
  104. package/src/input/input-registry.ts +171 -164
  105. package/src/input/input-system.ts +393 -393
  106. package/src/input/legacy-controls.ts +64 -64
  107. package/src/input/nav-state.ts +191 -191
  108. package/src/input/navigator.ts +171 -171
  109. package/src/input/on-screen-controls.ts +101 -101
  110. package/src/input/page-control.ts +43 -43
  111. package/src/input/page-input.ts +193 -193
  112. package/src/input/player-remaps.ts +77 -77
  113. package/src/input/source-values.ts +116 -116
  114. package/src/input/spatial.ts +47 -47
  115. package/src/input/types.ts +331 -331
  116. package/src/input/zero-config.ts +177 -177
  117. package/src/input.ts +22 -22
  118. package/src/mp.ts +10 -10
  119. package/src/overlay/controls-panel.ts +163 -163
  120. package/src/overlay/dom.ts +46 -46
  121. package/src/overlay/layout-clusters.ts +162 -162
  122. package/src/overlay/layout-geometry.ts +136 -136
  123. package/src/overlay/layout.ts +203 -203
  124. package/src/overlay/listener-scope.ts +23 -23
  125. package/src/overlay/on-screen-keyboard.ts +137 -137
  126. package/src/overlay/overlay.ts +208 -208
  127. package/src/overlay/styles.ts +9 -9
  128. package/src/overlay/virtual-controls.ts +262 -262
  129. package/src/race.ts +10 -10
  130. package/src/realtime/bridge.ts +126 -121
  131. package/src/realtime/cross-bundle.ts +29 -29
  132. package/src/realtime/fallback-notice.ts +48 -48
  133. package/src/realtime/hub-link.ts +65 -65
  134. package/src/realtime/latest-state.ts +33 -33
  135. package/src/realtime/multiplayer-forwarder.ts +40 -28
  136. package/src/realtime/multiplayer-types.ts +66 -52
  137. package/src/realtime/page-runtime.ts +60 -60
  138. package/src/realtime/race-forwarder.ts +59 -59
  139. package/src/realtime/race-types.ts +132 -132
  140. package/src/realtime/room-types.ts +151 -125
  141. package/src/realtime/rooms-forwarder.ts +91 -79
  142. package/src/realtime/runtime-api.ts +60 -49
  143. package/src/realtime/runtime-opener.ts +61 -61
  144. package/src/realtime/social-forwarder.ts +77 -72
  145. package/src/realtime/social-types.ts +86 -78
  146. package/src/realtime/types.ts +53 -53
  147. package/src/realtime/wire-types.ts +79 -72
  148. package/src/rooms.ts +10 -10
  149. package/src/social.ts +18 -18
  150. package/src/types.ts +13 -13
  151. package/src/uistack/page-stack.ts +45 -45
  152. package/src/uistack/play-suspension.ts +82 -82
  153. package/src/uistack/types.ts +76 -76
  154. package/src/uistack/ui-stack.ts +144 -144
@@ -1,193 +1,193 @@
1
- /**
2
- * The page's one input system (plan §4.4). The game's copy of the client owns
3
- * the page because the game defines the controls; the host menu reaches it
4
- * through the stable {@link PageInputControl} slice. Remaps persist to
5
- * localStorage (offline-safe) and, through the core client, to a reserved
6
- * cloud save slot.
7
- */
8
- import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
9
- import { claimSingleton, LEGACY_GLOBAL_NAMES } from '../globals';
10
- import { hasMethods, isObject, readField } from '../realtime/cross-bundle';
11
- import { InputSystem } from './input-system';
12
- import { isPageInputControl } from './page-control';
13
- import type { PageInputControl } from './page-control';
14
- import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
15
-
16
- /** The cross-bundle shape number of the `input` singleton. Bump only on an incompatible change. */
17
- export const INPUT_SINGLETON_API = 1;
18
-
19
- /** The public methods of every input system, v1 copies included. */
20
- const INPUT_SYSTEM_METHODS = [
21
- 'define', 'enable', 'disable', 'isEnabled', 'value', 'get', 'down', 'up', 'pressed', 'axis', 'vector', 'on',
22
- 'setVirtualHtml', 'showVirtual', 'setInsets', 'autoNavigate', 'clearAutoNavigate', 'attachMenu', 'setLegacyControls',
23
- 'openControls', 'rebind', 'setVirtual',
24
- ] as const;
25
-
26
- /** Whether `candidate` is some bundle's input system (v1 copies included). */
27
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
28
- export function isInputSystem(candidate: unknown): candidate is InputSystem {
29
- return hasMethods(candidate, INPUT_SYSTEM_METHODS);
30
- }
31
-
32
- /** How the core client connects the input system to its game. */
33
- export interface InputOptions {
34
- /** This page's game slug (default: `window.__HUB__.slug`). */
35
- slug?: string | null;
36
- /** Mirror remaps to the cloud (the core client writes the `__controls` save slot). */
37
- saveToCloud?: (overrides: InputOverrides) => void;
38
- }
39
-
40
- /** A finite number, or `undefined`. */
41
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
42
- function finiteNumber(candidate: unknown): number | undefined {
43
- return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
44
- }
45
-
46
- /** A saved rebind: the keys or gamepad button the player chose. */
47
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
48
- function parseBinding(saved: unknown): Partial<InputDef> {
49
- const binding: Partial<InputDef> = {};
50
- const keys = readField(saved, 'keys');
51
- if (Array.isArray(keys) && keys.every((key) => typeof key === 'string')) {
52
- binding.keys = keys.filter((key): key is string => typeof key === 'string');
53
- }
54
- const button = finiteNumber(readField(readField(saved, 'gamepad'), 'button'));
55
- if (button !== undefined) {
56
- binding.gamepad = { button };
57
- }
58
- return binding;
59
- }
60
-
61
- /** A saved dragged spot. */
62
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
63
- function parseVirtualOverride(saved: unknown): VirtualOverride {
64
- const spot: VirtualOverride = {};
65
- const left = finiteNumber(readField(saved, 'x'));
66
- const top = finiteNumber(readField(saved, 'y'));
67
- if (left !== undefined) {
68
- spot.x = left;
69
- }
70
- if (top !== undefined) {
71
- spot.y = top;
72
- }
73
- return spot;
74
- }
75
-
76
- /** Entries of a saved record, parsed. */
77
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
78
- function parseRecord<Parsed>(saved: unknown, parseEntry: (entry: unknown) => Parsed): Record<string, Parsed> | undefined {
79
- if (!isObject(saved)) {
80
- return undefined;
81
- }
82
- return Object.fromEntries(Object.entries(saved).map(([key, entry]) => [key, parseEntry(entry)]));
83
- }
84
-
85
- /** Saved overrides from JSON, keeping only well-formed fields. */
86
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from localStorage
87
- export function parseInputOverrides(saved: unknown): InputOverrides {
88
- const overrides: InputOverrides = {};
89
- const bindings = parseRecord(readField(saved, 'bindings'), parseBinding);
90
- const virtual = parseRecord(readField(saved, 'virtual'), parseVirtualOverride);
91
- const showVirtual = readField(saved, 'showVirtual');
92
- if (bindings !== undefined) {
93
- overrides.bindings = bindings;
94
- }
95
- if (virtual !== undefined) {
96
- overrides.virtual = virtual;
97
- }
98
- if (typeof showVirtual === 'boolean') {
99
- overrides.showVirtual = showVirtual;
100
- }
101
- return overrides;
102
- }
103
-
104
- /** A field of the host's injected `window.__HUB__`. */
105
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the host page's injected config, checked by the caller
106
- function injectedField(key: string): unknown {
107
- return typeof window === 'undefined' ? undefined : readField(readField(window, '__HUB__'), key);
108
- }
109
-
110
- /** The slug the host injected, or `null`. */
111
- function injectedSlug(): string | null {
112
- const slug = injectedField('slug');
113
- return typeof slug === 'string' ? slug : null;
114
- }
115
-
116
- /** A new input system, starting from the host's injected `game.controls` (the menu may pass them again later). */
117
- function createInputSystem(options: InputOptions, isPageOwner: boolean): InputSystem {
118
- const system = new InputSystem(createInputHost(options), { isPageOwner, claimPage: registerAsPageInput });
119
- system.setLegacyControls(injectedField('controls') ?? null);
120
- return system;
121
- }
122
-
123
- /** Persistence for remaps: localStorage under `hub:<slug>:input`, mirrored to the cloud when given. */
124
- function createInputHost(options: InputOptions): InputHost {
125
- const slug = options.slug === undefined ? injectedSlug() : options.slug;
126
- const storageKey = `hub:${slug ?? 'null'}:input`;
127
- return {
128
- slug,
129
- loadOverrides() {
130
- try {
131
- const saved = localStorage.getItem(storageKey);
132
- if (saved === null) {
133
- return null;
134
- }
135
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON from localStorage, parsed straight away
136
- const parsed: unknown = JSON.parse(saved);
137
- return parseInputOverrides(parsed);
138
- } catch {
139
- return null;
140
- }
141
- },
142
- saveOverrides(overrides) {
143
- try {
144
- localStorage.setItem(storageKey, JSON.stringify(overrides));
145
- } catch {
146
- // Storage full or blocked: the cloud copy still saves.
147
- }
148
- if (slug !== null) {
149
- options.saveToCloud?.(overrides);
150
- }
151
- },
152
- };
153
- }
154
-
155
- /** Make `system` the page's registered input system; returns the one it replaces. */
156
- function registerAsPageInput(system: InputSystem): PageInputControl | null {
157
- const registry = window.__DG__ ?? {};
158
- window.__DG__ = registry;
159
- const previous = registry.input?.instance ?? window[LEGACY_GLOBAL_NAMES.input];
160
- registry.input = { version: HUB_CLIENT_VERSION, api: INPUT_SINGLETON_API, instance: system };
161
- window[LEGACY_GLOBAL_NAMES.input] = system;
162
- return previous !== system && isPageInputControl(previous) ? previous : null;
163
- }
164
-
165
- /** Whether another bundle registered an input system with a different shape number. */
166
- function hasMismatchedPageInput(): boolean {
167
- const registered = typeof window === 'undefined' ? undefined : window.__DG__?.input;
168
- return registered !== undefined && registered.api !== INPUT_SINGLETON_API && isInputSystem(registered.instance);
169
- }
170
-
171
- let claimedInput: InputSystem | null = null;
172
-
173
- /**
174
- * The page-wide input system, created on first use. `options` connect it to
175
- * the core client (its slug, the cloud save); the first call's options win.
176
- * When another client copy with a different shape owns the page, this copy
177
- * gets a private instance that takes the page over once its game defines
178
- * inputs.
179
- */
180
- export function getInput(options: InputOptions = {}): InputSystem {
181
- if (claimedInput !== null) {
182
- return claimedInput;
183
- }
184
- const isPrivate = hasMismatchedPageInput();
185
- claimedInput = claimSingleton({
186
- name: 'input',
187
- version: HUB_CLIENT_VERSION,
188
- api: INPUT_SINGLETON_API,
189
- create: () => createInputSystem(options, !isPrivate),
190
- isInstance: isInputSystem,
191
- });
192
- return claimedInput;
193
- }
1
+ /**
2
+ * The page's one input system (plan §4.4). The game's copy of the client owns
3
+ * the page because the game defines the controls; the host menu reaches it
4
+ * through the stable {@link PageInputControl} slice. Remaps persist to
5
+ * localStorage (offline-safe) and, through the core client, to a reserved
6
+ * cloud save slot.
7
+ */
8
+ import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
9
+ import { claimSingleton, LEGACY_GLOBAL_NAMES } from '../globals';
10
+ import { hasMethods, isObject, readField } from '../realtime/cross-bundle';
11
+ import { InputSystem } from './input-system';
12
+ import { isPageInputControl } from './page-control';
13
+ import type { PageInputControl } from './page-control';
14
+ import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
15
+
16
+ /** The cross-bundle shape number of the `input` singleton. Bump only on an incompatible change. */
17
+ export const INPUT_SINGLETON_API = 1;
18
+
19
+ /** The public methods of every input system, v1 copies included. */
20
+ const INPUT_SYSTEM_METHODS = [
21
+ 'define', 'enable', 'disable', 'isEnabled', 'value', 'get', 'down', 'up', 'pressed', 'axis', 'vector', 'on',
22
+ 'setVirtualHtml', 'showVirtual', 'setInsets', 'autoNavigate', 'clearAutoNavigate', 'attachMenu', 'setLegacyControls',
23
+ 'openControls', 'rebind', 'setVirtual',
24
+ ] as const;
25
+
26
+ /** Whether `candidate` is some bundle's input system (v1 copies included). */
27
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
28
+ export function isInputSystem(candidate: unknown): candidate is InputSystem {
29
+ return hasMethods(candidate, INPUT_SYSTEM_METHODS);
30
+ }
31
+
32
+ /** How the core client connects the input system to its game. */
33
+ export interface InputOptions {
34
+ /** This page's game slug (default: `window.__HUB__.slug`). */
35
+ slug?: string | null;
36
+ /** Mirror remaps to the cloud (the core client writes the `__controls` save slot). */
37
+ saveToCloud?: (overrides: InputOverrides) => void;
38
+ }
39
+
40
+ /** A finite number, or `undefined`. */
41
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
42
+ function finiteNumber(candidate: unknown): number | undefined {
43
+ return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
44
+ }
45
+
46
+ /** A saved rebind: the keys or gamepad button the player chose. */
47
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
48
+ function parseBinding(saved: unknown): Partial<InputDef> {
49
+ const binding: Partial<InputDef> = {};
50
+ const keys = readField(saved, 'keys');
51
+ if (Array.isArray(keys) && keys.every((key) => typeof key === 'string')) {
52
+ binding.keys = keys.filter((key): key is string => typeof key === 'string');
53
+ }
54
+ const button = finiteNumber(readField(readField(saved, 'gamepad'), 'button'));
55
+ if (button !== undefined) {
56
+ binding.gamepad = { button };
57
+ }
58
+ return binding;
59
+ }
60
+
61
+ /** A saved dragged spot. */
62
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
63
+ function parseVirtualOverride(saved: unknown): VirtualOverride {
64
+ const spot: VirtualOverride = {};
65
+ const left = finiteNumber(readField(saved, 'x'));
66
+ const top = finiteNumber(readField(saved, 'y'));
67
+ if (left !== undefined) {
68
+ spot.x = left;
69
+ }
70
+ if (top !== undefined) {
71
+ spot.y = top;
72
+ }
73
+ return spot;
74
+ }
75
+
76
+ /** Entries of a saved record, parsed. */
77
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
78
+ function parseRecord<Parsed>(saved: unknown, parseEntry: (entry: unknown) => Parsed): Record<string, Parsed> | undefined {
79
+ if (!isObject(saved)) {
80
+ return undefined;
81
+ }
82
+ return Object.fromEntries(Object.entries(saved).map(([key, entry]) => [key, parseEntry(entry)]));
83
+ }
84
+
85
+ /** Saved overrides from JSON, keeping only well-formed fields. */
86
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from localStorage
87
+ export function parseInputOverrides(saved: unknown): InputOverrides {
88
+ const overrides: InputOverrides = {};
89
+ const bindings = parseRecord(readField(saved, 'bindings'), parseBinding);
90
+ const virtual = parseRecord(readField(saved, 'virtual'), parseVirtualOverride);
91
+ const showVirtual = readField(saved, 'showVirtual');
92
+ if (bindings !== undefined) {
93
+ overrides.bindings = bindings;
94
+ }
95
+ if (virtual !== undefined) {
96
+ overrides.virtual = virtual;
97
+ }
98
+ if (typeof showVirtual === 'boolean') {
99
+ overrides.showVirtual = showVirtual;
100
+ }
101
+ return overrides;
102
+ }
103
+
104
+ /** A field of the host's injected `window.__HUB__`. */
105
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the host page's injected config, checked by the caller
106
+ function injectedField(key: string): unknown {
107
+ return typeof window === 'undefined' ? undefined : readField(readField(window, '__HUB__'), key);
108
+ }
109
+
110
+ /** The slug the host injected, or `null`. */
111
+ function injectedSlug(): string | null {
112
+ const slug = injectedField('slug');
113
+ return typeof slug === 'string' ? slug : null;
114
+ }
115
+
116
+ /** A new input system, starting from the host's injected `game.controls` (the menu may pass them again later). */
117
+ function createInputSystem(options: InputOptions, isPageOwner: boolean): InputSystem {
118
+ const system = new InputSystem(createInputHost(options), { isPageOwner, claimPage: registerAsPageInput });
119
+ system.setLegacyControls(injectedField('controls') ?? null);
120
+ return system;
121
+ }
122
+
123
+ /** Persistence for remaps: localStorage under `hub:<slug>:input`, mirrored to the cloud when given. */
124
+ function createInputHost(options: InputOptions): InputHost {
125
+ const slug = options.slug === undefined ? injectedSlug() : options.slug;
126
+ const storageKey = `hub:${slug ?? 'null'}:input`;
127
+ return {
128
+ slug,
129
+ loadOverrides() {
130
+ try {
131
+ const saved = localStorage.getItem(storageKey);
132
+ if (saved === null) {
133
+ return null;
134
+ }
135
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON from localStorage, parsed straight away
136
+ const parsed: unknown = JSON.parse(saved);
137
+ return parseInputOverrides(parsed);
138
+ } catch {
139
+ return null;
140
+ }
141
+ },
142
+ saveOverrides(overrides) {
143
+ try {
144
+ localStorage.setItem(storageKey, JSON.stringify(overrides));
145
+ } catch {
146
+ // Storage full or blocked: the cloud copy still saves.
147
+ }
148
+ if (slug !== null) {
149
+ options.saveToCloud?.(overrides);
150
+ }
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Make `system` the page's registered input system; returns the one it replaces. */
156
+ function registerAsPageInput(system: InputSystem): PageInputControl | null {
157
+ const registry = window.__DG__ ?? {};
158
+ window.__DG__ = registry;
159
+ const previous = registry.input?.instance ?? window[LEGACY_GLOBAL_NAMES.input];
160
+ registry.input = { version: HUB_CLIENT_VERSION, api: INPUT_SINGLETON_API, instance: system };
161
+ window[LEGACY_GLOBAL_NAMES.input] = system;
162
+ return previous !== system && isPageInputControl(previous) ? previous : null;
163
+ }
164
+
165
+ /** Whether another bundle registered an input system with a different shape number. */
166
+ function hasMismatchedPageInput(): boolean {
167
+ const registered = typeof window === 'undefined' ? undefined : window.__DG__?.input;
168
+ return registered !== undefined && registered.api !== INPUT_SINGLETON_API && isInputSystem(registered.instance);
169
+ }
170
+
171
+ let claimedInput: InputSystem | null = null;
172
+
173
+ /**
174
+ * The page-wide input system, created on first use. `options` connect it to
175
+ * the core client (its slug, the cloud save); the first call's options win.
176
+ * When another client copy with a different shape owns the page, this copy
177
+ * gets a private instance that takes the page over once its game defines
178
+ * inputs.
179
+ */
180
+ export function getInput(options: InputOptions = {}): InputSystem {
181
+ if (claimedInput !== null) {
182
+ return claimedInput;
183
+ }
184
+ const isPrivate = hasMismatchedPageInput();
185
+ claimedInput = claimSingleton({
186
+ name: 'input',
187
+ version: HUB_CLIENT_VERSION,
188
+ api: INPUT_SINGLETON_API,
189
+ create: () => createInputSystem(options, !isPrivate),
190
+ isInstance: isInputSystem,
191
+ });
192
+ return claimedInput;
193
+ }
@@ -1,77 +1,77 @@
1
- /**
2
- * The player's remaps and on-screen control customisations for one game,
3
- * persisted through the input host after every change.
4
- */
5
- import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
6
-
7
- /** A readable label of an input's binding (`w/ArrowUp · Pad 0`). */
8
- export function describeBinding(definition: InputDef | undefined): string {
9
- const parts: string[] = [];
10
- if (definition?.keys !== undefined && definition.keys.length > 0) {
11
- parts.push(definition.keys.join('/'));
12
- }
13
- if (definition?.gamepad?.button !== undefined) {
14
- parts.push(`Pad ${String(definition.gamepad.button)}`);
15
- }
16
- if (definition?.gamepad?.axis !== undefined) {
17
- parts.push(`Axis ${String(definition.gamepad.axis[0])}${definition.gamepad.axis[1]}`);
18
- }
19
- return parts.length > 0 ? parts.join(' · ') : '—';
20
- }
21
-
22
- /** The player's overrides, saved on every change. */
23
- export class PlayerRemaps {
24
- private readonly host: InputHost;
25
- private saved: InputOverrides;
26
-
27
- constructor(host: InputHost) {
28
- this.host = host;
29
- this.saved = host.loadOverrides() ?? {};
30
- }
31
-
32
- /** The overrides as they stand. */
33
- get overrides(): InputOverrides {
34
- return this.saved;
35
- }
36
-
37
- /** A declared input with the player's rebind applied. */
38
- withBinding(name: string, declared: InputDef): InputDef {
39
- return { ...declared, ...this.saved.bindings?.[name] };
40
- }
41
-
42
- /** Rebind an input. */
43
- rebind(inputName: string, patch: Partial<InputDef>): void {
44
- const bindings = this.saved.bindings ?? {};
45
- bindings[inputName] = { ...bindings[inputName], ...patch };
46
- this.saved.bindings = bindings;
47
- this.persist();
48
- }
49
-
50
- /** Move or restyle an on-screen control. */
51
- customiseControl(id: string, patch: VirtualOverride): void {
52
- const virtual = this.saved.virtual ?? {};
53
- virtual[id] = { ...virtual[id], ...patch };
54
- this.saved.virtual = virtual;
55
- this.persist();
56
- }
57
-
58
- /** Record the player's show/hide choice. */
59
- setShowVirtual(isVisible: boolean): void {
60
- this.saved.showVirtual = isVisible;
61
- this.persist();
62
- }
63
-
64
- /** Forget everything. */
65
- reset(): void {
66
- this.saved = {};
67
- this.persist();
68
- }
69
-
70
- private persist(): void {
71
- try {
72
- this.host.saveOverrides(this.saved);
73
- } catch {
74
- // Storage can be full or blocked; remaps still apply for this visit.
75
- }
76
- }
77
- }
1
+ /**
2
+ * The player's remaps and on-screen control customisations for one game,
3
+ * persisted through the input host after every change.
4
+ */
5
+ import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
6
+
7
+ /** A readable label of an input's binding (`w/ArrowUp · Pad 0`). */
8
+ export function describeBinding(definition: InputDef | undefined): string {
9
+ const parts: string[] = [];
10
+ if (definition?.keys !== undefined && definition.keys.length > 0) {
11
+ parts.push(definition.keys.join('/'));
12
+ }
13
+ if (definition?.gamepad?.button !== undefined) {
14
+ parts.push(`Pad ${String(definition.gamepad.button)}`);
15
+ }
16
+ if (definition?.gamepad?.axis !== undefined) {
17
+ parts.push(`Axis ${String(definition.gamepad.axis[0])}${definition.gamepad.axis[1]}`);
18
+ }
19
+ return parts.length > 0 ? parts.join(' · ') : '—';
20
+ }
21
+
22
+ /** The player's overrides, saved on every change. */
23
+ export class PlayerRemaps {
24
+ private readonly host: InputHost;
25
+ private saved: InputOverrides;
26
+
27
+ constructor(host: InputHost) {
28
+ this.host = host;
29
+ this.saved = host.loadOverrides() ?? {};
30
+ }
31
+
32
+ /** The overrides as they stand. */
33
+ get overrides(): InputOverrides {
34
+ return this.saved;
35
+ }
36
+
37
+ /** A declared input with the player's rebind applied. */
38
+ withBinding(name: string, declared: InputDef): InputDef {
39
+ return { ...declared, ...this.saved.bindings?.[name] };
40
+ }
41
+
42
+ /** Rebind an input. */
43
+ rebind(inputName: string, patch: Partial<InputDef>): void {
44
+ const bindings = this.saved.bindings ?? {};
45
+ bindings[inputName] = { ...bindings[inputName], ...patch };
46
+ this.saved.bindings = bindings;
47
+ this.persist();
48
+ }
49
+
50
+ /** Move or restyle an on-screen control. */
51
+ customiseControl(id: string, patch: VirtualOverride): void {
52
+ const virtual = this.saved.virtual ?? {};
53
+ virtual[id] = { ...virtual[id], ...patch };
54
+ this.saved.virtual = virtual;
55
+ this.persist();
56
+ }
57
+
58
+ /** Record the player's show/hide choice. */
59
+ setShowVirtual(isVisible: boolean): void {
60
+ this.saved.showVirtual = isVisible;
61
+ this.persist();
62
+ }
63
+
64
+ /** Forget everything. */
65
+ reset(): void {
66
+ this.saved = {};
67
+ this.persist();
68
+ }
69
+
70
+ private persist(): void {
71
+ try {
72
+ this.host.saveOverrides(this.saved);
73
+ } catch {
74
+ // Storage can be full or blocked; remaps still apply for this visit.
75
+ }
76
+ }
77
+ }