@crowdedkingdoms/construct 0.2.2-dev.1 → 0.3.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 +7 -8
- package/package.json +3 -3
- package/src/grid/GridProgramRunner.ts +3 -9
- package/src/index.ts +9 -3
- package/src/platform/onboarding/steps.d.mts +0 -7
- package/src/platform/onboarding/steps.mjs +25 -56
- package/src/platform/onboarding/steps.test.ts +41 -31
- package/src/platform/studio/StudioService.ts +162 -170
- package/src/platform/studio/clientHalves.test.ts +348 -0
- package/src/platform/studio/clientHalves.ts +312 -0
- package/src/platform/studio/clientModHost.test.ts +41 -4
- package/src/platform/studio/clientModHost.ts +27 -139
- package/src/platform/studio/ClientModLifecycle.test.ts +0 -56
- package/src/platform/studio/ClientModLifecycle.ts +0 -102
package/README.md
CHANGED
|
@@ -9,15 +9,15 @@ and the games built on it share.
|
|
|
9
9
|
| `engine/` | `GameLoop`, `SceneRouter`, `Input`, `Controls`, the `GameScene` contract |
|
|
10
10
|
| `platform/` | Hosted sign-in and app entry (`AuthService`, `NetworkManager`), `GameSession`, World Stores presence and the pose codec, chat, voice, webcam, envScope storage |
|
|
11
11
|
| `platform/exec/`, `platform/model/` | The game's world hub on ck-exec (`WorldHub`, one connection per page as `NetworkManager.worldHub`) and `ModelService`, which reads the pulse, programs and progression from it |
|
|
12
|
-
| `platform/studio/` | The Crowdy Studio dock (
|
|
12
|
+
| `platform/studio/` | The Crowdy Studio dock (a project runs as a ck-exec mod and its CLIENT half), grid claims and the world hub's claim registry (`GridService`), the CLIENT halves of the grid the player stands in (`GridClientHalves`, over CrowdyJS's `ExecClientHalves`) and the host calls they reach, the agent's player host |
|
|
13
13
|
| `grid/` | JS grid programs (`GridProgramRunner`): player JavaScript with the full CrowdyJS SDK, sandboxed, relayed with a grid-scoped token |
|
|
14
14
|
| `node/` | `security-headers` (site, `/dsh/`, `/grid-program.html`), `vite-plugins`, `construct-copy-dsh` |
|
|
15
15
|
| `sandbox/boot` | The grid program sandbox bootstrap |
|
|
16
16
|
|
|
17
17
|
It ships **TypeScript source** for Vite (it uses `?worker&url` and
|
|
18
|
-
`import.meta.env`). Peer dependencies: `@crowdedkingdoms/crowdyjs`
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
`import.meta.env`). Peer dependencies: `@crowdedkingdoms/crowdyjs` 18 (0.3.0
|
|
19
|
+
and later; the 0.2 line peers on 17) and, for the Studio agent pane,
|
|
20
|
+
`@crowdedkingdoms/crowdy-dsh` 0.4+. On a
|
|
21
21
|
prerelease tier the ranges name each `X.Y.Z` line they admit, because npm
|
|
22
22
|
matches a prerelease only against a comparator on the same `X.Y.Z`. A game can
|
|
23
23
|
take a new CrowdyJS line only once a construct release names it (until then
|
|
@@ -77,7 +77,6 @@ plugins: constructHeaderPlugins({
|
|
|
77
77
|
|
|
78
78
|
`@crowdedkingdoms/construct/platform/onboarding/steps` provisions an app
|
|
79
79
|
(org, app, access tier, redirect URIs, grid claim policy, your ck-exec code
|
|
80
|
-
(`deployExec`: `execBuild` + `execDeploy`),
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`commonFiles`.
|
|
80
|
+
(`deployExec`: `execBuild` + `execDeploy`), Studio Common Files, agent policy).
|
|
81
|
+
Pass your own `exec` (manifest, crates, and a client holding your session on
|
|
82
|
+
the app's datacenter) and `commonFiles`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-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",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"./grid/*": "./src/grid/*.ts"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@crowdedkingdoms/crowdyjs": ">=
|
|
49
|
-
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1"
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=18.0.0-dev.0 <19",
|
|
49
|
+
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@crowdedkingdoms/crowdy-dsh": {
|
|
@@ -56,10 +56,7 @@ export class GridProgramRunner implements CrowdyStudioDshGridHost {
|
|
|
56
56
|
const scope = this.options.client().grid(this.options.appId(), gridId);
|
|
57
57
|
const token = await scope.mintToken(60).catch(() => null);
|
|
58
58
|
if (!token) return null;
|
|
59
|
-
const
|
|
60
|
-
scope.channels.list().catch(() => []),
|
|
61
|
-
scope.sessions.list({ status: 'active' }).catch(() => []),
|
|
62
|
-
]);
|
|
59
|
+
const channels = await scope.channels.list().catch(() => []);
|
|
63
60
|
return {
|
|
64
61
|
appId: this.options.appId(),
|
|
65
62
|
gridId,
|
|
@@ -67,11 +64,8 @@ export class GridProgramRunner implements CrowdyStudioDshGridHost {
|
|
|
67
64
|
high: token.highChunk,
|
|
68
65
|
owned: this.options.owns?.(gridId) ?? false,
|
|
69
66
|
channels: channels.map((c) => ({ groupId: String(c.groupId), name: c.name })),
|
|
70
|
-
sessions
|
|
71
|
-
|
|
72
|
-
name: s.name ?? null,
|
|
73
|
-
status: s.status,
|
|
74
|
-
})),
|
|
67
|
+
// Grid sessions were the legacy game model's; the bridge's field stays until its protocol moves.
|
|
68
|
+
sessions: [],
|
|
75
69
|
};
|
|
76
70
|
}
|
|
77
71
|
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The platform layer every Crowded Kingdoms browser game needs (hosted
|
|
5
5
|
* sign-in, app entry, World Stores presence, voice / webcam / chat, the
|
|
6
|
-
* Crowdy Studio dock with
|
|
7
|
-
* loop around it. Deep imports (`@crowdedkingdoms/construct/platform/...`,
|
|
6
|
+
* Crowdy Studio dock with grid mods and their CLIENT halves, JS grid programs)
|
|
7
|
+
* and the engine loop around it. Deep imports (`@crowdedkingdoms/construct/platform/...`,
|
|
8
8
|
* `/engine/...`, `/grid/...`) reach every module; this barrel re-exports the
|
|
9
9
|
* pieces a game wires at boot.
|
|
10
10
|
*/
|
|
@@ -81,8 +81,14 @@ export {
|
|
|
81
81
|
export {
|
|
82
82
|
routeClientHostCall,
|
|
83
83
|
routeWithFallback,
|
|
84
|
-
runConsentedGridMod,
|
|
85
84
|
type ClientModHostReads,
|
|
86
85
|
type ClientModHostWrites,
|
|
87
86
|
} from './platform/studio/clientModHost';
|
|
87
|
+
export {
|
|
88
|
+
GridClientHalves,
|
|
89
|
+
describeClientHalfPrompt,
|
|
90
|
+
isOwnClientHalfPrompt,
|
|
91
|
+
type ClientHalvesGrid,
|
|
92
|
+
type GridClientHalvesOptions,
|
|
93
|
+
} from './platform/studio/clientHalves';
|
|
88
94
|
export { GridProgramRunner, type GridProgramRunnerOptions } from './grid/GridProgramRunner';
|
|
@@ -75,8 +75,6 @@ export interface OnboardingExec {
|
|
|
75
75
|
export interface RunOnboardingOptions {
|
|
76
76
|
/** Origins to register as the app's redirect URIs (hosted sign-in + CORS). */
|
|
77
77
|
redirectOrigins?: string[];
|
|
78
|
-
/** A Game Model's Game Kit blueprints, for a game that keeps one. */
|
|
79
|
-
blueprints?: unknown[];
|
|
80
78
|
/** The game's ck-exec code, built and deployed as a step (the starter's world hub). */
|
|
81
79
|
exec?: OnboardingExec;
|
|
82
80
|
/** Studio Common Files to publish (starter templates); none by default. */
|
|
@@ -131,11 +129,6 @@ export function deployExec(
|
|
|
131
129
|
input: DeployExecInput,
|
|
132
130
|
log?: Logger,
|
|
133
131
|
): 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
132
|
export function publishStarterFiles(
|
|
140
133
|
game: CrowdyClient,
|
|
141
134
|
input: { appId: string; commonFiles?: OnboardingCommonFile[] },
|
|
@@ -198,8 +198,8 @@ export async function ensureConstructorTier(identity, { appId, userId }, log = n
|
|
|
198
198
|
// 2026-09-07 the replay path of grantAppAccess answered a DateTime
|
|
199
199
|
// serialization error on dev, and "already on this tier" is the honest
|
|
200
200
|
// idempotent answer anyway.
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
201
|
+
const held = await accessOf(identity, appId, userId);
|
|
202
|
+
if (held && String(held.tierId) === String(tier.tierId) && held.status !== 'revoked') {
|
|
203
203
|
log(`User ${userId} already holds "${CONSTRUCTOR_TIER_NAME}"`);
|
|
204
204
|
return { tier, created, updated, granted: false };
|
|
205
205
|
}
|
|
@@ -208,6 +208,27 @@ export async function ensureConstructorTier(identity, { appId, userId }, log = n
|
|
|
208
208
|
return { tier, created, updated, granted: true };
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** `userId`'s access to the app: the caller's own grant, or theirs among the app's grants. */
|
|
212
|
+
async function accessOf(identity, appId, userId) {
|
|
213
|
+
let me = null;
|
|
214
|
+
try {
|
|
215
|
+
me = await identity.users.me();
|
|
216
|
+
} catch {
|
|
217
|
+
// Unknown caller: look the user up among the app's grants.
|
|
218
|
+
}
|
|
219
|
+
if (me && String(me.userId) === String(userId)) {
|
|
220
|
+
return identity.appAccess.myAccess(appId).catch(() => null);
|
|
221
|
+
}
|
|
222
|
+
const page = 100;
|
|
223
|
+
for (let offset = 0; ; offset += page) {
|
|
224
|
+
const rows = await identity.appAccess
|
|
225
|
+
.usersByApp(appId, { limit: page, offset })
|
|
226
|
+
.catch(() => []);
|
|
227
|
+
const row = rows.find((r) => String(r.userId) === String(userId));
|
|
228
|
+
if (row || rows.length < page) return row ?? null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
211
232
|
/** 4. Players may claim an unowned chunk for themselves (the Claim pad). */
|
|
212
233
|
export async function ensureSelfClaimPolicy(client, { appId }, log = noop) {
|
|
213
234
|
const current = await client.marketplace.gridClaimPolicy({ appId }).catch(() => null);
|
|
@@ -286,54 +307,6 @@ export async function deployExec(
|
|
|
286
307
|
return { buildId: build.buildId, version, restarted: restart };
|
|
287
308
|
}
|
|
288
309
|
|
|
289
|
-
/**
|
|
290
|
-
* Deploy a Game Model (kit blueprints), for a game that keeps one. The Construct
|
|
291
|
-
* starter keeps its state in its ck-exec world hub instead (`deployExec`).
|
|
292
|
-
*
|
|
293
|
-
* `gameModelSeed` upserts DEFINITIONS (types, properties, functions) by name,
|
|
294
|
-
* but seed CONTAINERS are instances and are created every time they are sent.
|
|
295
|
-
* Re-running a seed that lists containers therefore duplicates them — measured
|
|
296
|
-
* on 2026-09-07: two runs, two `WorldState`s. So before deploying, drop every
|
|
297
|
-
* seed container whose type already has an instance with the same display
|
|
298
|
-
* name. Definitions still refresh; existing data is left alone.
|
|
299
|
-
*/
|
|
300
|
-
export async function deployModel(game, { appId, blueprints }, log = noop) {
|
|
301
|
-
if (!Array.isArray(blueprints)) {
|
|
302
|
-
throw new Error("deployModel needs the game's blueprints (e.g. constructBlueprints())");
|
|
303
|
-
}
|
|
304
|
-
for (const blueprint of blueprints) {
|
|
305
|
-
if (!blueprint.containers?.length) continue;
|
|
306
|
-
const keep = [];
|
|
307
|
-
const seenTypes = new Map();
|
|
308
|
-
for (const container of blueprint.containers) {
|
|
309
|
-
let existing = seenTypes.get(container.typeName);
|
|
310
|
-
if (!existing) {
|
|
311
|
-
existing = await game.gameModel
|
|
312
|
-
.containers({ appId, typeName: container.typeName })
|
|
313
|
-
.catch(() => []);
|
|
314
|
-
seenTypes.set(container.typeName, existing);
|
|
315
|
-
}
|
|
316
|
-
if (existing.some((row) => row.displayName === container.displayName)) {
|
|
317
|
-
log(
|
|
318
|
-
`Container "${container.displayName}" (${container.typeName}) already exists; not re-seeding`,
|
|
319
|
-
);
|
|
320
|
-
} else {
|
|
321
|
-
keep.push(container);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
blueprint.containers = keep;
|
|
325
|
-
}
|
|
326
|
-
const result = await game.kit(appId).deploy(blueprints);
|
|
327
|
-
const seed = result.seed ?? {};
|
|
328
|
-
log(
|
|
329
|
-
`Model deployed: ${seed.containerTypesCreated ?? 0} new types, ` +
|
|
330
|
-
`${seed.functionsCreated ?? 0} new functions, ${seed.containersCreated ?? 0} new containers, ` +
|
|
331
|
-
`${result.automations?.length ?? 0} automations upserted`,
|
|
332
|
-
);
|
|
333
|
-
for (const warning of result.warnings ?? seed.warnings ?? []) log(` seed warning: ${warning}`);
|
|
334
|
-
return result;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
310
|
const PUBLISH_COMMON = `mutation PublishCommon($input: PublishCrowdyStudioCommonFileInput!) {
|
|
338
311
|
crowdyStudioCommonPublish(input: $input) { commonFileId slug versionId versionNo }
|
|
339
312
|
}`;
|
|
@@ -516,8 +489,8 @@ function messageOf(error) {
|
|
|
516
489
|
/**
|
|
517
490
|
* The whole sequence. `enterApp(appId)` must return a client holding an
|
|
518
491
|
* app-scoped token for `appId` (browser: NetworkManager.enterApp; Node: mint
|
|
519
|
-
* and build a client). `exec` (the game's ck-exec code) and
|
|
520
|
-
*
|
|
492
|
+
* and build a client). `exec` (the game's ck-exec code) is built and deployed
|
|
493
|
+
* when given. Returns a report the UI renders.
|
|
521
494
|
*/
|
|
522
495
|
export async function runOnboarding(options) {
|
|
523
496
|
const {
|
|
@@ -529,7 +502,6 @@ export async function runOnboarding(options) {
|
|
|
529
502
|
appSlug,
|
|
530
503
|
datacenter,
|
|
531
504
|
redirectOrigins = [],
|
|
532
|
-
blueprints,
|
|
533
505
|
exec,
|
|
534
506
|
commonFiles = [],
|
|
535
507
|
log = noop,
|
|
@@ -577,9 +549,6 @@ export async function runOnboarding(options) {
|
|
|
577
549
|
}
|
|
578
550
|
const game = await step('enter', 'App token', () => enterApp(appId));
|
|
579
551
|
await step('claims', 'Grid claim policy', () => ensureSelfClaimPolicy(identity, { appId }, log));
|
|
580
|
-
if (blueprints) {
|
|
581
|
-
await step('model', 'Game model', () => deployModel(game, { appId, blueprints }, log));
|
|
582
|
-
}
|
|
583
552
|
if (exec) {
|
|
584
553
|
await step('exec', 'ck-exec code', async () => {
|
|
585
554
|
const client = await exec.client(appId);
|
|
@@ -35,6 +35,9 @@ function identityStub(overrides: Any = {}): Any {
|
|
|
35
35
|
return { appId: '77', name: input.name, slug: input.slug };
|
|
36
36
|
}),
|
|
37
37
|
},
|
|
38
|
+
users: {
|
|
39
|
+
me: vi.fn(async () => ({ userId: overrides.callerId ?? '42' })),
|
|
40
|
+
},
|
|
38
41
|
appAccess: {
|
|
39
42
|
tiers: vi.fn(async () => overrides.tiers ?? []),
|
|
40
43
|
createTier: vi.fn(async (input: Any) => {
|
|
@@ -46,6 +49,7 @@ function identityStub(overrides: Any = {}): Any {
|
|
|
46
49
|
return { tierId: id, name: 'Constructor', permissionKeys: input.permissionKeys };
|
|
47
50
|
}),
|
|
48
51
|
myAccess: vi.fn(async () => overrides.myAccess ?? null),
|
|
52
|
+
usersByApp: vi.fn(async () => overrides.usersByApp ?? []),
|
|
49
53
|
grant: vi.fn(async (input: Any) => {
|
|
50
54
|
created.push(['grant', input]);
|
|
51
55
|
return { tierId: input.tierId };
|
|
@@ -160,6 +164,43 @@ describe('onboarding steps', () => {
|
|
|
160
164
|
expect(already.appAccess.updateTier).not.toHaveBeenCalled();
|
|
161
165
|
});
|
|
162
166
|
|
|
167
|
+
it("grants another user by that user's access, not the caller's", async () => {
|
|
168
|
+
const tiers = [
|
|
169
|
+
{
|
|
170
|
+
tierId: 'd',
|
|
171
|
+
name: 'Default',
|
|
172
|
+
isDefault: true,
|
|
173
|
+
status: 'active',
|
|
174
|
+
permissionKeys: ['access', ...steps.VISITOR_RUN_KEYS],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
tierId: 't1',
|
|
178
|
+
name: 'Constructor',
|
|
179
|
+
status: 'active',
|
|
180
|
+
permissionKeys: [...steps.CONSTRUCTOR_TIER_KEYS],
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
// The caller (an org admin) holds Constructor; user 42 holds the default tier.
|
|
184
|
+
const admin = identityStub({
|
|
185
|
+
callerId: '1',
|
|
186
|
+
tiers,
|
|
187
|
+
myAccess: { tierId: 't1', status: 'active' },
|
|
188
|
+
usersByApp: [{ userId: '42', tierId: 'd', status: 'active' }],
|
|
189
|
+
});
|
|
190
|
+
const a = await steps.ensureConstructorTier(admin as never, { appId: '77', userId: '42' });
|
|
191
|
+
expect(a.granted).toBe(true);
|
|
192
|
+
expect(admin.appAccess.grant).toHaveBeenCalledWith({ appId: '77', userId: '42', tierId: 't1' });
|
|
193
|
+
|
|
194
|
+
const holds = identityStub({
|
|
195
|
+
callerId: '1',
|
|
196
|
+
tiers,
|
|
197
|
+
usersByApp: [{ userId: '42', tierId: 't1', status: 'active' }],
|
|
198
|
+
});
|
|
199
|
+
const b = await steps.ensureConstructorTier(holds as never, { appId: '77', userId: '42' });
|
|
200
|
+
expect(b.granted).toBe(false);
|
|
201
|
+
expect(holds.appAccess.grant).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
163
204
|
it('only sets the claim policy when it differs', async () => {
|
|
164
205
|
const game = {
|
|
165
206
|
marketplace: {
|
|
@@ -179,37 +220,6 @@ describe('onboarding steps', () => {
|
|
|
179
220
|
});
|
|
180
221
|
});
|
|
181
222
|
|
|
182
|
-
it('does not re-seed containers that already exist', async () => {
|
|
183
|
-
const deploy = vi.fn(async () => ({
|
|
184
|
-
seed: { containersCreated: 0 },
|
|
185
|
-
automations: [],
|
|
186
|
-
warnings: [],
|
|
187
|
-
}));
|
|
188
|
-
const game = {
|
|
189
|
-
kit: () => ({ deploy }),
|
|
190
|
-
gameModel: {
|
|
191
|
-
containers: vi.fn(async ({ typeName }: Any) =>
|
|
192
|
-
typeName === 'WorldState' ? [{ displayName: 'The Construct' }] : [],
|
|
193
|
-
),
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
await steps.deployModel(game as never, {
|
|
197
|
-
appId: '77',
|
|
198
|
-
blueprints: [
|
|
199
|
-
{
|
|
200
|
-
name: 'construct-world',
|
|
201
|
-
containers: [
|
|
202
|
-
{ typeName: 'WorldState', displayName: 'The Construct' },
|
|
203
|
-
{ typeName: 'Program', displayName: 'Paint' },
|
|
204
|
-
],
|
|
205
|
-
},
|
|
206
|
-
],
|
|
207
|
-
});
|
|
208
|
-
const blueprints = (deploy.mock.calls as Any[][])[0]![0] as Any[];
|
|
209
|
-
const world = blueprints.find((b) => b.name === 'construct-world')!;
|
|
210
|
-
expect(world.containers.map((c: Any) => c.typeName)).toEqual(['Program']);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
223
|
const execManifest = {
|
|
214
224
|
root: 'construct',
|
|
215
225
|
types: {
|