@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.
- package/LICENSE +202 -0
- package/NOTICE +1 -0
- package/README.md +29 -2
- package/lib/build/callbacks.d.ts +998 -0
- package/lib/build/callbacks.js +32 -0
- package/lib/build/context-merge.d.ts +59 -0
- package/lib/build/context-merge.js +118 -0
- package/lib/build/document.d.ts +36 -0
- package/lib/build/document.js +8 -0
- package/lib/build/encoding.d.ts +26 -0
- package/lib/build/encoding.js +78 -0
- package/lib/build/endpoints.d.ts +421 -0
- package/lib/build/endpoints.js +46 -0
- package/lib/build/hooks.d.ts +74 -0
- package/lib/build/hooks.js +1 -0
- package/lib/build/index.d.ts +18 -0
- package/lib/build/index.js +16 -0
- package/lib/build/llm.d.ts +166 -0
- package/lib/build/llm.js +17 -0
- package/lib/build/resource-schema.d.ts +1048 -0
- package/lib/build/resource-schema.js +7 -0
- package/lib/build/scheduling.d.ts +112 -0
- package/lib/build/scheduling.js +217 -0
- package/lib/build/seed.d.ts +51 -0
- package/lib/build/seed.js +89 -0
- package/lib/build/signatures.d.ts +372 -0
- package/lib/build/signatures.js +1 -0
- package/lib/build/speech.d.ts +102 -0
- package/lib/build/speech.js +11 -0
- package/lib/build/tick-validation.d.ts +23 -0
- package/lib/build/tick-validation.js +96 -0
- package/lib/build/timer.d.ts +74 -0
- package/lib/build/timer.js +1 -0
- package/lib/build/validators.d.ts +116 -0
- package/lib/build/validators.js +1 -0
- package/lib/commands.d.ts +357 -0
- package/lib/commands.js +14 -0
- package/lib/e2e.d.ts +238 -0
- package/lib/e2e.js +248 -0
- package/lib/projection.d.ts +266 -0
- package/lib/projection.js +23 -0
- package/lib/testing/index.d.ts +13 -0
- package/lib/testing/index.js +13 -0
- package/lib/testing/logic-args.d.ts +177 -0
- package/lib/testing/logic-args.js +229 -0
- package/lib/testing/stub-services.d.ts +41 -0
- package/lib/testing/stub-services.js +176 -0
- package/lib/ui/CrossTeamDataContext.d.ts +36 -0
- package/lib/ui/CrossTeamDataContext.js +32 -0
- package/lib/ui/PresentationContext.d.ts +72 -0
- package/lib/ui/PresentationContext.js +13 -0
- package/lib/ui/PresentationWrapper.d.ts +12 -0
- package/lib/ui/PresentationWrapper.js +78 -0
- package/lib/ui/SessionUserInterfaceCallbacksContext.d.ts +3 -0
- package/lib/ui/SessionUserInterfaceCallbacksContext.js +7 -0
- package/lib/ui/SessionUserInterfaceDataContext.d.ts +103 -0
- package/lib/ui/SessionUserInterfaceDataContext.js +17 -0
- package/lib/ui/SessionUserInterfaceHookFailuresContext.d.ts +44 -0
- package/lib/ui/SessionUserInterfaceHookFailuresContext.js +15 -0
- package/lib/ui/SessionUserInterfaceHostOverlaysContext.d.ts +23 -0
- package/lib/ui/SessionUserInterfaceHostOverlaysContext.js +23 -0
- package/lib/ui/SessionUserInterfaceLocaleContext.d.ts +11 -0
- package/lib/ui/SessionUserInterfaceLocaleContext.js +13 -0
- package/lib/ui/SessionUserInterfaceMetaContext.d.ts +39 -0
- package/lib/ui/SessionUserInterfaceMetaContext.js +13 -0
- package/lib/ui/SessionUserInterfaceParticipantsContext.d.ts +12 -0
- package/lib/ui/SessionUserInterfaceParticipantsContext.js +16 -0
- package/lib/ui/SessionUserInterfaceTeamConfigurationContext.d.ts +53 -0
- package/lib/ui/SessionUserInterfaceTeamConfigurationContext.js +23 -0
- package/lib/ui/SessionUserInterfaceTeamLinksContext.d.ts +56 -0
- package/lib/ui/SessionUserInterfaceTeamLinksContext.js +12 -0
- package/lib/ui/SessionUserInterfaceTeamsContext.d.ts +13 -0
- package/lib/ui/SessionUserInterfaceTeamsContext.js +13 -0
- package/lib/ui/SessionUserInterfaceThemeContext.d.ts +9 -0
- package/lib/ui/SessionUserInterfaceThemeContext.js +7 -0
- package/lib/ui/SessionUserInterfaceThemePreferenceContext.d.ts +3 -0
- package/lib/ui/SessionUserInterfaceThemePreferenceContext.js +7 -0
- package/lib/ui/SessionUserInterfaceWalkthroughLinkContext.d.ts +42 -0
- package/lib/ui/SessionUserInterfaceWalkthroughLinkContext.js +26 -0
- package/lib/ui/SessionUserInterfaceWrapper.d.ts +8 -0
- package/lib/ui/SessionUserInterfaceWrapper.js +321 -0
- package/lib/ui/SettingsEditorCallbacksContext.d.ts +3 -0
- package/lib/ui/SettingsEditorCallbacksContext.js +7 -0
- package/lib/ui/SettingsEditorContext.d.ts +33 -0
- package/lib/ui/SettingsEditorContext.js +16 -0
- package/lib/ui/SettingsEditorLocaleContext.d.ts +13 -0
- package/lib/ui/SettingsEditorLocaleContext.js +15 -0
- package/lib/ui/SettingsEditorThemeContext.d.ts +9 -0
- package/lib/ui/SettingsEditorThemeContext.js +7 -0
- package/lib/ui/SettingsEditorThemePreferenceContext.d.ts +3 -0
- package/lib/ui/SettingsEditorThemePreferenceContext.js +7 -0
- package/lib/ui/SettingsEditorWrapper.d.ts +13 -0
- package/lib/ui/SettingsEditorWrapper.js +74 -0
- package/lib/ui/UserInterfaceCallbacksContext.d.ts +3 -0
- package/lib/ui/UserInterfaceCallbacksContext.js +7 -0
- package/lib/ui/UserInterfaceDataContext.d.ts +78 -0
- package/lib/ui/UserInterfaceDataContext.js +11 -0
- package/lib/ui/UserInterfaceLocaleContext.d.ts +13 -0
- package/lib/ui/UserInterfaceLocaleContext.js +15 -0
- package/lib/ui/UserInterfaceOwnTeamContext.d.ts +25 -0
- package/lib/ui/UserInterfaceOwnTeamContext.js +26 -0
- package/lib/ui/UserInterfaceOwnTeamJoinLinkContext.d.ts +52 -0
- package/lib/ui/UserInterfaceOwnTeamJoinLinkContext.js +13 -0
- package/lib/ui/UserInterfaceParticipantContext.d.ts +16 -0
- package/lib/ui/UserInterfaceParticipantContext.js +24 -0
- package/lib/ui/UserInterfaceParticipantsContext.d.ts +12 -0
- package/lib/ui/UserInterfaceParticipantsContext.js +13 -0
- package/lib/ui/UserInterfaceRunKindContext.d.ts +31 -0
- package/lib/ui/UserInterfaceRunKindContext.js +31 -0
- package/lib/ui/UserInterfaceTeamsContext.d.ts +12 -0
- package/lib/ui/UserInterfaceTeamsContext.js +13 -0
- package/lib/ui/UserInterfaceThemeContext.d.ts +9 -0
- package/lib/ui/UserInterfaceThemeContext.js +7 -0
- package/lib/ui/UserInterfaceThemePreferenceContext.d.ts +3 -0
- package/lib/ui/UserInterfaceThemePreferenceContext.js +7 -0
- package/lib/ui/UserInterfaceTimerContext.d.ts +10 -0
- package/lib/ui/UserInterfaceTimerContext.js +12 -0
- package/lib/ui/UserInterfaceValidationContext.d.ts +25 -0
- package/lib/ui/UserInterfaceValidationContext.js +14 -0
- package/lib/ui/UserInterfaceViewerRoleContext.d.ts +14 -0
- package/lib/ui/UserInterfaceViewerRoleContext.js +17 -0
- package/lib/ui/UserInterfaceWrapper.d.ts +8 -0
- package/lib/ui/UserInterfaceWrapper.js +219 -0
- package/lib/ui/expectedParentOrigin.d.ts +10 -0
- package/lib/ui/expectedParentOrigin.js +32 -0
- package/lib/ui/index.d.ts +50 -0
- package/lib/ui/index.js +52 -0
- package/lib/ui/internal/SurfaceGate.d.ts +19 -0
- package/lib/ui/internal/SurfaceGate.js +17 -0
- package/lib/ui/internal/SurfaceScope.d.ts +17 -0
- package/lib/ui/internal/SurfaceScope.js +16 -0
- package/lib/ui/internal/createContexts.d.ts +50 -0
- package/lib/ui/internal/createContexts.js +32 -0
- package/lib/ui/internal/document-attributes.d.ts +9 -0
- package/lib/ui/internal/document-attributes.js +87 -0
- package/lib/ui/internal/surface-timer.d.ts +3 -0
- package/lib/ui/internal/surface-timer.js +11 -0
- package/lib/ui/internal/useLatestRef.d.ts +7 -0
- package/lib/ui/internal/useLatestRef.js +11 -0
- package/lib/ui/internal/useOptimisticMerge.d.ts +19 -0
- package/lib/ui/internal/useOptimisticMerge.js +28 -0
- package/lib/ui/internal/useSurfaceControls.d.ts +52 -0
- package/lib/ui/internal/useSurfaceControls.js +19 -0
- package/lib/ui/internal/useTypedPastState.d.ts +17 -0
- package/lib/ui/internal/useTypedPastState.js +17 -0
- package/lib/ui/mutation-commit.d.ts +32 -0
- package/lib/ui/mutation-commit.js +1 -0
- package/lib/ui/mutation-outcome.d.ts +66 -0
- package/lib/ui/mutation-outcome.js +49 -0
- package/lib/ui/request-id.d.ts +18 -0
- package/lib/ui/request-id.js +44 -0
- package/lib/ui/testing.d.ts +365 -0
- package/lib/ui/testing.js +572 -0
- package/lib/ui/tick-snapshot.d.ts +28 -0
- package/lib/ui/tick-snapshot.js +34 -0
- package/lib/ui/ui-protocol.d.ts +799 -0
- package/lib/ui/ui-protocol.js +50 -0
- package/lib/ui/useAsset.d.ts +80 -0
- package/lib/ui/useAsset.js +139 -0
- package/lib/ui/useEndpointCallbacks.d.ts +31 -0
- package/lib/ui/useEndpointCallbacks.js +68 -0
- package/lib/ui/useHostChannel.d.ts +90 -0
- package/lib/ui/useHostChannel.js +338 -0
- package/lib/ui/useMutationReporter.d.ts +100 -0
- package/lib/ui/useMutationReporter.js +97 -0
- package/lib/ui/usePastState.d.ts +39 -0
- package/lib/ui/usePastState.js +63 -0
- package/lib/ui/useViewerStorage.d.ts +54 -0
- package/lib/ui/useViewerStorage.js +141 -0
- 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
|
+
}
|