@crowdedkingdoms/construct 0.0.0 → 0.2.0-dev.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/README.md +76 -1
- package/node/copy-dsh-web.mjs +71 -0
- package/node/security-headers.d.mts +23 -0
- package/node/security-headers.mjs +243 -0
- package/node/vite-plugins.d.mts +6 -0
- package/node/vite-plugins.mjs +102 -0
- package/package.json +46 -3
- package/sandbox/boot.ts +74 -0
- package/src/engine/GameLoop.ts +106 -0
- package/src/engine/GameScene.ts +74 -0
- package/src/engine/Input.test.ts +129 -0
- package/src/engine/Input.ts +257 -0
- package/src/engine/SceneRouter.test.ts +80 -0
- package/src/engine/SceneRouter.ts +113 -0
- package/src/engine/controls.test.ts +26 -0
- package/src/engine/controls.ts +63 -0
- package/src/grid/GridProgramRunner.ts +205 -0
- package/src/grid/sandboxProtocol.ts +9 -0
- package/src/index.ts +88 -0
- package/src/platform/GameSession.ts +239 -0
- package/src/platform/auth/AuthService.ts +45 -0
- package/src/platform/config.test.ts +110 -0
- package/src/platform/config.ts +212 -0
- package/src/platform/envScope.test.ts +42 -0
- package/src/platform/envScope.ts +76 -0
- package/src/platform/exec/worldHub.test.ts +61 -0
- package/src/platform/exec/worldHub.ts +86 -0
- package/src/platform/media/VoiceService.ts +423 -0
- package/src/platform/media/WebcamService.ts +346 -0
- package/src/platform/media/mulaw.test.ts +20 -0
- package/src/platform/media/mulaw.ts +56 -0
- package/src/platform/media/videoFrames.test.ts +197 -0
- package/src/platform/media/voiceFrames.test.ts +82 -0
- package/src/platform/model/ModelService.test.ts +59 -0
- package/src/platform/model/ModelService.ts +124 -0
- package/src/platform/network/NetworkManager.ts +519 -0
- package/src/platform/onboarding/steps.d.mts +154 -0
- package/src/platform/onboarding/steps.mjs +616 -0
- package/src/platform/onboarding/steps.test.ts +486 -0
- package/src/platform/realtime/WorldStores.ts +153 -0
- package/src/platform/realtime/actorCodec.test.ts +62 -0
- package/src/platform/realtime/actorCodec.ts +142 -0
- package/src/platform/realtime/space.test.ts +33 -0
- package/src/platform/realtime/space.ts +63 -0
- package/src/platform/social/ChatService.ts +92 -0
- package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
- package/src/platform/studio/ClientModLifecycle.ts +102 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
- package/src/platform/studio/GridService.test.ts +131 -0
- package/src/platform/studio/GridService.ts +295 -0
- package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
- package/src/platform/studio/HumanInputMonitor.ts +48 -0
- package/src/platform/studio/StudioService.ts +686 -0
- package/src/platform/studio/agentLocomotion.test.ts +31 -0
- package/src/platform/studio/agentLocomotion.ts +97 -0
- package/src/platform/studio/clientModHost.test.ts +199 -0
- package/src/platform/studio/clientModHost.ts +369 -0
- package/src/platform/studio/instanceSchema.test.ts +280 -0
- package/src/platform/studio/instanceSchema.ts +746 -0
- package/src/platform/studio/instanceStore.test.ts +97 -0
- package/src/platform/studio/instanceStore.ts +120 -0
- package/src/platform/studio/modOverlay.test.ts +56 -0
- package/src/platform/studio/modOverlay.ts +51 -0
- package/src/platform/studio/permissions.test.ts +51 -0
- package/src/platform/studio/permissions.ts +134 -0
- package/src/platform/studio/pointerClicks.test.ts +23 -0
- package/src/platform/studio/pointerClicks.ts +132 -0
- package/src/platform/util/Emitter.ts +32 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place this game talks to Crowded Kingdoms.
|
|
3
|
+
*
|
|
4
|
+
* ONE token, held by two clients, one sign-in that never touches this page:
|
|
5
|
+
*
|
|
6
|
+
* - the **platform** client dials the public API origin. It sends the player
|
|
7
|
+
* to Crowded Kingdoms' HOSTED sign-in (Studio's /authorize, PKCE) and
|
|
8
|
+
* receives back a short-lived **app-scoped** token for THIS app. It never
|
|
9
|
+
* sees a password, and it never holds an identity session: a browser game on
|
|
10
|
+
* its own domain cannot (the direct sign-in mutations answer
|
|
11
|
+
* HOSTED_SIGN_IN_REQUIRED from any non-first-party origin, ck-api v1.88.0).
|
|
12
|
+
* - the **game** client holds that same app token and is pointed at the app's
|
|
13
|
+
* own datacenter (the token response carries the endpoint). Everything
|
|
14
|
+
* realtime, Studio and persistence runs on it, and so does the connection
|
|
15
|
+
* to the app's world hub on ck-exec (`worldHub`).
|
|
16
|
+
*
|
|
17
|
+
* Because the page never holds a session, everything that needs one -- creating
|
|
18
|
+
* the org and the app, deploying the world hub, registering this origin as a
|
|
19
|
+
* redirect URI -- happens in `npm run setup` (a Node script, no Origin header) or in
|
|
20
|
+
* Studio. The browser knows its app id from `VITE_APP_ID`, `?app=`, or storage.
|
|
21
|
+
*
|
|
22
|
+
* Scenes and services never import the SDK client directly; they read
|
|
23
|
+
* `NetworkManager.instance.game`. That keeps "which token, which endpoint" a
|
|
24
|
+
* single decision made here.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
BrowserLocalStorageTokenStore,
|
|
28
|
+
createCrowdyClient,
|
|
29
|
+
type CrowdyClient,
|
|
30
|
+
type RealtimeStatus,
|
|
31
|
+
type UdpNotification,
|
|
32
|
+
type UdpNotificationHandlers,
|
|
33
|
+
type UnsubscribeFn,
|
|
34
|
+
} from '@crowdedkingdoms/crowdyjs';
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
API_HTTP_URL,
|
|
38
|
+
API_WS_URL,
|
|
39
|
+
APP_TOKEN_REFRESH_MS,
|
|
40
|
+
APP_TOKEN_REFRESH_RETRY_MS,
|
|
41
|
+
AUTHORIZE_URL,
|
|
42
|
+
} from '../config';
|
|
43
|
+
import { envScopedKey, readScoped, writeScoped } from '../envScope';
|
|
44
|
+
import { WorldHub } from '../exec/worldHub';
|
|
45
|
+
import { Emitter } from '../util/Emitter';
|
|
46
|
+
|
|
47
|
+
export interface SessionUser {
|
|
48
|
+
userId: string;
|
|
49
|
+
email?: string;
|
|
50
|
+
gamertag?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AppRoute {
|
|
54
|
+
appId: string;
|
|
55
|
+
gameApiUrl: string | null;
|
|
56
|
+
gameApiWsUrl: string | null;
|
|
57
|
+
discoveryUrl: string | null;
|
|
58
|
+
expiresAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where the entered app's route is remembered across a reload (env-scoped). */
|
|
62
|
+
export const APP_ROUTE_STORAGE_KEY = 'construct:app-route';
|
|
63
|
+
|
|
64
|
+
/** The token store key for the app token (env-scoped). One app at a time. */
|
|
65
|
+
const APP_TOKEN_STORE_KEY = 'construct:app-token';
|
|
66
|
+
|
|
67
|
+
export interface BootstrapInfo {
|
|
68
|
+
minimumClientVersion?: string;
|
|
69
|
+
maxReplicationDistance?: number;
|
|
70
|
+
udpConnected?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface NetworkEvents {
|
|
74
|
+
auth: SessionUser | null;
|
|
75
|
+
app: AppRoute | null;
|
|
76
|
+
realtime: RealtimeStatus;
|
|
77
|
+
log: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type UdpHandlerKind = keyof UdpNotificationHandlers;
|
|
81
|
+
type UdpHandler = (payload: never) => void;
|
|
82
|
+
|
|
83
|
+
function graphqlEndpoint(url: string): string {
|
|
84
|
+
const trimmed = url.replace(/\/$/, '');
|
|
85
|
+
return trimmed.endsWith('/graphql') ? trimmed : `${trimmed}/graphql`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function wsTwin(httpUrl: string): string {
|
|
89
|
+
return httpUrl.replace(/^http(s?):/, (_m, secure: string) => `ws${secure}:`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class NetworkManager {
|
|
93
|
+
private static singleton: NetworkManager | null = null;
|
|
94
|
+
|
|
95
|
+
static get instance(): NetworkManager {
|
|
96
|
+
if (!NetworkManager.singleton) NetworkManager.singleton = new NetworkManager();
|
|
97
|
+
return NetworkManager.singleton;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Test seam: replace the singleton (unit tests build their own). */
|
|
101
|
+
static reset(): void {
|
|
102
|
+
NetworkManager.singleton?.close();
|
|
103
|
+
NetworkManager.singleton = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
readonly events = new Emitter<NetworkEvents>();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The client on the public API origin. Holds the app token the hosted
|
|
110
|
+
* sign-in returned (or nothing). Not an identity session; see the header.
|
|
111
|
+
*/
|
|
112
|
+
readonly platform: CrowdyClient;
|
|
113
|
+
|
|
114
|
+
private gameClient: CrowdyClient | null = null;
|
|
115
|
+
private worldHubValue: WorldHub | null = null;
|
|
116
|
+
private route: AppRoute | null = null;
|
|
117
|
+
private userValue: SessionUser | null = null;
|
|
118
|
+
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
119
|
+
private refreshFailures = 0;
|
|
120
|
+
private udpUnsubscribe: UnsubscribeFn | null = null;
|
|
121
|
+
private realtimeUnsubscribe: UnsubscribeFn | null = null;
|
|
122
|
+
private readonly udpHandlers = new Map<UdpHandlerKind, Set<UdpHandler>>();
|
|
123
|
+
|
|
124
|
+
private constructor() {
|
|
125
|
+
this.platform = createCrowdyClient({
|
|
126
|
+
httpUrl: API_HTTP_URL,
|
|
127
|
+
wsUrl: API_WS_URL,
|
|
128
|
+
tokenStore: new BrowserLocalStorageTokenStore(envScopedKey(APP_TOKEN_STORE_KEY)),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Sign-in (hosted) and the player's identity
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
get user(): SessionUser | null {
|
|
137
|
+
return this.userValue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Holding an app token (possibly expired; `restore` finds out). */
|
|
141
|
+
get isSignedIn(): boolean {
|
|
142
|
+
return Boolean(this.platform.session.getToken());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Send the player to Crowded Kingdoms' hosted sign-in for `appId`. The page
|
|
147
|
+
* navigates away; on return, `completeHostedSignInIfPresent()` finishes the
|
|
148
|
+
* job. The return address is this page itself, so no extra route is needed
|
|
149
|
+
* on whatever static host serves the game -- but its ORIGIN must be one of
|
|
150
|
+
* the app's registered redirect URIs (`npm run setup` registers the dev
|
|
151
|
+
* server's; Studio > Apps > Settings adds the production one).
|
|
152
|
+
*/
|
|
153
|
+
async signInHosted(appId: string): Promise<void> {
|
|
154
|
+
if (typeof window === 'undefined') throw new Error('Hosted sign-in needs a browser');
|
|
155
|
+
this.log(`Redirecting to hosted sign-in for app ${appId}`);
|
|
156
|
+
await this.platform.portal.signIn({
|
|
157
|
+
appId,
|
|
158
|
+
redirectUri: window.location.origin + window.location.pathname,
|
|
159
|
+
...(AUTHORIZE_URL ? { authorizeUrl: AUTHORIZE_URL } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Finish a hosted sign-in if the URL carries its `?code=`. Returns the
|
|
165
|
+
* entered app's route, or null when there is nothing to finish. The SDK
|
|
166
|
+
* strips `code`/`state` from the address bar so a reload does not replay.
|
|
167
|
+
* An `?error=access_denied` (the player pressed Cancel on the consent
|
|
168
|
+
* screen) is reported and cleared.
|
|
169
|
+
*/
|
|
170
|
+
async completeHostedSignInIfPresent(): Promise<AppRoute | null> {
|
|
171
|
+
if (typeof window === 'undefined') return null;
|
|
172
|
+
const url = new URL(window.location.href);
|
|
173
|
+
const denied = url.searchParams.get('error');
|
|
174
|
+
if (denied) {
|
|
175
|
+
url.searchParams.delete('error');
|
|
176
|
+
url.searchParams.delete('state');
|
|
177
|
+
window.history.replaceState({}, '', url.toString());
|
|
178
|
+
this.log(`Hosted sign-in did not complete: ${denied}`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (!url.searchParams.has('code')) return null;
|
|
182
|
+
try {
|
|
183
|
+
const minted = await this.platform.portal.handleSignInCallback();
|
|
184
|
+
if (!minted) return null;
|
|
185
|
+
const route = this.routeFrom(minted, minted.appId);
|
|
186
|
+
writeScoped(APP_ROUTE_STORAGE_KEY, JSON.stringify(route));
|
|
187
|
+
await this.hydrateUser();
|
|
188
|
+
this.log(`Hosted sign-in complete for app ${route.appId}`);
|
|
189
|
+
return route;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.log(`Hosted sign-in failed: ${messageOf(error)}`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Restore a stored app token + route from a previous visit. Returns the route
|
|
198
|
+
* when the token still authenticates, else null (and forgets both).
|
|
199
|
+
*/
|
|
200
|
+
async restore(): Promise<AppRoute | null> {
|
|
201
|
+
await this.platform.session.restore();
|
|
202
|
+
if (!this.platform.session.getToken()) return null;
|
|
203
|
+
const raw = readScoped(APP_ROUTE_STORAGE_KEY);
|
|
204
|
+
let route: AppRoute | null;
|
|
205
|
+
try {
|
|
206
|
+
route = raw ? (JSON.parse(raw) as AppRoute) : null;
|
|
207
|
+
} catch {
|
|
208
|
+
route = null;
|
|
209
|
+
}
|
|
210
|
+
if (!route?.appId) {
|
|
211
|
+
this.forgetCredentials();
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const user = await this.hydrateUser();
|
|
215
|
+
if (!user) return null;
|
|
216
|
+
return route;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async signOut(): Promise<void> {
|
|
220
|
+
this.leaveApp();
|
|
221
|
+
try {
|
|
222
|
+
// An app token may end its own session.
|
|
223
|
+
await this.platform.auth.logout();
|
|
224
|
+
} catch {
|
|
225
|
+
// Already invalid; forgetting it locally is the whole point.
|
|
226
|
+
}
|
|
227
|
+
this.forgetCredentials();
|
|
228
|
+
this.userValue = null;
|
|
229
|
+
this.events.emit('auth', null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private forgetCredentials(): void {
|
|
233
|
+
this.platform.session.setToken(null);
|
|
234
|
+
writeScoped(APP_ROUTE_STORAGE_KEY, null);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async hydrateUser(): Promise<SessionUser | null> {
|
|
238
|
+
try {
|
|
239
|
+
// `me` is one of the few management reads an app token may make.
|
|
240
|
+
const me = await this.platform.users.me();
|
|
241
|
+
if (!me) throw new Error('me() returned null');
|
|
242
|
+
this.userValue = {
|
|
243
|
+
userId: String(me.userId),
|
|
244
|
+
email: me.email ?? undefined,
|
|
245
|
+
gamertag: me.gamertag ?? undefined,
|
|
246
|
+
};
|
|
247
|
+
this.events.emit('auth', this.userValue);
|
|
248
|
+
return this.userValue;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
this.log(`Stored token is not valid: ${messageOf(error)}`);
|
|
251
|
+
this.forgetCredentials();
|
|
252
|
+
this.userValue = null;
|
|
253
|
+
this.events.emit('auth', null);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private routeFrom(
|
|
259
|
+
minted: {
|
|
260
|
+
appId?: string | null;
|
|
261
|
+
gameApiUrl?: string | null;
|
|
262
|
+
gameApiWsUrl?: string | null;
|
|
263
|
+
discoveryUrl?: string | null;
|
|
264
|
+
expiresAt: string;
|
|
265
|
+
},
|
|
266
|
+
fallbackAppId: string,
|
|
267
|
+
): AppRoute {
|
|
268
|
+
return {
|
|
269
|
+
appId: String(minted.appId ?? fallbackAppId),
|
|
270
|
+
gameApiUrl: minted.gameApiUrl ?? null,
|
|
271
|
+
gameApiWsUrl: minted.gameApiWsUrl ?? null,
|
|
272
|
+
discoveryUrl: minted.discoveryUrl ?? null,
|
|
273
|
+
expiresAt: minted.expiresAt,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// App entry
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/** The routed game client. Throws before `enterApp` has succeeded. */
|
|
282
|
+
get game(): CrowdyClient {
|
|
283
|
+
if (!this.gameClient) throw new Error('No app entered yet — call enterApp(appId) first');
|
|
284
|
+
return this.gameClient;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
get hasGame(): boolean {
|
|
288
|
+
return this.gameClient !== null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The entered app's world hub on ck-exec, over one connection the model and grid services
|
|
293
|
+
* share. It goes with the game client (another app, another endpoint, leaving).
|
|
294
|
+
*/
|
|
295
|
+
get worldHub(): WorldHub {
|
|
296
|
+
this.worldHubValue ??= new WorldHub(this.game, this.requireAppId());
|
|
297
|
+
return this.worldHubValue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
get appId(): string | null {
|
|
301
|
+
return this.route?.appId ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
get appRoute(): AppRoute | null {
|
|
305
|
+
return this.route;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Build the game client on the app's own endpoint with the app token the
|
|
310
|
+
* platform client holds. Idempotent for the same app; switches cleanly for
|
|
311
|
+
* another. The route comes from the hosted sign-in (or storage): the token
|
|
312
|
+
* response names the datacenter endpoint and the discovery URL.
|
|
313
|
+
*/
|
|
314
|
+
async enterApp(route: AppRoute): Promise<AppRoute> {
|
|
315
|
+
const token = this.platform.session.getToken();
|
|
316
|
+
if (!token) throw new Error('Sign in before entering an app');
|
|
317
|
+
if (this.route && this.route.appId !== route.appId) this.leaveApp();
|
|
318
|
+
|
|
319
|
+
const httpUrl = route.gameApiUrl ?? API_HTTP_URL;
|
|
320
|
+
const wsUrl = route.gameApiWsUrl ?? wsTwin(httpUrl);
|
|
321
|
+
if (!this.gameClient || !this.route || this.route.gameApiUrl !== route.gameApiUrl) {
|
|
322
|
+
this.disposeGameClient();
|
|
323
|
+
this.gameClient = createCrowdyClient({
|
|
324
|
+
httpUrl,
|
|
325
|
+
wsUrl,
|
|
326
|
+
graphqlEndpoint: graphqlEndpoint(httpUrl),
|
|
327
|
+
wsEndpoint: graphqlEndpoint(wsUrl),
|
|
328
|
+
tokenStore: new BrowserLocalStorageTokenStore(envScopedKey(`crowdyjs:app:${route.appId}`)),
|
|
329
|
+
realtime: {
|
|
330
|
+
retryAttempts: 8,
|
|
331
|
+
retryInitialDelayMs: 250,
|
|
332
|
+
retryMaxDelayMs: 5_000,
|
|
333
|
+
waitTimeoutMs: 5_000,
|
|
334
|
+
// Live video/voice should ride the binary relay when the API
|
|
335
|
+
// exposes it; CrowdyJS falls back to GraphQL if it is down.
|
|
336
|
+
binaryTransport: true,
|
|
337
|
+
// ALWAYS the shared origin, never the per-instance URL above: a
|
|
338
|
+
// token-holding client cannot re-mint, so when its instance dies
|
|
339
|
+
// it must be able to ask a name that always answers.
|
|
340
|
+
discoveryUrl: route.discoveryUrl ?? API_HTTP_URL,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
this.realtimeUnsubscribe = this.gameClient.realtime.onStatus((status) =>
|
|
344
|
+
this.events.emit('realtime', status),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
this.gameClient.setToken(token);
|
|
348
|
+
this.route = route;
|
|
349
|
+
writeScoped(APP_ROUTE_STORAGE_KEY, JSON.stringify(route));
|
|
350
|
+
this.refreshFailures = 0;
|
|
351
|
+
this.scheduleRefresh(APP_TOKEN_REFRESH_MS);
|
|
352
|
+
this.log(`Entered app ${route.appId} via ${httpUrl}`);
|
|
353
|
+
this.events.emit('app', route);
|
|
354
|
+
return route;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Version floor, spatial limits and the UDP proxy status for this app. */
|
|
358
|
+
async bootstrap(): Promise<BootstrapInfo> {
|
|
359
|
+
const appId = this.requireAppId();
|
|
360
|
+
const boot = await this.game.serverStatus.gameClientBootstrap(appId);
|
|
361
|
+
const min = boot.versionInfo?.minimumClientVersion;
|
|
362
|
+
return {
|
|
363
|
+
minimumClientVersion: min ? `${min.major}.${min.minor}.${min.patch}.${min.build}` : undefined,
|
|
364
|
+
maxReplicationDistance: boot.maxReplicationDistance ?? undefined,
|
|
365
|
+
udpConnected: boot.udpProxyConnectionStatus?.connected ?? undefined,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
leaveApp(): void {
|
|
370
|
+
this.clearRefresh();
|
|
371
|
+
this.disposeGameClient();
|
|
372
|
+
this.route = null;
|
|
373
|
+
this.events.emit('app', null);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private requireAppId(): string {
|
|
377
|
+
if (!this.route) throw new Error('No app entered');
|
|
378
|
+
return this.route.appId;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private disposeGameClient(): void {
|
|
382
|
+
this.worldHubValue?.close();
|
|
383
|
+
this.worldHubValue = null;
|
|
384
|
+
this.udpUnsubscribe?.();
|
|
385
|
+
this.udpUnsubscribe = null;
|
|
386
|
+
this.realtimeUnsubscribe?.();
|
|
387
|
+
this.realtimeUnsubscribe = null;
|
|
388
|
+
this.gameClient?.close();
|
|
389
|
+
this.gameClient = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Token rotation
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
private scheduleRefresh(delayMs: number): void {
|
|
397
|
+
this.clearRefresh();
|
|
398
|
+
this.refreshTimer = setTimeout(() => void this.rotateAppToken(), delayMs);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private clearRefresh(): void {
|
|
402
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
|
403
|
+
this.refreshTimer = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Rotate the app token while gameplay is live. The SDK disconnects the
|
|
408
|
+
* old-token UDP proxy, refreshes, and reconnects with handlers intact. The
|
|
409
|
+
* rotated token is mirrored onto the platform client so a reload restores
|
|
410
|
+
* the fresh one. There is no session to re-mint from: after two failures the
|
|
411
|
+
* player is sent back through hosted sign-in, which is one silent bounce
|
|
412
|
+
* while their Studio session lasts.
|
|
413
|
+
*/
|
|
414
|
+
private async rotateAppToken(): Promise<void> {
|
|
415
|
+
if (!this.gameClient || !this.route) return;
|
|
416
|
+
try {
|
|
417
|
+
const refreshed = await this.gameClient.refreshGameplayToken();
|
|
418
|
+
this.platform.session.setToken(refreshed.token);
|
|
419
|
+
this.route = { ...this.route, expiresAt: refreshed.expiresAt };
|
|
420
|
+
writeScoped(APP_ROUTE_STORAGE_KEY, JSON.stringify(this.route));
|
|
421
|
+
this.refreshFailures = 0;
|
|
422
|
+
this.log('App token rotated');
|
|
423
|
+
this.scheduleRefresh(APP_TOKEN_REFRESH_MS);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
this.refreshFailures += 1;
|
|
426
|
+
this.log(`App token rotation failed (${this.refreshFailures}): ${messageOf(error)}`);
|
|
427
|
+
if (this.refreshFailures >= 2) {
|
|
428
|
+
const appId = this.route.appId;
|
|
429
|
+
this.forgetCredentials();
|
|
430
|
+
this.events.emit('auth', null);
|
|
431
|
+
void this.signInHosted(appId);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.scheduleRefresh(APP_TOKEN_REFRESH_RETRY_MS);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Realtime fan-out (for lanes World Stores do not own: chat, events, errors)
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
on<K extends keyof UdpNotificationHandlers>(
|
|
443
|
+
kind: K,
|
|
444
|
+
handler: NonNullable<UdpNotificationHandlers[K]>,
|
|
445
|
+
): UnsubscribeFn {
|
|
446
|
+
let set = this.udpHandlers.get(kind);
|
|
447
|
+
if (!set) {
|
|
448
|
+
set = new Set();
|
|
449
|
+
this.udpHandlers.set(kind, set);
|
|
450
|
+
}
|
|
451
|
+
set.add(handler as UdpHandler);
|
|
452
|
+
this.ensureUdpSubscription();
|
|
453
|
+
return () => {
|
|
454
|
+
set?.delete(handler as UdpHandler);
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private ensureUdpSubscription(): void {
|
|
459
|
+
if (this.udpUnsubscribe || !this.gameClient || !this.route) return;
|
|
460
|
+
const dispatch = (kind: UdpHandlerKind) => (payload: unknown) => {
|
|
461
|
+
const set = this.udpHandlers.get(kind);
|
|
462
|
+
if (!set) return;
|
|
463
|
+
// A handler that throws must never unwind into the socket's onmessage:
|
|
464
|
+
// graphql-ws treats an exception there as a fatal bad response and tears
|
|
465
|
+
// down the whole subscription for every notification type.
|
|
466
|
+
for (const handler of [...set]) {
|
|
467
|
+
try {
|
|
468
|
+
(handler as (n: unknown) => void)(payload);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error(`[NetworkManager] ${kind} handler threw:`, error);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
const handlers: UdpNotificationHandlers = {
|
|
475
|
+
voxelUpdate: dispatch('voxelUpdate'),
|
|
476
|
+
text: dispatch('text'),
|
|
477
|
+
audio: dispatch('audio'),
|
|
478
|
+
video: dispatch('video'),
|
|
479
|
+
// The server's "this actor is gone" notice (CrowdyJS 15.5 / Buddy
|
|
480
|
+
// v0.25): the World Stores lane removes the actor on it, and services
|
|
481
|
+
// holding per-uuid resources (webcam textures) release them at once
|
|
482
|
+
// instead of waiting out the 12 s stale reaper.
|
|
483
|
+
actorLeft: dispatch('actorLeft'),
|
|
484
|
+
clientEvent: dispatch('clientEvent'),
|
|
485
|
+
serverEvent: dispatch('serverEvent'),
|
|
486
|
+
singleActorMessage: dispatch('singleActorMessage'),
|
|
487
|
+
channelMessage: dispatch('channelMessage'),
|
|
488
|
+
genericError: dispatch('genericError'),
|
|
489
|
+
connectionEvent: dispatch('connectionEvent'),
|
|
490
|
+
error: dispatch('error'),
|
|
491
|
+
};
|
|
492
|
+
this.udpUnsubscribe = this.gameClient.udp.subscribe(handlers, this.route.appId);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/** The last 100 log lines, newest last — for the HUD and for debugging. */
|
|
498
|
+
readonly recentLogs: string[] = [];
|
|
499
|
+
|
|
500
|
+
log(message: string): void {
|
|
501
|
+
const line = `${new Date().toISOString().slice(11, 19)} ${message}`;
|
|
502
|
+
this.recentLogs.push(line);
|
|
503
|
+
if (this.recentLogs.length > 100) this.recentLogs.shift();
|
|
504
|
+
this.events.emit('log', message);
|
|
505
|
+
if (import.meta.env.DEV) console.debug(`[network] ${message}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
close(): void {
|
|
509
|
+
this.clearRefresh();
|
|
510
|
+
this.disposeGameClient();
|
|
511
|
+
this.platform.close();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export type AnyNotification = UdpNotification;
|
|
516
|
+
|
|
517
|
+
export function messageOf(error: unknown): string {
|
|
518
|
+
return error instanceof Error ? error.message : String(error);
|
|
519
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { CrowdyClient, ExecCrate, ExecNodeTypeInput } from '@crowdedkingdoms/crowdyjs';
|
|
2
|
+
|
|
3
|
+
export const CONSTRUCTOR_TIER_NAME: string;
|
|
4
|
+
export const CONSTRUCTOR_TIER_KEYS: readonly string[];
|
|
5
|
+
export const VISITOR_RUN_KEYS: readonly string[];
|
|
6
|
+
export const STUDIO_AGENT_MODELS: readonly string[];
|
|
7
|
+
export const STUDIO_AGENT_MODEL: string;
|
|
8
|
+
export const STUDIO_AGENT_MODES: readonly string[];
|
|
9
|
+
export function slugify(value: string): string;
|
|
10
|
+
|
|
11
|
+
export type Logger = (line: string) => void;
|
|
12
|
+
|
|
13
|
+
export interface OnboardingStepEvent {
|
|
14
|
+
id:
|
|
15
|
+
| 'org'
|
|
16
|
+
| 'app'
|
|
17
|
+
| 'tier'
|
|
18
|
+
| 'redirects'
|
|
19
|
+
| 'enter'
|
|
20
|
+
| 'claims'
|
|
21
|
+
| 'model'
|
|
22
|
+
| 'exec'
|
|
23
|
+
| 'studio'
|
|
24
|
+
| 'agent'
|
|
25
|
+
| 'github';
|
|
26
|
+
label: string;
|
|
27
|
+
status: 'running' | 'done' | 'failed';
|
|
28
|
+
value?: unknown;
|
|
29
|
+
error?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OnboardingReport {
|
|
33
|
+
steps: OnboardingStepEvent[];
|
|
34
|
+
org: { orgId: string; slug: string; name: string };
|
|
35
|
+
app: { appId: string; name: string; slug: string };
|
|
36
|
+
appId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A Studio Common File the onboarding publishes (a starter template's file). */
|
|
40
|
+
export interface OnboardingCommonFile {
|
|
41
|
+
slug: string;
|
|
42
|
+
title: string;
|
|
43
|
+
content: string;
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A ck-exec manifest whose types name crates of the build (`exec/ckx.json`). */
|
|
48
|
+
export interface ExecManifest {
|
|
49
|
+
root: string;
|
|
50
|
+
types: Record<string, ExecNodeTypeInput>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Wraps each game API request of a deploy, e.g. to retry a busy API. */
|
|
54
|
+
export type RequestRetry = <T>(request: () => Promise<T>) => Promise<T>;
|
|
55
|
+
|
|
56
|
+
export interface DeployExecInput {
|
|
57
|
+
appId: string;
|
|
58
|
+
manifest: ExecManifest;
|
|
59
|
+
crates: ExecCrate[];
|
|
60
|
+
/** Types to switch off and on after the deploy, so running instances move to it. */
|
|
61
|
+
restart?: string[];
|
|
62
|
+
retry?: RequestRetry;
|
|
63
|
+
restartPauseMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OnboardingExec {
|
|
67
|
+
manifest: ExecManifest;
|
|
68
|
+
crates: ExecCrate[];
|
|
69
|
+
/** A client holding the developer's session on the app's own datacenter. */
|
|
70
|
+
client: (appId: string) => Promise<CrowdyClient>;
|
|
71
|
+
restart?: string[];
|
|
72
|
+
retry?: RequestRetry;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RunOnboardingOptions {
|
|
76
|
+
/** Origins to register as the app's redirect URIs (hosted sign-in + CORS). */
|
|
77
|
+
redirectOrigins?: string[];
|
|
78
|
+
/** A Game Model's Game Kit blueprints, for a game that keeps one. */
|
|
79
|
+
blueprints?: unknown[];
|
|
80
|
+
/** The game's ck-exec code, built and deployed as a step (the starter's world hub). */
|
|
81
|
+
exec?: OnboardingExec;
|
|
82
|
+
/** Studio Common Files to publish (starter templates); none by default. */
|
|
83
|
+
commonFiles?: OnboardingCommonFile[];
|
|
84
|
+
identity: CrowdyClient;
|
|
85
|
+
userId: string;
|
|
86
|
+
enterApp: (appId: string) => Promise<CrowdyClient>;
|
|
87
|
+
orgName: string;
|
|
88
|
+
appName: string;
|
|
89
|
+
appSlug?: string;
|
|
90
|
+
datacenter?: string;
|
|
91
|
+
log?: Logger;
|
|
92
|
+
onStep?: (event: OnboardingStepEvent) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ensureOrganization(
|
|
96
|
+
identity: CrowdyClient,
|
|
97
|
+
input: { name: string; slug?: string },
|
|
98
|
+
log?: Logger,
|
|
99
|
+
): Promise<{
|
|
100
|
+
org: { orgId: string; slug: string; name: string };
|
|
101
|
+
created: boolean;
|
|
102
|
+
permissions: string[];
|
|
103
|
+
}>;
|
|
104
|
+
export function ensureApp(
|
|
105
|
+
identity: CrowdyClient,
|
|
106
|
+
input: { orgId: string; orgSlug: string; name: string; slug: string; datacenter?: string },
|
|
107
|
+
log?: Logger,
|
|
108
|
+
): Promise<{ app: { appId: string; name: string; slug: string }; created: boolean }>;
|
|
109
|
+
export function ensureRedirectUris(
|
|
110
|
+
identity: CrowdyClient,
|
|
111
|
+
args: { appId: string; origins: string[] },
|
|
112
|
+
log?: Logger,
|
|
113
|
+
): Promise<{ redirectUris: string[]; added: string[] }>;
|
|
114
|
+
export function ensureConstructorTier(
|
|
115
|
+
identity: CrowdyClient,
|
|
116
|
+
input: { appId: string; userId: string },
|
|
117
|
+
log?: Logger,
|
|
118
|
+
): Promise<{
|
|
119
|
+
tier: { tierId: string; name: string };
|
|
120
|
+
created: boolean;
|
|
121
|
+
updated: boolean;
|
|
122
|
+
granted: boolean;
|
|
123
|
+
}>;
|
|
124
|
+
export function ensureSelfClaimPolicy(
|
|
125
|
+
game: CrowdyClient,
|
|
126
|
+
input: { appId: string },
|
|
127
|
+
log?: Logger,
|
|
128
|
+
): Promise<{ policy: string; changed: boolean }>;
|
|
129
|
+
export function deployExec(
|
|
130
|
+
exec: CrowdyClient,
|
|
131
|
+
input: DeployExecInput,
|
|
132
|
+
log?: Logger,
|
|
133
|
+
): Promise<{ buildId: string; version: number; restarted: string[] }>;
|
|
134
|
+
export function deployModel(
|
|
135
|
+
game: CrowdyClient,
|
|
136
|
+
input: { appId: string; blueprints: unknown[] },
|
|
137
|
+
log?: Logger,
|
|
138
|
+
): Promise<unknown>;
|
|
139
|
+
export function publishStarterFiles(
|
|
140
|
+
game: CrowdyClient,
|
|
141
|
+
input: { appId: string; commonFiles?: OnboardingCommonFile[] },
|
|
142
|
+
log?: Logger,
|
|
143
|
+
): Promise<Array<{ slug: string; status: 'current' | 'published'; versionNo?: number }>>;
|
|
144
|
+
export function ensureAgentPolicy(
|
|
145
|
+
identity: CrowdyClient,
|
|
146
|
+
input: { appId: string },
|
|
147
|
+
log?: Logger,
|
|
148
|
+
): Promise<{ enabled: boolean; skipped: boolean }>;
|
|
149
|
+
export function checkGitHubIntegration(
|
|
150
|
+
game: CrowdyClient,
|
|
151
|
+
input: { appId: string },
|
|
152
|
+
log?: Logger,
|
|
153
|
+
): Promise<{ connected: boolean; skipped: boolean; repo?: string }>;
|
|
154
|
+
export function runOnboarding(options: RunOnboardingOptions): Promise<OnboardingReport>;
|