@seriousgaming/sdk 0.0.0-stage → 1.0.0-alpha.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 (170) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +1 -0
  3. package/README.md +29 -2
  4. package/lib/build/callbacks.d.ts +998 -0
  5. package/lib/build/callbacks.js +32 -0
  6. package/lib/build/context-merge.d.ts +59 -0
  7. package/lib/build/context-merge.js +118 -0
  8. package/lib/build/document.d.ts +36 -0
  9. package/lib/build/document.js +8 -0
  10. package/lib/build/encoding.d.ts +26 -0
  11. package/lib/build/encoding.js +78 -0
  12. package/lib/build/endpoints.d.ts +421 -0
  13. package/lib/build/endpoints.js +46 -0
  14. package/lib/build/hooks.d.ts +74 -0
  15. package/lib/build/hooks.js +1 -0
  16. package/lib/build/index.d.ts +18 -0
  17. package/lib/build/index.js +16 -0
  18. package/lib/build/llm.d.ts +166 -0
  19. package/lib/build/llm.js +17 -0
  20. package/lib/build/resource-schema.d.ts +1048 -0
  21. package/lib/build/resource-schema.js +7 -0
  22. package/lib/build/scheduling.d.ts +112 -0
  23. package/lib/build/scheduling.js +217 -0
  24. package/lib/build/seed.d.ts +51 -0
  25. package/lib/build/seed.js +89 -0
  26. package/lib/build/signatures.d.ts +372 -0
  27. package/lib/build/signatures.js +1 -0
  28. package/lib/build/speech.d.ts +102 -0
  29. package/lib/build/speech.js +11 -0
  30. package/lib/build/tick-validation.d.ts +23 -0
  31. package/lib/build/tick-validation.js +96 -0
  32. package/lib/build/timer.d.ts +74 -0
  33. package/lib/build/timer.js +1 -0
  34. package/lib/build/validators.d.ts +116 -0
  35. package/lib/build/validators.js +1 -0
  36. package/lib/commands.d.ts +357 -0
  37. package/lib/commands.js +14 -0
  38. package/lib/e2e.d.ts +238 -0
  39. package/lib/e2e.js +248 -0
  40. package/lib/projection.d.ts +266 -0
  41. package/lib/projection.js +23 -0
  42. package/lib/testing/index.d.ts +13 -0
  43. package/lib/testing/index.js +13 -0
  44. package/lib/testing/logic-args.d.ts +177 -0
  45. package/lib/testing/logic-args.js +229 -0
  46. package/lib/testing/stub-services.d.ts +41 -0
  47. package/lib/testing/stub-services.js +176 -0
  48. package/lib/ui/CrossTeamDataContext.d.ts +36 -0
  49. package/lib/ui/CrossTeamDataContext.js +32 -0
  50. package/lib/ui/PresentationContext.d.ts +72 -0
  51. package/lib/ui/PresentationContext.js +13 -0
  52. package/lib/ui/PresentationWrapper.d.ts +12 -0
  53. package/lib/ui/PresentationWrapper.js +78 -0
  54. package/lib/ui/SessionUserInterfaceCallbacksContext.d.ts +3 -0
  55. package/lib/ui/SessionUserInterfaceCallbacksContext.js +7 -0
  56. package/lib/ui/SessionUserInterfaceDataContext.d.ts +103 -0
  57. package/lib/ui/SessionUserInterfaceDataContext.js +17 -0
  58. package/lib/ui/SessionUserInterfaceHookFailuresContext.d.ts +44 -0
  59. package/lib/ui/SessionUserInterfaceHookFailuresContext.js +15 -0
  60. package/lib/ui/SessionUserInterfaceHostOverlaysContext.d.ts +23 -0
  61. package/lib/ui/SessionUserInterfaceHostOverlaysContext.js +23 -0
  62. package/lib/ui/SessionUserInterfaceLocaleContext.d.ts +11 -0
  63. package/lib/ui/SessionUserInterfaceLocaleContext.js +13 -0
  64. package/lib/ui/SessionUserInterfaceMetaContext.d.ts +39 -0
  65. package/lib/ui/SessionUserInterfaceMetaContext.js +13 -0
  66. package/lib/ui/SessionUserInterfaceParticipantsContext.d.ts +12 -0
  67. package/lib/ui/SessionUserInterfaceParticipantsContext.js +16 -0
  68. package/lib/ui/SessionUserInterfaceTeamConfigurationContext.d.ts +53 -0
  69. package/lib/ui/SessionUserInterfaceTeamConfigurationContext.js +23 -0
  70. package/lib/ui/SessionUserInterfaceTeamLinksContext.d.ts +56 -0
  71. package/lib/ui/SessionUserInterfaceTeamLinksContext.js +12 -0
  72. package/lib/ui/SessionUserInterfaceTeamsContext.d.ts +13 -0
  73. package/lib/ui/SessionUserInterfaceTeamsContext.js +13 -0
  74. package/lib/ui/SessionUserInterfaceThemeContext.d.ts +9 -0
  75. package/lib/ui/SessionUserInterfaceThemeContext.js +7 -0
  76. package/lib/ui/SessionUserInterfaceThemePreferenceContext.d.ts +3 -0
  77. package/lib/ui/SessionUserInterfaceThemePreferenceContext.js +7 -0
  78. package/lib/ui/SessionUserInterfaceWalkthroughLinkContext.d.ts +42 -0
  79. package/lib/ui/SessionUserInterfaceWalkthroughLinkContext.js +26 -0
  80. package/lib/ui/SessionUserInterfaceWrapper.d.ts +8 -0
  81. package/lib/ui/SessionUserInterfaceWrapper.js +321 -0
  82. package/lib/ui/SettingsEditorCallbacksContext.d.ts +3 -0
  83. package/lib/ui/SettingsEditorCallbacksContext.js +7 -0
  84. package/lib/ui/SettingsEditorContext.d.ts +33 -0
  85. package/lib/ui/SettingsEditorContext.js +16 -0
  86. package/lib/ui/SettingsEditorLocaleContext.d.ts +13 -0
  87. package/lib/ui/SettingsEditorLocaleContext.js +15 -0
  88. package/lib/ui/SettingsEditorThemeContext.d.ts +9 -0
  89. package/lib/ui/SettingsEditorThemeContext.js +7 -0
  90. package/lib/ui/SettingsEditorThemePreferenceContext.d.ts +3 -0
  91. package/lib/ui/SettingsEditorThemePreferenceContext.js +7 -0
  92. package/lib/ui/SettingsEditorWrapper.d.ts +13 -0
  93. package/lib/ui/SettingsEditorWrapper.js +74 -0
  94. package/lib/ui/UserInterfaceCallbacksContext.d.ts +3 -0
  95. package/lib/ui/UserInterfaceCallbacksContext.js +7 -0
  96. package/lib/ui/UserInterfaceDataContext.d.ts +78 -0
  97. package/lib/ui/UserInterfaceDataContext.js +11 -0
  98. package/lib/ui/UserInterfaceLocaleContext.d.ts +13 -0
  99. package/lib/ui/UserInterfaceLocaleContext.js +15 -0
  100. package/lib/ui/UserInterfaceOwnTeamContext.d.ts +25 -0
  101. package/lib/ui/UserInterfaceOwnTeamContext.js +26 -0
  102. package/lib/ui/UserInterfaceOwnTeamJoinLinkContext.d.ts +52 -0
  103. package/lib/ui/UserInterfaceOwnTeamJoinLinkContext.js +13 -0
  104. package/lib/ui/UserInterfaceParticipantContext.d.ts +16 -0
  105. package/lib/ui/UserInterfaceParticipantContext.js +24 -0
  106. package/lib/ui/UserInterfaceParticipantsContext.d.ts +12 -0
  107. package/lib/ui/UserInterfaceParticipantsContext.js +13 -0
  108. package/lib/ui/UserInterfaceRunKindContext.d.ts +31 -0
  109. package/lib/ui/UserInterfaceRunKindContext.js +31 -0
  110. package/lib/ui/UserInterfaceTeamsContext.d.ts +12 -0
  111. package/lib/ui/UserInterfaceTeamsContext.js +13 -0
  112. package/lib/ui/UserInterfaceThemeContext.d.ts +9 -0
  113. package/lib/ui/UserInterfaceThemeContext.js +7 -0
  114. package/lib/ui/UserInterfaceThemePreferenceContext.d.ts +3 -0
  115. package/lib/ui/UserInterfaceThemePreferenceContext.js +7 -0
  116. package/lib/ui/UserInterfaceTimerContext.d.ts +10 -0
  117. package/lib/ui/UserInterfaceTimerContext.js +12 -0
  118. package/lib/ui/UserInterfaceValidationContext.d.ts +25 -0
  119. package/lib/ui/UserInterfaceValidationContext.js +14 -0
  120. package/lib/ui/UserInterfaceViewerRoleContext.d.ts +14 -0
  121. package/lib/ui/UserInterfaceViewerRoleContext.js +17 -0
  122. package/lib/ui/UserInterfaceWrapper.d.ts +8 -0
  123. package/lib/ui/UserInterfaceWrapper.js +219 -0
  124. package/lib/ui/expectedParentOrigin.d.ts +10 -0
  125. package/lib/ui/expectedParentOrigin.js +32 -0
  126. package/lib/ui/index.d.ts +50 -0
  127. package/lib/ui/index.js +52 -0
  128. package/lib/ui/internal/SurfaceGate.d.ts +19 -0
  129. package/lib/ui/internal/SurfaceGate.js +17 -0
  130. package/lib/ui/internal/SurfaceScope.d.ts +17 -0
  131. package/lib/ui/internal/SurfaceScope.js +16 -0
  132. package/lib/ui/internal/createContexts.d.ts +50 -0
  133. package/lib/ui/internal/createContexts.js +32 -0
  134. package/lib/ui/internal/document-attributes.d.ts +9 -0
  135. package/lib/ui/internal/document-attributes.js +87 -0
  136. package/lib/ui/internal/surface-timer.d.ts +3 -0
  137. package/lib/ui/internal/surface-timer.js +11 -0
  138. package/lib/ui/internal/useLatestRef.d.ts +7 -0
  139. package/lib/ui/internal/useLatestRef.js +11 -0
  140. package/lib/ui/internal/useOptimisticMerge.d.ts +19 -0
  141. package/lib/ui/internal/useOptimisticMerge.js +28 -0
  142. package/lib/ui/internal/useSurfaceControls.d.ts +52 -0
  143. package/lib/ui/internal/useSurfaceControls.js +19 -0
  144. package/lib/ui/internal/useTypedPastState.d.ts +17 -0
  145. package/lib/ui/internal/useTypedPastState.js +17 -0
  146. package/lib/ui/mutation-commit.d.ts +32 -0
  147. package/lib/ui/mutation-commit.js +1 -0
  148. package/lib/ui/mutation-outcome.d.ts +66 -0
  149. package/lib/ui/mutation-outcome.js +49 -0
  150. package/lib/ui/request-id.d.ts +18 -0
  151. package/lib/ui/request-id.js +44 -0
  152. package/lib/ui/testing.d.ts +365 -0
  153. package/lib/ui/testing.js +572 -0
  154. package/lib/ui/tick-snapshot.d.ts +28 -0
  155. package/lib/ui/tick-snapshot.js +34 -0
  156. package/lib/ui/ui-protocol.d.ts +799 -0
  157. package/lib/ui/ui-protocol.js +50 -0
  158. package/lib/ui/useAsset.d.ts +80 -0
  159. package/lib/ui/useAsset.js +139 -0
  160. package/lib/ui/useEndpointCallbacks.d.ts +31 -0
  161. package/lib/ui/useEndpointCallbacks.js +68 -0
  162. package/lib/ui/useHostChannel.d.ts +90 -0
  163. package/lib/ui/useHostChannel.js +338 -0
  164. package/lib/ui/useMutationReporter.d.ts +100 -0
  165. package/lib/ui/useMutationReporter.js +97 -0
  166. package/lib/ui/usePastState.d.ts +39 -0
  167. package/lib/ui/usePastState.js +63 -0
  168. package/lib/ui/useViewerStorage.d.ts +54 -0
  169. package/lib/ui/useViewerStorage.js +141 -0
  170. package/package.json +83 -3
