@crowdedkingdoms/construct 0.0.0 → 0.2.1-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 +48 -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,239 @@
1
+ /**
2
+ * The engine-agnostic facade scenes receive. One instance per entered app.
3
+ *
4
+ * It wraps the World Stores session (presence, chunks, save), chat, webcam,
5
+ * voice, and the player's identity as a small, stable surface. Scenes read `players()` to
6
+ * render others and never touch the SDK; the loop calls `feedPose` / `moveTo`
7
+ * with what the active scene reports.
8
+ */
9
+ import { NetworkManager } from './network/NetworkManager';
10
+ import { FLAG_STUDIO_OPEN, NEUTRAL_POSE, type Pose } from './realtime/actorCodec';
11
+ import { chunkInput, type ChunkCoord } from './realtime/space';
12
+ import {
13
+ EMPTY_SAVE,
14
+ disposeWorldSession,
15
+ remotePlayers,
16
+ worldSession,
17
+ type ConstructWorldSession,
18
+ type RemotePlayer,
19
+ type SaveState,
20
+ } from './realtime/WorldStores';
21
+ import { WebcamService } from './media/WebcamService';
22
+ import { VoiceService } from './media/VoiceService';
23
+ import { ModelService } from './model/ModelService';
24
+ import { ChatService } from './social/ChatService';
25
+ import { StudioService } from './studio/StudioService';
26
+ import { Emitter } from './util/Emitter';
27
+
28
+ export interface GameSessionEvents {
29
+ joined: { chunk: ChunkCoord };
30
+ left: undefined;
31
+ }
32
+
33
+ export const TINT_COUNT = 8;
34
+
35
+ export class GameSession {
36
+ readonly events = new Emitter<GameSessionEvents>();
37
+ readonly chat: ChatService;
38
+ /** Proximity webcam: `toggle()` to send; scenes listen for `frame`/`ended`. */
39
+ readonly webcam: WebcamService;
40
+ /** Proximity voice: `toggle()` to send; playback is automatic. */
41
+ readonly voice: VoiceService;
42
+ readonly studio: StudioService;
43
+ readonly model: ModelService;
44
+ /** Flipped by the Studio service so other players see the "coding" marker. */
45
+ studioOpen = false;
46
+
47
+ private joinedFlag = false;
48
+ private tintValue = 0;
49
+ private save: SaveState = { ...EMPTY_SAVE };
50
+
51
+ constructor(readonly network: NetworkManager = NetworkManager.instance) {
52
+ this.chat = new ChatService(network);
53
+ this.webcam = new WebcamService({
54
+ onVideo: (handler) => network.on('video', handler),
55
+ onActorLeft: (handler) => network.on('actorLeft', handler),
56
+ onLaneLeave: (handler) =>
57
+ this.world.actors.lane('players').onLeave((actor) => handler(actor.uuid)),
58
+ selfUuid: () => (this.joinedFlag ? this.world.self.uuid : null),
59
+ selfChunk: () => {
60
+ if (!this.joinedFlag) return null;
61
+ const chunk = this.world.self.chunk;
62
+ return chunk ? { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) } : null;
63
+ },
64
+ appId: () => this.appId,
65
+ sendVideoFrame: (input) => network.game.udp.sendVideoFrame(input),
66
+ log: (message) => network.log(message),
67
+ });
68
+ this.voice = new VoiceService({
69
+ onAudio: (handler) => network.on('audio', handler),
70
+ onActorLeft: (handler) => network.on('actorLeft', handler),
71
+ onLaneLeave: (handler) =>
72
+ this.world.actors.lane('players').onLeave((actor) => handler(actor.uuid)),
73
+ selfUuid: () => (this.joinedFlag ? this.world.self.uuid : null),
74
+ selfChunk: () => {
75
+ if (!this.joinedFlag) return null;
76
+ const chunk = this.world.self.chunk;
77
+ return chunk ? { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) } : null;
78
+ },
79
+ selfPosition: () => {
80
+ if (!this.joinedFlag) return null;
81
+ const pose = this.world.self.state;
82
+ return pose ? { x: pose.x, y: pose.y, z: pose.z } : null;
83
+ },
84
+ remotePositions: () =>
85
+ remotePlayers().map((player) => ({
86
+ uuid: player.uuid,
87
+ x: player.pose.x,
88
+ y: player.pose.y,
89
+ z: player.pose.z,
90
+ })),
91
+ appId: () => this.appId,
92
+ sendAudioPacket: (input) => network.game.udp.sendAudioPacket(input),
93
+ log: (message) => network.log(message),
94
+ });
95
+ this.studio = new StudioService(this);
96
+ this.model = new ModelService(this);
97
+ }
98
+
99
+ get appId(): string {
100
+ const appId = this.network.appId;
101
+ if (!appId) throw new Error('GameSession needs an entered app');
102
+ return appId;
103
+ }
104
+
105
+ get userId(): string {
106
+ return this.network.user?.userId ?? '';
107
+ }
108
+
109
+ /** What other players see over your head. */
110
+ get displayName(): string {
111
+ const user = this.network.user;
112
+ return (user?.gamertag || user?.email?.split('@')[0] || 'player').slice(0, 20);
113
+ }
114
+
115
+ get tint(): number {
116
+ return this.tintValue;
117
+ }
118
+
119
+ setTint(tint: number): void {
120
+ this.tintValue = ((tint % TINT_COUNT) + TINT_COUNT) % TINT_COUNT;
121
+ this.world.save.patch({ tint: this.tintValue });
122
+ }
123
+
124
+ get joined(): boolean {
125
+ return this.joinedFlag;
126
+ }
127
+
128
+ /** The World Stores session for this app (lazily created). */
129
+ get world(): ConstructWorldSession {
130
+ return worldSession();
131
+ }
132
+
133
+ get selfUuid(): string {
134
+ return this.world.self.uuid;
135
+ }
136
+
137
+ /** The saved state as loaded (or the empty default). */
138
+ get saved(): Readonly<SaveState> {
139
+ return this.save;
140
+ }
141
+
142
+ /** Load the save blob and hydrate identity-ish fields (tint). */
143
+ async loadSave(): Promise<SaveState> {
144
+ const loaded = await this.world.save.load();
145
+ this.save = loaded && loaded.version === 1 ? loaded : { ...EMPTY_SAVE };
146
+ if (!loaded) this.world.save.set(this.save);
147
+ if (this.save.tint !== undefined) this.tintValue = this.save.tint;
148
+ else this.setTint(hashTint(this.userId));
149
+ return this.save;
150
+ }
151
+
152
+ /** Announce presence at `position` and start receiving the world. */
153
+ async join(position: { x: number; y: number; z: number }): Promise<void> {
154
+ const chunk = chunkInput({
155
+ x: Math.floor(position.x / 16),
156
+ y: Math.floor(position.y / 16),
157
+ z: Math.floor(position.z / 16),
158
+ });
159
+ await this.world.self.join(chunk, {
160
+ ...NEUTRAL_POSE,
161
+ x: position.x,
162
+ y: position.y,
163
+ z: position.z,
164
+ name: this.displayName,
165
+ tint: this.tint,
166
+ });
167
+ this.joinedFlag = true;
168
+ this.chat.start();
169
+ this.webcam.listen();
170
+ this.voice.listen();
171
+ this.world.save.patch({ visits: (this.save.visits ?? 0) + 1 });
172
+ this.events.emit('joined', {
173
+ chunk: { x: Number(chunk.x), y: Number(chunk.y), z: Number(chunk.z) },
174
+ });
175
+ }
176
+
177
+ feedPose(pose: Pose): void {
178
+ if (!this.joinedFlag) return;
179
+ this.world.self.setState(pose);
180
+ }
181
+
182
+ extraPoseFlags(): number {
183
+ return this.studioOpen ? FLAG_STUDIO_OPEN : 0;
184
+ }
185
+
186
+ async moveTo(chunk: ChunkCoord): Promise<void> {
187
+ if (!this.joinedFlag) return;
188
+ try {
189
+ await this.world.self.moveTo(chunkInput(chunk));
190
+ await this.world.chunks.ensureAround(chunk, 1);
191
+ } catch (error) {
192
+ this.network.log(`moveTo failed: ${error instanceof Error ? error.message : String(error)}`);
193
+ }
194
+ }
195
+
196
+ rememberPosition(sceneId: string, pose: Pose): void {
197
+ this.world.save.patch({
198
+ position: { x: pose.x, y: pose.y, z: pose.z, yaw: pose.yaw },
199
+ lastProgram: sceneId === 'holodeck' ? 0 : this.save.lastProgram,
200
+ });
201
+ }
202
+
203
+ rememberProgram(programId: number): void {
204
+ this.save = { ...this.save, lastProgram: programId };
205
+ this.world.save.patch({ lastProgram: programId });
206
+ }
207
+
208
+ /** Remote players, optionally only those in the same program. */
209
+ players(programId?: number): RemotePlayer[] {
210
+ const all = remotePlayers();
211
+ return programId === undefined ? all : all.filter((p) => p.pose.program === programId);
212
+ }
213
+
214
+ async flushSave(): Promise<void> {
215
+ try {
216
+ await this.world.save.save();
217
+ } catch (error) {
218
+ this.network.log(`save failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ }
221
+
222
+ async leave(): Promise<void> {
223
+ if (!this.joinedFlag) return;
224
+ this.joinedFlag = false;
225
+ this.chat.stop();
226
+ this.webcam.dispose();
227
+ this.voice.dispose();
228
+ this.studio.close();
229
+ await this.flushSave();
230
+ disposeWorldSession();
231
+ this.events.emit('left', undefined);
232
+ }
233
+ }
234
+
235
+ function hashTint(seed: string): number {
236
+ let h = 0;
237
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
238
+ return h % TINT_COUNT;
239
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Sign-in for a game on its own domain: HOSTED, always.
3
+ *
4
+ * The player is sent to Crowded Kingdoms' sign-in page (Studio's `/authorize`)
5
+ * with a PKCE challenge and comes back with a token confined to this app. This
6
+ * page never sees a password and never holds an identity session -- the direct
7
+ * sign-in mutations are refused from any browser origin that is not Crowded
8
+ * Kingdoms' own (`HOSTED_SIGN_IN_REQUIRED`, ck-api v1.88.0). The reason is the
9
+ * player's password: a form here that collected it would be indistinguishable,
10
+ * to the platform and to the player, from a phishing page.
11
+ *
12
+ * What this means for a third-party game:
13
+ * - there is no login form, register form, magic link or guest account in the
14
+ * browser. Account creation happens on Studio's page during the redirect.
15
+ * - the app id must be known BEFORE sign-in (the redirect names it). It comes
16
+ * from `VITE_APP_ID`, `?app=`, or this browser's storage; `npm run setup`
17
+ * creates the app and prints the id.
18
+ * - this page's origin must be one of the app's registered redirect URIs.
19
+ * `npm run setup` registers the dev server's; add the production origin in
20
+ * Studio > Apps > Settings > Sign-in & redirect URIs.
21
+ */
22
+ import { NetworkManager, type AppRoute } from '../network/NetworkManager';
23
+
24
+ export class AuthService {
25
+ constructor(private readonly network: NetworkManager = NetworkManager.instance) {}
26
+
27
+ /** Leave for Crowded Kingdoms' sign-in page; the browser navigates away. */
28
+ async signIn(appId: string): Promise<void> {
29
+ await this.network.signInHosted(appId);
30
+ }
31
+
32
+ /** Finish a sign-in this page is returning from, if any. */
33
+ async completeIfReturning(): Promise<AppRoute | null> {
34
+ return this.network.completeHostedSignInIfPresent();
35
+ }
36
+
37
+ /** A previous visit's token and route, if they still work. */
38
+ async restore(): Promise<AppRoute | null> {
39
+ return this.network.restore();
40
+ }
41
+
42
+ async signOut(): Promise<void> {
43
+ await this.network.signOut();
44
+ }
45
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // The SDK root barrel drags Monaco into a unit test; config only needs the
4
+ // two generated constants.
5
+ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
6
+ CROWDY_DEFAULT_HTTP_ORIGIN: 'https://ck.test.example',
7
+ CROWDY_DEFAULT_TIER: 'test',
8
+ }));
9
+
10
+ const { resolveAppId, resolveAuthorizeUrl, resolveStudioOrigin, API_HTTP_URL, API_WS_URL } =
11
+ await import('./config');
12
+
13
+ describe('resolveAppId', () => {
14
+ it('prefers the URL, then storage, then the build', () => {
15
+ expect(resolveAppId({ search: '?app=123', stored: '456', env: '789' })).toEqual({
16
+ appId: '123',
17
+ source: 'query',
18
+ });
19
+ expect(resolveAppId({ search: '', stored: '456', env: '789' })).toEqual({
20
+ appId: '456',
21
+ source: 'storage',
22
+ });
23
+ expect(resolveAppId({ search: null, stored: null, env: '789' })).toEqual({
24
+ appId: '789',
25
+ source: 'env',
26
+ });
27
+ });
28
+
29
+ it('falls through to none when nothing is set', () => {
30
+ expect(resolveAppId({})).toEqual({ appId: null, source: 'none' });
31
+ });
32
+
33
+ it('ignores values that are not numeric ids', () => {
34
+ expect(resolveAppId({ search: '?app=drop%20table', stored: 'abc', env: ' ' })).toEqual({
35
+ appId: null,
36
+ source: 'none',
37
+ });
38
+ expect(resolveAppId({ search: '?app=', stored: '42' })).toEqual({
39
+ appId: '42',
40
+ source: 'storage',
41
+ });
42
+ });
43
+ });
44
+
45
+ describe('API origin', () => {
46
+ it('defaults to the SDK build origin with a websocket twin', () => {
47
+ expect(API_HTTP_URL).toBe('https://ck.test.example');
48
+ expect(API_WS_URL).toBe('wss://ck.test.example');
49
+ });
50
+ });
51
+
52
+ describe('resolveAuthorizeUrl', () => {
53
+ it('rewrites a loopback authorize host when the page is public', () => {
54
+ expect(
55
+ resolveAuthorizeUrl({
56
+ authorizeUrl: 'http://127.0.0.1:5173/authorize',
57
+ pageHostname: '203.0.113.8',
58
+ }),
59
+ ).toBe('http://203.0.113.8:5173/authorize');
60
+ });
61
+
62
+ it('keeps loopback when the page is also loopback', () => {
63
+ expect(
64
+ resolveAuthorizeUrl({
65
+ authorizeUrl: 'http://localhost:5173/authorize?app=1',
66
+ pageHostname: '127.0.0.1',
67
+ }),
68
+ ).toBe('http://localhost:5173/authorize?app=1');
69
+ });
70
+
71
+ it('returns undefined when nothing is set', () => {
72
+ expect(resolveAuthorizeUrl({})).toBeUndefined();
73
+ expect(resolveAuthorizeUrl({ authorizeUrl: ' ' })).toBeUndefined();
74
+ });
75
+ });
76
+
77
+ describe('resolveStudioOrigin', () => {
78
+ it('prefers an explicit Studio URL', () => {
79
+ expect(
80
+ resolveStudioOrigin({
81
+ authorizeUrl: 'http://127.0.0.1:5173/authorize',
82
+ studioUrl: 'https://studio.example/app',
83
+ pageHostname: '203.0.113.8',
84
+ }),
85
+ ).toBe('https://studio.example');
86
+ });
87
+
88
+ it('rewrites a loopback authorize host when the page is public', () => {
89
+ expect(
90
+ resolveStudioOrigin({
91
+ authorizeUrl: 'http://127.0.0.1:5173/authorize',
92
+ pageHostname: '203.0.113.8',
93
+ }),
94
+ ).toBe('http://203.0.113.8:5173');
95
+ });
96
+
97
+ it('keeps loopback when the page is also loopback', () => {
98
+ expect(
99
+ resolveStudioOrigin({
100
+ authorizeUrl: 'http://localhost:5173/authorize',
101
+ pageHostname: '127.0.0.1',
102
+ }),
103
+ ).toBe('http://localhost:5173');
104
+ });
105
+
106
+ it('returns null when nothing can be derived', () => {
107
+ expect(resolveStudioOrigin({})).toBeNull();
108
+ expect(resolveStudioOrigin({ authorizeUrl: 'not-a-url' })).toBeNull();
109
+ });
110
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Build-time and runtime configuration for The Construct.
3
+ *
4
+ * The starter needs almost nothing configured: the installed CrowdyJS build
5
+ * already knows the public API origin for its tier, and the app id can come
6
+ * from the Setup wizard instead of an env file. Everything here is therefore
7
+ * optional and resolved in a documented order, and the pure functions are
8
+ * unit-tested in `config.test.ts`.
9
+ */
10
+ import { CROWDY_DEFAULT_HTTP_ORIGIN, CROWDY_DEFAULT_TIER } from '@crowdedkingdoms/crowdyjs';
11
+
12
+ function envString(name: string): string | undefined {
13
+ const value = (import.meta.env as Record<string, string | undefined>)[name];
14
+ const trimmed = value?.trim();
15
+ return trimmed ? trimmed : undefined;
16
+ }
17
+
18
+ function envFlag(name: string, fallback: boolean): boolean {
19
+ const raw = envString(name);
20
+ if (raw === undefined) return fallback;
21
+ return !['0', 'false', 'off', 'no'].includes(raw.toLowerCase());
22
+ }
23
+
24
+ function trimApiOrigin(url: string): string {
25
+ return url.replace(/\/graphql\/?$/, '').replace(/\/$/, '');
26
+ }
27
+
28
+ /**
29
+ * The API root the bundle dials. Explicit override first; otherwise the origin
30
+ * the SDK was published with. Never a hostname written into this repository.
31
+ *
32
+ * `same-origin` (or `/`) means "this page" — used when Vite proxies `/graphql`
33
+ * so the IDE browser on a public IP and Playwright on localhost share one build.
34
+ */
35
+ export const API_HTTP_URL: string = trimApiOrigin(
36
+ (() => {
37
+ const raw = envString('VITE_CROWDY_HTTP_URL');
38
+ if (!raw) return CROWDY_DEFAULT_HTTP_ORIGIN;
39
+ if (
40
+ (raw === 'same-origin' || raw === '/') &&
41
+ typeof window !== 'undefined' &&
42
+ window.location?.origin
43
+ ) {
44
+ return window.location.origin;
45
+ }
46
+ return raw;
47
+ })(),
48
+ );
49
+
50
+ /** The same host over WebSocket. */
51
+ export const API_WS_URL: string = API_HTTP_URL.replace(
52
+ /^http(s?):/,
53
+ (_m, secure: string) => `ws${secure}:`,
54
+ );
55
+
56
+ /** Which tier the SDK build targets; informational (shown in the boot card). */
57
+ export const API_TIER: string = envString('VITE_CROWDY_HTTP_URL') ? 'custom' : CROWDY_DEFAULT_TIER;
58
+
59
+ function originOf(value: string): string | null {
60
+ try {
61
+ return new URL(value).origin;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function isLoopbackHost(host: string): boolean {
68
+ const normalized = host.toLowerCase();
69
+ return (
70
+ normalized === 'localhost' ||
71
+ normalized === '127.0.0.1' ||
72
+ normalized === '::1' ||
73
+ normalized === '[::1]'
74
+ );
75
+ }
76
+
77
+ /**
78
+ * When the game is opened on a public IP (IDE browser) but Studio is
79
+ * configured as loopback, rewrite the hostname so navigation can actually
80
+ * reach it. Path, port, and query stay intact.
81
+ */
82
+ function rewriteLoopbackToPageHost(raw: string, pageHostname?: string | null): URL | null {
83
+ try {
84
+ const url = new URL(raw);
85
+ const pageHost = pageHostname?.trim();
86
+ if (pageHost && isLoopbackHost(url.hostname) && !isLoopbackHost(pageHost)) {
87
+ url.hostname = pageHost;
88
+ }
89
+ return url;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Hosted sign-in URL. Same loopback rewrite as Studio origin — otherwise
97
+ * `portal.signIn` sends the IDE browser to `127.0.0.1` and the tab goes blank.
98
+ */
99
+ export function resolveAuthorizeUrl(input: {
100
+ authorizeUrl?: string | null;
101
+ pageHostname?: string | null;
102
+ }): string | undefined {
103
+ const raw = input.authorizeUrl?.trim();
104
+ if (!raw) return undefined;
105
+ return rewriteLoopbackToPageHost(raw, input.pageHostname)?.toString() ?? raw;
106
+ }
107
+
108
+ /**
109
+ * Hosted sign-in page (Studio `/authorize`). Required when `VITE_CROWDY_HTTP_URL`
110
+ * is not a CK tier host — CrowdyJS cannot derive Studio from a same-origin
111
+ * proxy or a raw IP. Leave unset to use the SDK's tier convention.
112
+ */
113
+ export const AUTHORIZE_URL: string | undefined = resolveAuthorizeUrl({
114
+ authorizeUrl: envString('VITE_AUTHORIZE_URL'),
115
+ pageHostname: typeof window !== 'undefined' ? window.location.hostname : null,
116
+ });
117
+
118
+ /**
119
+ * Studio origin for in-game links. Explicit `VITE_STUDIO_URL` wins; otherwise
120
+ * the origin of the hosted-sign-in URL. When that URL is loopback and the
121
+ * page is not (public-IP IDE against a local stack), the page hostname is
122
+ * substituted so Wallet opens the Studio the player can actually reach.
123
+ * Never a hostname written into this repository.
124
+ */
125
+ export function resolveStudioOrigin(input: {
126
+ authorizeUrl?: string | null;
127
+ studioUrl?: string | null;
128
+ pageHostname?: string | null;
129
+ }): string | null {
130
+ const explicit = input.studioUrl?.trim();
131
+ if (explicit) return originOf(explicit);
132
+ const authorize = input.authorizeUrl?.trim();
133
+ if (!authorize) return null;
134
+ return rewriteLoopbackToPageHost(authorize, input.pageHostname)?.origin ?? null;
135
+ }
136
+
137
+ export const STUDIO_ORIGIN: string | null = resolveStudioOrigin({
138
+ authorizeUrl: envString('VITE_AUTHORIZE_URL'),
139
+ studioUrl: envString('VITE_STUDIO_URL'),
140
+ pageHostname: typeof window !== 'undefined' ? window.location.hostname : null,
141
+ });
142
+
143
+ /** Player wallet page in Studio. Null when we cannot derive Studio's origin. */
144
+ export const STUDIO_WALLET_URL: string | null = STUDIO_ORIGIN
145
+ ? `${STUDIO_ORIGIN}/account/wallet`
146
+ : null;
147
+
148
+ /** CLIENT-target Crowdy Studio mods (browser sandbox). A fork can turn these off. */
149
+ export const CLIENT_MODS_ENABLED: boolean = envFlag('VITE_CONSTRUCT_CLIENT_MODS', true);
150
+
151
+ /** App-token rotation cadence. Tokens live ~30 minutes; rotate well before. */
152
+ export const APP_TOKEN_REFRESH_MS: number =
153
+ Math.max(60, Number(envString('VITE_APP_TOKEN_REFRESH_SECONDS') ?? 1200)) * 1000;
154
+
155
+ /** Retry a failed rotation sooner than the full interval. */
156
+ export const APP_TOKEN_REFRESH_RETRY_MS = 30_000;
157
+
158
+ /** Fixed spatial parameters shared by every scene. */
159
+ export const CHUNK_SIZE = 16;
160
+ export const REPLICATION_DISTANCE = 4;
161
+ export const ACTOR_SYNC_INTERVAL_MS = 200;
162
+ export const STALE_ACTOR_TIMEOUT_MS = 12_000;
163
+
164
+ /** Shown in the Studio dock and the boot card; `VITE_GAME_NAME` overrides it. */
165
+ export const GAME_NAME: string = envString('VITE_GAME_NAME') ?? 'The Construct';
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // App id resolution
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export const APP_ID_STORAGE_KEY = 'construct:app-id';
172
+
173
+ export interface AppIdSources {
174
+ /** `window.location.search` (or any query string). */
175
+ search?: string | null;
176
+ /** The env-scoped storage the wizard writes to. */
177
+ stored?: string | null;
178
+ /** `VITE_APP_ID` as built. */
179
+ env?: string | null;
180
+ }
181
+
182
+ export type AppIdResolution =
183
+ { appId: string; source: 'query' | 'storage' | 'env' } | { appId: null; source: 'none' };
184
+
185
+ const APP_ID_PATTERN = /^\d{1,20}$/;
186
+
187
+ function validAppId(value: string | null | undefined): string | null {
188
+ const trimmed = value?.trim();
189
+ return trimmed && APP_ID_PATTERN.test(trimmed) ? trimmed : null;
190
+ }
191
+
192
+ /**
193
+ * Resolution order, most explicit first:
194
+ * 1. `?app=<id>` in the URL (share a link to a specific app),
195
+ * 2. the id the Setup wizard remembered in this browser (env-scoped),
196
+ * 3. `VITE_APP_ID` baked into the build,
197
+ * 4. none — the boot flow opens the Setup wizard.
198
+ */
199
+ export function resolveAppId(sources: AppIdSources): AppIdResolution {
200
+ const fromQuery = validAppId(
201
+ sources.search ? new URLSearchParams(sources.search).get('app') : null,
202
+ );
203
+ if (fromQuery) return { appId: fromQuery, source: 'query' };
204
+ const fromStorage = validAppId(sources.stored);
205
+ if (fromStorage) return { appId: fromStorage, source: 'storage' };
206
+ const fromEnv = validAppId(sources.env);
207
+ if (fromEnv) return { appId: fromEnv, source: 'env' };
208
+ return { appId: null, source: 'none' };
209
+ }
210
+
211
+ /** The env-baked app id, if any (exposed for the boot flow + the wizard UI). */
212
+ export const BUILD_APP_ID: string | null = validAppId(envString('VITE_APP_ID'));
@@ -0,0 +1,42 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
4
+ CROWDY_DEFAULT_HTTP_ORIGIN: 'https://ck.test.example',
5
+ CROWDY_DEFAULT_TIER: 'test',
6
+ }));
7
+
8
+ const { ENV_HANDLE, envHandleFor, envScopedKey, ensureEnvScope, readScoped, writeScoped } =
9
+ await import('./envScope');
10
+
11
+ describe('envScope', () => {
12
+ beforeEach(() => localStorage.clear());
13
+
14
+ it('derives a handle from the origin without inspecting hostname shape', () => {
15
+ expect(envHandleFor('https://ck.prod.crowdedkingdoms.com')).toBe('ck.prod.crowdedkingdoms.com');
16
+ expect(envHandleFor('http://localhost:3000')).toBe('localhost_3000');
17
+ expect(envHandleFor('not a url')).toBe('not_a_url');
18
+ expect(ENV_HANDLE).toBe('ck.test.example');
19
+ });
20
+
21
+ it('suffixes keys with the handle', () => {
22
+ expect(envScopedKey('construct:app-id')).toBe('construct:app-id:ck.test.example');
23
+ expect(envScopedKey('x', 'other')).toBe('x:other');
24
+ });
25
+
26
+ it('drops per-environment values when the environment changes', () => {
27
+ localStorage.setItem('construct:env-handle', 'ck.old.example');
28
+ localStorage.setItem('construct:app-id:ck.old.example', '111');
29
+ localStorage.setItem('construct:app-id:ck.test.example', '222');
30
+ ensureEnvScope();
31
+ expect(localStorage.getItem('construct:app-id:ck.old.example')).toBeNull();
32
+ expect(localStorage.getItem('construct:app-id:ck.test.example')).toBe('222');
33
+ expect(localStorage.getItem('construct:env-handle')).toBe('ck.test.example');
34
+ });
35
+
36
+ it('reads and writes scoped values', () => {
37
+ writeScoped('construct:app-id', '99');
38
+ expect(readScoped('construct:app-id')).toBe('99');
39
+ writeScoped('construct:app-id', null);
40
+ expect(readScoped('construct:app-id')).toBeNull();
41
+ });
42
+ });