@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.
Files changed (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +46 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. 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>;