@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 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 (the SERVER target as a ck-exec mod), grid claims and the world hub's claim registry (`GridService`), CLIENT mods for the player's grid and consented grid mods, the agent's player host |
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` 17.7+ (17.12
19
- for `ModelService`, `GridService` and the Studio's SERVER target on ck-exec)
20
- and, for the Studio agent pane, `@crowdedkingdoms/crowdy-dsh` 0.4+. On a
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`), a Game Model if you keep one, Studio
81
- Common Files, agent policy). Pass your own `exec` (manifest, crates, and a
82
- client holding your session on the app's datacenter), `blueprints` and
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.2.2-dev.1",
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": ">=17.7.0-dev.0 <18 || >=17.8.0-dev.0 <18 || >=17.9.0-dev.0 <18 || >=17.12.0-dev.0 <18 || >=17.13.0-dev.0 <18",
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 [channels, sessions] = await Promise.all([
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: sessions.map((s) => ({
71
- sessionId: s.sessionId,
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 app and grid mods, JS grid programs) and the engine
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 mine = await identity.appAccess.myAccess(appId).catch(() => null);
202
- if (mine && String(mine.tierId) === String(tier.tierId) && mine.status !== 'revoked') {
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 `blueprints` (a
520
- * Game Model) are each deployed when given. Returns a report the UI renders.
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: {