@@ -0,0 +1,338 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { readExpectedParentOrigin } from './expectedParentOrigin.js';
3
+ import { MutationError } from './mutation-outcome.js';
4
+ import { newRequestId } from './request-id.js';
5
+ import { TickSnapshotError } from './tick-snapshot.js';
6
+ import { UI_PROTOCOL_VERSION } from './ui-protocol.js';
7
+ import { useEndpointCallbacks } from './useEndpointCallbacks.js';
8
+ const BLOB_PREFIX = 'blob:';
9
+ const ASSET_PREFIX = 'asset:';
10
+ const SHARED_PREFIX = 'shared:';
11
+ const STANDARD_TYPES = new Set([
12
+ 'ping',
13
+ 'setAssetUrls',
14
+ 'refreshAssetAuth',
15
+ 'uploadBlob:response',
16
+ 'callEndpoint:response',
17
+ 'getPastState:response',
18
+ 'getAllTeamsParticipants:response',
19
+ 'mutation:response',
20
+ 'themePreferenceChanged',
21
+ ]);
22
+ /**
23
+ * Iframe-side counterpart to the host's `useIframeChannel`. Owns the half of the
24
+ * host↔iframe protocol every UI wrapper shares: the two-phase handshake, port
25
+ * lifecycle and HMR reset, the per-callback pending-promise maps (blob upload,
26
+ * endpoints, tick snapshots), asset-URL resolution and `refreshAssetAuth`,
27
+ * theme-preference relay, and required-message gating.
28
+ *
29
+ * Each wrapper supplies its `requiredMessages` and an `onMessage` handler for the
30
+ * channel-specific messages (`stateChanged`/`setSettings`/`setTheme`/…) and keeps its
31
+ * own state and context providers.
32
+ */
33
+ export function useHostChannel(options) {
34
+ const { targetWindow } = options;
35
+ const expectedParentOrigin = useMemo(() => readExpectedParentOrigin(), []);
36
+ const portRef = useRef(null);
37
+ const receivedRef = useRef(new Set());
38
+ const [isInitialized, setIsInitialized] = useState(false);
39
+ const pendingBlob = useRef(new Map());
40
+ const pendingMutation = useRef(new Map());
41
+ const pendingPastState = useRef(new Map());
42
+ const pendingAllTeamsParticipants = useRef(new Map());
43
+ const [assetUrls, setAssetUrlsState] = useState(undefined);
44
+ const assetUrlsRef = useRef(undefined);
45
+ const [themePreference, setThemePreferenceState] = useState('system');
46
+ const [sessionKey, setSessionKey] = useState(undefined);
47
+ const { endpoints, handleEndpointResponse, resetPending: resetEndpointPending, } = useEndpointCallbacks(() => portRef.current);
48
+ // Refs for stable closures so the handshake effect runs once.
49
+ const optionsRef = useRef(options);
50
+ optionsRef.current = options;
51
+ const handleEndpointResponseRef = useRef(handleEndpointResponse);
52
+ handleEndpointResponseRef.current = handleEndpointResponse;
53
+ const resetEndpointPendingRef = useRef(resetEndpointPending);
54
+ resetEndpointPendingRef.current = resetEndpointPending;
55
+ useEffect(() => {
56
+ if (!expectedParentOrigin)
57
+ return;
58
+ targetWindow.postMessage({ type: 'ready', protocolVersion: UI_PROTOCOL_VERSION }, expectedParentOrigin);
59
+ const handleMessage = (event) => {
60
+ if (event.origin !== expectedParentOrigin)
61
+ return;
62
+ if (event.data?.type !== 'init')
63
+ return;
64
+ const peerVersion = event.data?.protocolVersion;
65
+ if (typeof peerVersion === 'number' && peerVersion !== UI_PROTOCOL_VERSION) {
66
+ console.warn(`[${optionsRef.current.label}] host↔iframe protocol version mismatch: ` +
67
+ `host ${peerVersion}, iframe ${UI_PROTOCOL_VERSION}`);
68
+ }
69
+ const port = event.ports[0];
70
+ if (!port)
71
+ return;
72
+ const key = event.data?.sessionKey;
73
+ setSessionKey(typeof key === 'string' && key.length > 0 ? key : undefined);
74
+ portRef.current = port;
75
+ receivedRef.current = new Set();
76
+ // Reject any in-flight calls from the previous connection.
77
+ pendingBlob.current.forEach(({ reject }) => reject(new Error('Connection reset')));
78
+ pendingBlob.current.clear();
79
+ pendingMutation.current.forEach(({ reject }) => reject(new MutationError({ kind: 'error', message: 'Connection reset' })));
80
+ pendingMutation.current.clear();
81
+ pendingPastState.current.forEach(({ reject }) => reject(new TickSnapshotError('Connection reset')));
82
+ pendingPastState.current.clear();
83
+ pendingAllTeamsParticipants.current.forEach(({ reject }) => reject(new Error('Connection reset')));
84
+ pendingAllTeamsParticipants.current.clear();
85
+ resetEndpointPendingRef.current();
86
+ optionsRef.current.onReset?.();
87
+ setIsInitialized(false);
88
+ const label = optionsRef.current.label;
89
+ const markReceived = (type) => {
90
+ receivedRef.current.add(type);
91
+ };
92
+ const helpers = {
93
+ markReceived,
94
+ postMessage: (message) => port.postMessage(message),
95
+ };
96
+ port.onmessage = (portEvent) => {
97
+ const raw = portEvent.data;
98
+ if (raw?.type && STANDARD_TYPES.has(raw.type)) {
99
+ const msg = raw;
100
+ switch (msg.type) {
101
+ case 'ping':
102
+ port.postMessage({ type: 'pong' });
103
+ break;
104
+ case 'setAssetUrls':
105
+ setAssetUrlsState(msg.payload);
106
+ assetUrlsRef.current = msg.payload;
107
+ markReceived('setAssetUrls');
108
+ break;
109
+ case 'refreshAssetAuth': {
110
+ const current = assetUrlsRef.current;
111
+ const requestId = msg.requestId;
112
+ if (!current) {
113
+ port.postMessage({
114
+ type: 'refreshAssetAuth:response',
115
+ requestId,
116
+ ok: false,
117
+ error: 'setAssetUrls not yet received',
118
+ });
119
+ break;
120
+ }
121
+ void fetch(current.assetAuthRedeemUrl, {
122
+ method: 'POST',
123
+ credentials: 'include',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ token: msg.token }),
126
+ }).then((res) => {
127
+ port.postMessage(res.ok
128
+ ? {
129
+ type: 'refreshAssetAuth:response',
130
+ requestId,
131
+ ok: true,
132
+ }
133
+ : {
134
+ type: 'refreshAssetAuth:response',
135
+ requestId,
136
+ ok: false,
137
+ error: `redeem failed (${res.status})`,
138
+ });
139
+ }, (err) => {
140
+ port.postMessage({
141
+ type: 'refreshAssetAuth:response',
142
+ requestId,
143
+ ok: false,
144
+ error: err instanceof Error ? err.message : 'redeem failed',
145
+ });
146
+ });
147
+ break;
148
+ }
149
+ case 'uploadBlob:response':
150
+ resolvePending(pendingBlob.current, msg, (m) => m.reference);
151
+ break;
152
+ case 'callEndpoint:response':
153
+ handleEndpointResponseRef.current(msg);
154
+ break;
155
+ case 'getPastState:response': {
156
+ const pending = pendingPastState.current.get(msg.requestId);
157
+ if (pending) {
158
+ pendingPastState.current.delete(msg.requestId);
159
+ if (msg.ok) {
160
+ pending.resolve(msg.entries);
161
+ }
162
+ else {
163
+ pending.reject(new TickSnapshotError(msg.error));
164
+ }
165
+ }
166
+ break;
167
+ }
168
+ case 'getAllTeamsParticipants:response':
169
+ resolvePending(pendingAllTeamsParticipants.current, msg, (m) => m.participants);
170
+ break;
171
+ case 'mutation:response': {
172
+ const pending = pendingMutation.current.get(msg.requestId);
173
+ if (pending) {
174
+ pendingMutation.current.delete(msg.requestId);
175
+ if (msg.ok) {
176
+ pending.resolve({
177
+ committedAtTick: msg.committedAtTick,
178
+ late: false,
179
+ });
180
+ }
181
+ else {
182
+ pending.reject(new MutationError(msg.outcome));
183
+ }
184
+ }
185
+ break;
186
+ }
187
+ case 'themePreferenceChanged':
188
+ setThemePreferenceState(msg.preference);
189
+ break;
190
+ }
191
+ }
192
+ else if (optionsRef.current.onMessage) {
193
+ optionsRef.current.onMessage(raw, helpers);
194
+ }
195
+ else {
196
+ // A non-standard message arrived but this wrapper wired no
197
+ // handler for it — surface it in dev rather than dropping it
198
+ // silently (browsers hide console.debug by default).
199
+ console.debug(`[${label}] Ignoring message with no handler: ${raw?.type ?? '(no type)'}`);
200
+ }
201
+ if (optionsRef.current.requiredMessages.every((t) => receivedRef.current.has(t))) {
202
+ setIsInitialized(true);
203
+ }
204
+ };
205
+ port.start();
206
+ };
207
+ window.addEventListener('message', handleMessage);
208
+ return () => window.removeEventListener('message', handleMessage);
209
+ }, [targetWindow, expectedParentOrigin]);
210
+ const postMessage = useCallback((message) => {
211
+ portRef.current?.postMessage(message);
212
+ }, []);
213
+ /**
214
+ * Post a fallible mutation message and resolve once the host confirms the
215
+ * commit, or reject with a {@link MutationError} carrying the typed
216
+ * outcome. A `requestId` is injected so the host can correlate its
217
+ * `mutation:response` back to this call; existing fire-and-forget callers
218
+ * that ignore the returned promise keep working unchanged.
219
+ */
220
+ const sendMutationReportingCommit = useCallback((message) => {
221
+ const port = portRef.current;
222
+ if (!port) {
223
+ return Promise.reject(new MutationError({ kind: 'error', message: 'Not connected to host' }));
224
+ }
225
+ return new Promise((resolve, reject) => {
226
+ const requestId = newRequestId();
227
+ pendingMutation.current.set(requestId, { resolve, reject });
228
+ port.postMessage({ ...message, requestId });
229
+ });
230
+ }, []);
231
+ const sendMutation = useCallback((message) => sendMutationReportingCommit(message).then(() => undefined), [sendMutationReportingCommit]);
232
+ const setThemePreference = useCallback((preference) => {
233
+ portRef.current?.postMessage({ type: 'setThemePreference', preference });
234
+ }, []);
235
+ const uploadBlob = useCallback(async (file) => {
236
+ const port = portRef.current;
237
+ if (!port)
238
+ throw new Error('Not connected to host');
239
+ return new Promise((resolve, reject) => {
240
+ const requestId = newRequestId();
241
+ pendingBlob.current.set(requestId, { resolve, reject });
242
+ file.arrayBuffer()
243
+ .then((data) => {
244
+ port.postMessage({
245
+ type: 'uploadBlob',
246
+ requestId,
247
+ data,
248
+ name: file.name,
249
+ mimeType: file.type,
250
+ }, [data]);
251
+ })
252
+ .catch(reject);
253
+ });
254
+ }, []);
255
+ const getAssetUrl = useCallback((reference) => {
256
+ if (!assetUrls) {
257
+ throw new Error('getAssetUrl called before setAssetUrls was received');
258
+ }
259
+ if (reference.startsWith(BLOB_PREFIX)) {
260
+ return assetUrls.blobPrefix + reference.slice(BLOB_PREFIX.length);
261
+ }
262
+ if (reference.startsWith(SHARED_PREFIX)) {
263
+ return assetUrls.sharedAssetPrefix + reference.slice(SHARED_PREFIX.length);
264
+ }
265
+ if (reference.startsWith(ASSET_PREFIX)) {
266
+ return assetUrls.scenarioAssetPrefix + reference.slice(ASSET_PREFIX.length);
267
+ }
268
+ throw new Error(`Invalid asset reference "${reference}": expected an "asset:", "shared:", or "blob:" prefix`);
269
+ }, [assetUrls]);
270
+ const readAsset = useCallback(async (reference) => {
271
+ const url = getAssetUrl(reference);
272
+ const res = await fetch(url, { credentials: 'include' });
273
+ if (!res.ok) {
274
+ throw new Error(`Failed to fetch asset (${res.status})`);
275
+ }
276
+ const buffer = await res.arrayBuffer();
277
+ const mimeType = res.headers.get('Content-Type') ?? 'application/octet-stream';
278
+ const nameHeader = res.headers.get('X-Asset-Name');
279
+ const name = nameHeader
280
+ ? decodeURIComponent(nameHeader)
281
+ : (reference.split('/').pop() ?? reference);
282
+ return { data: new Uint8Array(buffer), mimeType, name };
283
+ }, [getAssetUrl]);
284
+ const getPastState = useCallback((query) => {
285
+ const port = portRef.current;
286
+ if (!port) {
287
+ return Promise.reject(new TickSnapshotError('Not connected to host'));
288
+ }
289
+ return new Promise((resolve, reject) => {
290
+ const requestId = newRequestId();
291
+ pendingPastState.current.set(requestId, { resolve, reject });
292
+ // The host resolves `teams`, including what 'own' means for
293
+ // this surface: a player channel has one team and a session
294
+ // channel has none, and neither is the iframe's to assert.
295
+ port.postMessage({ type: 'getPastState', requestId, query });
296
+ });
297
+ }, []);
298
+ const getAllTeamsParticipants = useCallback(() => {
299
+ const port = portRef.current;
300
+ if (!port) {
301
+ return Promise.reject(new Error('Not connected to host'));
302
+ }
303
+ return new Promise((resolve, reject) => {
304
+ const requestId = newRequestId();
305
+ pendingAllTeamsParticipants.current.set(requestId, { resolve, reject });
306
+ port.postMessage({ type: 'getAllTeamsParticipants', requestId });
307
+ });
308
+ }, []);
309
+ return {
310
+ expectedParentOrigin,
311
+ isInitialized,
312
+ sessionKey,
313
+ postMessage,
314
+ sendMutation,
315
+ sendMutationReportingCommit,
316
+ themePreference,
317
+ setThemePreference,
318
+ uploadBlob,
319
+ readAsset,
320
+ getPastState,
321
+ getAllTeamsParticipants,
322
+ getAssetUrl,
323
+ assetUrls,
324
+ endpoints,
325
+ };
326
+ }
327
+ function resolvePending(map, msg, getValue) {
328
+ const pending = map.get(msg.requestId);
329
+ if (!pending)
330
+ return;
331
+ map.delete(msg.requestId);
332
+ if (msg.ok) {
333
+ pending.resolve(getValue(msg));
334
+ }
335
+ else {
336
+ pending.reject(new Error(msg.error));
337
+ }
338
+ }
@@ -0,0 +1,100 @@
1
+ import type { MutationCommit } from './mutation-commit.js';
2
+ import { type MutationOutcome } from './mutation-outcome.js';
3
+ /**
4
+ * A mutation that failed in a way the participant needs to know about.
5
+ */
6
+ export type MutationFailure = {
7
+ /** Why the host refused the write. */
8
+ outcome: MutationOutcome;
9
+ /** What the participant was trying to do, if the caller named it. */
10
+ action?: string;
11
+ /**
12
+ * Present on a `conflict` alone: run the same operation again, resolving as
13
+ * {@link MutationReporter.run} does. For a 'Try again' control a person
14
+ * presses, never to be called automatically (SPECIFICATION.md §12.5.1). By
15
+ * the time a `conflict` reaches a surface the host has already retried and
16
+ * given up, so what changes the answer is a person deciding to try again.
17
+ * Absent for every other outcome, none of which a second attempt changes.
18
+ */
19
+ retry?: () => Promise<boolean>;
20
+ };
21
+ export type MutationReporter = {
22
+ /**
23
+ * Run a fallible mutation and wait for the host to commit it. Resolves true
24
+ * on commit, false once it has failed. Stable across renders, so it is safe
25
+ * in an effect's dependency list.
26
+ *
27
+ * `action` is an optional label for the attempt, carried back on
28
+ * {@link MutationFailure} so one notice can say which control failed.
29
+ */
30
+ run: (mutate: () => Promise<unknown>, action?: string) => Promise<boolean>;
31
+ /** True while a write is in flight. Disable the control that started it. */
32
+ pending: boolean;
33
+ /**
34
+ * Set when the last committed write landed in a later round than the one
35
+ * the participant was looking at: they submitted as the tick fired, so the
36
+ * decision counts for the next round rather than the one on screen. Not a
37
+ * failure — the write is in — but worth saying out loud, because nothing
38
+ * else on screen distinguishes it from a decision that arrived in time.
39
+ *
40
+ * Null when the last write landed in the round it was composed for, or
41
+ * when the host reported no tick.
42
+ */
43
+ late: MutationCommit | null;
44
+ /** The failure still to be shown to the participant, or null. */
45
+ failure: MutationFailure | null;
46
+ /** Clear the failure and the late notice, so the participant can try again. */
47
+ dismiss: () => void;
48
+ };
49
+ /**
50
+ * Runs a fallible mutation (`setData`, `requestTick`, and the rest) so that a
51
+ * failure reaches the participant instead of vanishing.
52
+ *
53
+ * Fire-and-forget is the trap this closes. The local view merges the change
54
+ * the moment it is sent, so a write the host refuses leaves the participant
55
+ * looking at a decision the game never received, with nothing on screen to
56
+ * say so.
57
+ *
58
+ * **It never retries on its own, deliberately.** A runtime using optimistic
59
+ * concurrency retries a losing write itself before it ever reports one
60
+ * (SPECIFICATION.md §9.7), so by the time a `conflict` reaches a surface the
61
+ * host has already tried and given up, and a surface MUST NOT retry it
62
+ * automatically (§12.5.1). On a `conflict` the failure carries
63
+ * {@link MutationFailure.retry} instead, which re-runs the same operation when
64
+ * a person asks for it: offer it as a 'Try again' button.
65
+ *
66
+ * Render {@link MutationReporter.failure} somewhere the participant will see
67
+ * it. The copy is yours: it is game language, and it needs localising.
68
+ *
69
+ * **A validator rejection arrives twice.** Once here as a `validation-rejected`
70
+ * outcome, and once through `useUserInterfaceValidationRejections`, which
71
+ * carries the validator's own message and is the better thing to show. A
72
+ * surface rendering both puts two notices on screen for one refusal, the
73
+ * generic one over the specific one. Filter that kind out here and let the
74
+ * rejection channel explain it:
75
+ *
76
+ * ```tsx
77
+ * const unexplained =
78
+ * failure && failure.outcome.kind !== 'validation-rejected' ? failure : null;
79
+ * ```
80
+ *
81
+ * What is left is what only this reports: `denied`, `tick-failed`, `conflict`
82
+ * and `error`, none of which broadcast anything a surface could otherwise
83
+ * notice.
84
+ *
85
+ * ```tsx
86
+ * const { run, pending, failure, dismiss } = useMutationReporter();
87
+ *
88
+ * <Button
89
+ * disabled={pending}
90
+ * onClick={() => run(() => setData({ decisions: { Price } }), 'price')}
91
+ * />
92
+ * {failure && (
93
+ * <Notice onClose={dismiss}>
94
+ * {t('write-failed')}
95
+ * {failure.retry && <Button onClick={failure.retry}>{t('try-again')}</Button>}
96
+ * </Notice>
97
+ * )}
98
+ * ```
99
+ */
100
+ export declare function useMutationReporter(): MutationReporter;
@@ -0,0 +1,97 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import { MutationError } from './mutation-outcome.js';
3
+ /**
4
+ * Runs a fallible mutation (`setData`, `requestTick`, and the rest) so that a
5
+ * failure reaches the participant instead of vanishing.
6
+ *
7
+ * Fire-and-forget is the trap this closes. The local view merges the change
8
+ * the moment it is sent, so a write the host refuses leaves the participant
9
+ * looking at a decision the game never received, with nothing on screen to
10
+ * say so.
11
+ *
12
+ * **It never retries on its own, deliberately.** A runtime using optimistic
13
+ * concurrency retries a losing write itself before it ever reports one
14
+ * (SPECIFICATION.md §9.7), so by the time a `conflict` reaches a surface the
15
+ * host has already tried and given up, and a surface MUST NOT retry it
16
+ * automatically (§12.5.1). On a `conflict` the failure carries
17
+ * {@link MutationFailure.retry} instead, which re-runs the same operation when
18
+ * a person asks for it: offer it as a 'Try again' button.
19
+ *
20
+ * Render {@link MutationReporter.failure} somewhere the participant will see
21
+ * it. The copy is yours: it is game language, and it needs localising.
22
+ *
23
+ * **A validator rejection arrives twice.** Once here as a `validation-rejected`
24
+ * outcome, and once through `useUserInterfaceValidationRejections`, which
25
+ * carries the validator's own message and is the better thing to show. A
26
+ * surface rendering both puts two notices on screen for one refusal, the
27
+ * generic one over the specific one. Filter that kind out here and let the
28
+ * rejection channel explain it:
29
+ *
30
+ * ```tsx
31
+ * const unexplained =
32
+ * failure && failure.outcome.kind !== 'validation-rejected' ? failure : null;
33
+ * ```
34
+ *
35
+ * What is left is what only this reports: `denied`, `tick-failed`, `conflict`
36
+ * and `error`, none of which broadcast anything a surface could otherwise
37
+ * notice.
38
+ *
39
+ * ```tsx
40
+ * const { run, pending, failure, dismiss } = useMutationReporter();
41
+ *
42
+ * <Button
43
+ * disabled={pending}
44
+ * onClick={() => run(() => setData({ decisions: { Price } }), 'price')}
45
+ * />
46
+ * {failure && (
47
+ * <Notice onClose={dismiss}>
48
+ * {t('write-failed')}
49
+ * {failure.retry && <Button onClick={failure.retry}>{t('try-again')}</Button>}
50
+ * </Notice>
51
+ * )}
52
+ * ```
53
+ */
54
+ export function useMutationReporter() {
55
+ const [pending, setPending] = useState(false);
56
+ const [failure, setFailure] = useState(null);
57
+ const [late, setLate] = useState(null);
58
+ const run = useCallback(async function attempt(mutate, action) {
59
+ setFailure(null);
60
+ setLate(null);
61
+ setPending(true);
62
+ try {
63
+ const commit = (await mutate());
64
+ if (commit?.late)
65
+ setLate(commit);
66
+ return true;
67
+ }
68
+ catch (error) {
69
+ const outcome = outcomeOf(error);
70
+ setFailure({
71
+ outcome,
72
+ ...(action === undefined ? {} : { action }),
73
+ ...(outcome.kind === 'conflict' ? { retry: () => attempt(mutate, action) } : {}),
74
+ });
75
+ return false;
76
+ }
77
+ finally {
78
+ setPending(false);
79
+ }
80
+ }, []);
81
+ const dismiss = useCallback(() => {
82
+ setFailure(null);
83
+ setLate(null);
84
+ }, []);
85
+ return useMemo(() => ({ run, pending, failure, late, dismiss }), [run, pending, failure, late, dismiss]);
86
+ }
87
+ /**
88
+ * A rejected mutation carries a {@link MutationOutcome}. Anything else reaching
89
+ * here is a bug rather than a refusal, and is reported as one rather than
90
+ * swallowed: a surface that shows nothing is the failure mode this exists to
91
+ * remove.
92
+ */
93
+ function outcomeOf(error) {
94
+ if (error instanceof MutationError)
95
+ return error.outcome;
96
+ return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
97
+ }
@@ -0,0 +1,39 @@
1
+ import type { PastStateEntry, PastStateQuery } from '../build/callbacks.js';
2
+ /** The surface's `getPastState` callback, as {@link usePastState} calls it. */
3
+ export type PastStateReader = <T = Record<string, unknown>>(query: PastStateQuery<T>) => Promise<PastStateEntry<T>[]>;
4
+ /**
5
+ * Provided by the wrappers whose surfaces can read past state: the player,
6
+ * session and presentation wrappers. Exported for the test helpers, which
7
+ * provide it without a host; game code reads it through {@link usePastState}.
8
+ */
9
+ export declare const PastStateContext: import("react").Context<PastStateReader | undefined>;
10
+ export interface PastStateResult<T> {
11
+ /**
12
+ * The entries the latest completed read returned. Empty until the first
13
+ * read completes, and kept on screen while a new query loads.
14
+ */
15
+ entries: PastStateEntry<T>[];
16
+ /** Why the latest read failed, until one succeeds. */
17
+ error: Error | undefined;
18
+ /** True while a read is in flight. */
19
+ loading: boolean;
20
+ /** Read the current query again, for a surface that knows the history has moved on. */
21
+ refetch: () => void;
22
+ }
23
+ /**
24
+ * Read committed past state (`getPastState`) and keep it in step with the
25
+ * query.
26
+ *
27
+ * Reads again when the query changes, compared by value so an inline object
28
+ * literal is fine. Only the latest read's answer is kept: a slow answer to an
29
+ * earlier query never replaces a newer one. The previous entries stay on
30
+ * screen while a new query loads, so a scrubber moving through rounds does not
31
+ * flash empty between them. Pass `null` to read nothing yet.
32
+ *
33
+ * Available on the player, session and presentation surfaces.
34
+ *
35
+ * ```tsx
36
+ * const { entries, loading } = usePastState<Context>({ fromTick: day, capabilityIds: ['sales'] });
37
+ * ```
38
+ */
39
+ export declare function usePastState<T = Record<string, unknown>>(query: PastStateQuery<T> | null | undefined): PastStateResult<T>;
@@ -0,0 +1,63 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
2
+ /**
3
+ * Provided by the wrappers whose surfaces can read past state: the player,
4
+ * session and presentation wrappers. Exported for the test helpers, which
5
+ * provide it without a host; game code reads it through {@link usePastState}.
6
+ */
7
+ export const PastStateContext = createContext(undefined);
8
+ /**
9
+ * Read committed past state (`getPastState`) and keep it in step with the
10
+ * query.
11
+ *
12
+ * Reads again when the query changes, compared by value so an inline object
13
+ * literal is fine. Only the latest read's answer is kept: a slow answer to an
14
+ * earlier query never replaces a newer one. The previous entries stay on
15
+ * screen while a new query loads, so a scrubber moving through rounds does not
16
+ * flash empty between them. Pass `null` to read nothing yet.
17
+ *
18
+ * Available on the player, session and presentation surfaces.
19
+ *
20
+ * ```tsx
21
+ * const { entries, loading } = usePastState<Context>({ fromTick: day, capabilityIds: ['sales'] });
22
+ * ```
23
+ */
24
+ export function usePastState(query) {
25
+ const getPastState = useContext(PastStateContext);
26
+ if (getPastState === undefined) {
27
+ throw new Error('usePastState must be used inside a UserInterfaceWrapper, SessionUserInterfaceWrapper or PresentationWrapper');
28
+ }
29
+ const key = query ? JSON.stringify(query) : '';
30
+ const [entries, setEntries] = useState([]);
31
+ const [error, setError] = useState(undefined);
32
+ const [loading, setLoading] = useState(key !== '');
33
+ const [generation, setGeneration] = useState(0);
34
+ const latest = useRef(0);
35
+ useEffect(() => {
36
+ if (key === '') {
37
+ setLoading(false);
38
+ return;
39
+ }
40
+ const request = ++latest.current;
41
+ setLoading(true);
42
+ getPastState(JSON.parse(key)).then((result) => {
43
+ if (request !== latest.current)
44
+ return;
45
+ setEntries(result);
46
+ setError(undefined);
47
+ setLoading(false);
48
+ }, (cause) => {
49
+ if (request !== latest.current)
50
+ return;
51
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
52
+ setLoading(false);
53
+ });
54
+ return () => {
55
+ // An unmounted surface, or one whose query has moved on, takes no
56
+ // answer from this read.
57
+ if (latest.current === request)
58
+ latest.current++;
59
+ };
60
+ }, [key, generation, getPastState]);
61
+ const refetch = useCallback(() => setGeneration((previous) => previous + 1), []);
62
+ return { entries, error, loading, refetch };
63
+ }