@crowdedkingdoms/construct 0.3.8 → 0.3.10-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 +1 -1
- package/package.json +1 -1
- package/src/platform/envScope.test.ts +23 -2
- package/src/platform/envScope.ts +23 -0
- package/src/platform/network/NetworkManager.ts +18 -10
- package/src/platform/studio/clientModHost.test.ts +19 -1
- package/src/platform/studio/clientModHost.ts +9 -8
- package/src/platform/studio/modChunkRuntime.ts +4 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Releases are on npm under one dist-tag per tier, and a game pins the exact
|
|
|
30
30
|
version of its own tier, as it pins CrowdyJS:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
npm install --save-exact @crowdedkingdoms/construct # X.Y.Z
|
|
33
|
+
npm install --save-exact @crowdedkingdoms/construct@dev # an X.Y.Z-dev.N build
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
`dev` is `X.Y.Z-dev.N`, `test` is `X.Y.Z-test.N`, and `prod` is `X.Y.Z` on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10-dev.1",
|
|
4
4
|
"description": "The Construct framework: the Crowded Kingdoms game platform layer (hosted sign-in, World Stores, voice/webcam/chat, Crowdy Studio dock, app and grid mods, grid programs) and engine loop, shared by the-construct starter and the games built on it. Ships TypeScript source for Vite.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -5,8 +5,16 @@ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
|
|
|
5
5
|
CROWDY_DEFAULT_TIER: 'test',
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
|
-
const {
|
|
9
|
-
|
|
8
|
+
const {
|
|
9
|
+
ENV_HANDLE,
|
|
10
|
+
appTokenStorageKey,
|
|
11
|
+
envHandleFor,
|
|
12
|
+
envScopedKey,
|
|
13
|
+
ensureEnvScope,
|
|
14
|
+
perGameBase,
|
|
15
|
+
readScoped,
|
|
16
|
+
writeScoped,
|
|
17
|
+
} = await import('./envScope');
|
|
10
18
|
|
|
11
19
|
describe('envScope', () => {
|
|
12
20
|
beforeEach(() => localStorage.clear());
|
|
@@ -23,6 +31,19 @@ describe('envScope', () => {
|
|
|
23
31
|
expect(envScopedKey('x', 'other')).toBe('x:other');
|
|
24
32
|
});
|
|
25
33
|
|
|
34
|
+
it('keys a saved app token by game path and API host', () => {
|
|
35
|
+
expect(appTokenStorageKey('/matchstick/')).toBe('crowdyjs:app:/matchstick/:ck.test.example');
|
|
36
|
+
expect(appTokenStorageKey('/blocks-with-friends/', 'ck.dev.example')).toBe(
|
|
37
|
+
'crowdyjs:app:/blocks-with-friends/:ck.dev.example',
|
|
38
|
+
);
|
|
39
|
+
expect(appTokenStorageKey('/matchstick/', 'ck.dev.example')).not.toBe(
|
|
40
|
+
appTokenStorageKey('/other-game/', 'ck.dev.example'),
|
|
41
|
+
);
|
|
42
|
+
expect(perGameBase('construct:app-route', '/matchstick/')).toBe(
|
|
43
|
+
'construct:app-route:/matchstick/',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
26
47
|
it('drops per-environment values when the environment changes', () => {
|
|
27
48
|
localStorage.setItem('construct:env-handle', 'ck.old.example');
|
|
28
49
|
localStorage.setItem('construct:app-id:ck.old.example', '111');
|
package/src/platform/envScope.ts
CHANGED
|
@@ -30,6 +30,29 @@ export function envScopedKey(base: string, handle: string = ENV_HANDLE): string
|
|
|
30
30
|
return `${base}:${handle}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* `<base>:<game-path>`, for `readScoped` / `writeScoped`, which append the host.
|
|
35
|
+
*
|
|
36
|
+
* The host alone is one key for every Construct game on a games host. Blocks
|
|
37
|
+
* with Friends keys its app token by `import.meta.env.BASE_URL` so two games
|
|
38
|
+
* do not share a session. Pass that base here.
|
|
39
|
+
*/
|
|
40
|
+
export function perGameBase(base: string, gamePath: string): string {
|
|
41
|
+
return `${base}:${gamePath}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* localStorage key for the saved app token.
|
|
46
|
+
*
|
|
47
|
+
* Shape is `BrowserLocalStorageTokenStore.appKey(gamePath)` plus the API-host
|
|
48
|
+
* handle: `crowdyjs:app:/matchstick/:ck.dev.crowdedkingdoms.com`. The path
|
|
49
|
+
* keeps a second Construct game from restoring Matchstick's token. The handle
|
|
50
|
+
* keeps a localhost build pointed at another tier from restoring the first.
|
|
51
|
+
*/
|
|
52
|
+
export function appTokenStorageKey(gamePath: string, handle: string = ENV_HANDLE): string {
|
|
53
|
+
return envScopedKey(`crowdyjs:app:${gamePath}`, handle);
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
/** Keys that describe the previous environment and must not survive a switch. */
|
|
34
57
|
const SCOPED_LOCAL_PREFIXES = ['construct:app-id', 'construct:last-program'];
|
|
35
58
|
|
|
@@ -40,7 +40,13 @@ import {
|
|
|
40
40
|
APP_TOKEN_REFRESH_RETRY_MS,
|
|
41
41
|
AUTHORIZE_URL,
|
|
42
42
|
} from '../config';
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
appTokenStorageKey,
|
|
45
|
+
envScopedKey,
|
|
46
|
+
perGameBase,
|
|
47
|
+
readScoped,
|
|
48
|
+
writeScoped,
|
|
49
|
+
} from '../envScope';
|
|
44
50
|
import { WorldHub } from '../exec/worldHub';
|
|
45
51
|
import { Emitter } from '../util/Emitter';
|
|
46
52
|
|
|
@@ -58,11 +64,13 @@ export interface AppRoute {
|
|
|
58
64
|
expiresAt: string;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
/** Where the entered app's route is remembered across a reload (
|
|
67
|
+
/** Where the entered app's route is remembered across a reload (per game path, per API host). */
|
|
62
68
|
export const APP_ROUTE_STORAGE_KEY = 'construct:app-route';
|
|
63
69
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
70
|
+
/** Route key for this game's Vite base, before the API-host suffix `readScoped` adds. */
|
|
71
|
+
function savedRouteBase(): string {
|
|
72
|
+
return perGameBase(APP_ROUTE_STORAGE_KEY, import.meta.env.BASE_URL);
|
|
73
|
+
}
|
|
66
74
|
|
|
67
75
|
export interface BootstrapInfo {
|
|
68
76
|
minimumClientVersion?: string;
|
|
@@ -125,7 +133,7 @@ export class NetworkManager {
|
|
|
125
133
|
this.platform = createCrowdyClient({
|
|
126
134
|
httpUrl: API_HTTP_URL,
|
|
127
135
|
wsUrl: API_WS_URL,
|
|
128
|
-
tokenStore: new BrowserLocalStorageTokenStore(
|
|
136
|
+
tokenStore: new BrowserLocalStorageTokenStore(appTokenStorageKey(import.meta.env.BASE_URL)),
|
|
129
137
|
});
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -183,7 +191,7 @@ export class NetworkManager {
|
|
|
183
191
|
const minted = await this.platform.portal.handleSignInCallback();
|
|
184
192
|
if (!minted) return null;
|
|
185
193
|
const route = this.routeFrom(minted, minted.appId);
|
|
186
|
-
writeScoped(
|
|
194
|
+
writeScoped(savedRouteBase(), JSON.stringify(route));
|
|
187
195
|
await this.hydrateUser();
|
|
188
196
|
this.log(`Hosted sign-in complete for app ${route.appId}`);
|
|
189
197
|
return route;
|
|
@@ -200,7 +208,7 @@ export class NetworkManager {
|
|
|
200
208
|
async restore(): Promise<AppRoute | null> {
|
|
201
209
|
await this.platform.session.restore();
|
|
202
210
|
if (!this.platform.session.getToken()) return null;
|
|
203
|
-
const raw = readScoped(
|
|
211
|
+
const raw = readScoped(savedRouteBase());
|
|
204
212
|
let route: AppRoute | null;
|
|
205
213
|
try {
|
|
206
214
|
route = raw ? (JSON.parse(raw) as AppRoute) : null;
|
|
@@ -231,7 +239,7 @@ export class NetworkManager {
|
|
|
231
239
|
|
|
232
240
|
private forgetCredentials(): void {
|
|
233
241
|
this.platform.session.setToken(null);
|
|
234
|
-
writeScoped(
|
|
242
|
+
writeScoped(savedRouteBase(), null);
|
|
235
243
|
}
|
|
236
244
|
|
|
237
245
|
private async hydrateUser(): Promise<SessionUser | null> {
|
|
@@ -346,7 +354,7 @@ export class NetworkManager {
|
|
|
346
354
|
}
|
|
347
355
|
this.gameClient.setToken(token);
|
|
348
356
|
this.route = route;
|
|
349
|
-
writeScoped(
|
|
357
|
+
writeScoped(savedRouteBase(), JSON.stringify(route));
|
|
350
358
|
this.refreshFailures = 0;
|
|
351
359
|
this.scheduleRefresh(APP_TOKEN_REFRESH_MS);
|
|
352
360
|
this.log(`Entered app ${route.appId} via ${httpUrl}`);
|
|
@@ -417,7 +425,7 @@ export class NetworkManager {
|
|
|
417
425
|
const refreshed = await this.gameClient.refreshGameplayToken();
|
|
418
426
|
this.platform.session.setToken(refreshed.token);
|
|
419
427
|
this.route = { ...this.route, expiresAt: refreshed.expiresAt };
|
|
420
|
-
writeScoped(
|
|
428
|
+
writeScoped(savedRouteBase(), JSON.stringify(this.route));
|
|
421
429
|
this.refreshFailures = 0;
|
|
422
430
|
this.log('App token rotated');
|
|
423
431
|
this.scheduleRefresh(APP_TOKEN_REFRESH_MS);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
DEFAULT_VOXEL_STATE,
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
bytesToBase64,
|
|
8
8
|
parseVoxelSetArgs,
|
|
9
9
|
routeClientHostCall,
|
|
10
|
+
routeWithFallback,
|
|
10
11
|
voxelStateToWire,
|
|
11
12
|
voxelsFromBase64,
|
|
12
13
|
voxelsListRows,
|
|
@@ -144,6 +145,23 @@ describe('routeClientHostCall', () => {
|
|
|
144
145
|
}
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
it("hands an SDK call to the host's server calls while no session is bound", async () => {
|
|
149
|
+
const { bindModSession } = await import('./modChunkRuntime');
|
|
150
|
+
bindModSession(null);
|
|
151
|
+
const r = reads();
|
|
152
|
+
for (const fn of ['emit_channel', 'channel_join', 'sessions_list']) {
|
|
153
|
+
const call = { fn, args: { channelId: '5' } } as never;
|
|
154
|
+
await expect(routeClientHostCall(call, r.reads, grid)).rejects.toBeInstanceOf(
|
|
155
|
+
HostCallRefusedError,
|
|
156
|
+
);
|
|
157
|
+
const serverCalls = vi.fn(async () => ({ answered: fn }));
|
|
158
|
+
await expect(
|
|
159
|
+
routeWithFallback(call, () => routeClientHostCall(call, r.reads, grid), serverCalls),
|
|
160
|
+
).resolves.toEqual({ answered: fn });
|
|
161
|
+
expect(serverCalls).toHaveBeenCalledWith(call);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
147
165
|
it('drains pointer_clicks when the game supplies an input sink', async () => {
|
|
148
166
|
const r = reads();
|
|
149
167
|
await expect(
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* the same route Paint uses. Never hand a mod the raw client.
|
|
14
14
|
*/
|
|
15
15
|
import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
|
|
16
|
-
import { routeModGameplay } from './modChunkRuntime';
|
|
16
|
+
import { NO_SESSION, routeModGameplay } from './modChunkRuntime';
|
|
17
17
|
import type { ModSceneStore } from './modScene';
|
|
18
18
|
|
|
19
19
|
export interface ClientVoxelState {
|
|
@@ -188,13 +188,14 @@ export async function routeClientHostCall(
|
|
|
188
188
|
return scene.store.setInstances(scene.source, args);
|
|
189
189
|
default: {
|
|
190
190
|
const answered = await routeModGameplay(call);
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
String((answered as { error: unknown }).error)
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
if (answered && typeof answered === 'object' && 'error' in answered) {
|
|
192
|
+
// A call the mod runtime does not route, or an SDK call in a game that never binds a
|
|
193
|
+
// session (one without StudioService), is the host's to answer: `routeWithFallback`
|
|
194
|
+
// hands a refusal to its server calls.
|
|
195
|
+
const error = String((answered as { error: unknown }).error);
|
|
196
|
+
if (error.startsWith('unrouted') || error === NO_SESSION) {
|
|
197
|
+
throw new HostCallRefusedError(fn);
|
|
198
|
+
}
|
|
198
199
|
}
|
|
199
200
|
return answered;
|
|
200
201
|
}
|
|
@@ -429,6 +429,9 @@ async function restoreAvatar(): Promise<void> {
|
|
|
429
429
|
.catch(() => undefined);
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
/** The answer to an SDK call while no session is bound; the host's router hands such a call on. */
|
|
433
|
+
export const NO_SESSION = 'no session';
|
|
434
|
+
|
|
432
435
|
const PASS_SDK = new Set([
|
|
433
436
|
'actors_create',
|
|
434
437
|
'actors_update',
|
|
@@ -494,7 +497,7 @@ const PASS_SDK = new Set([
|
|
|
494
497
|
|
|
495
498
|
async function passSdk(fn: string, args: Record<string, unknown>): Promise<unknown> {
|
|
496
499
|
if (!PASS_SDK.has(fn)) return { ok: false, error: `unrouted host call ${fn}` };
|
|
497
|
-
if (!port) return { ok: false, error:
|
|
500
|
+
if (!port) return { ok: false, error: NO_SESSION };
|
|
498
501
|
const client = port.client;
|
|
499
502
|
switch (fn) {
|
|
500
503
|
case 'actors_create':
|