@crowdedkingdoms/construct 0.2.3-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
@@ -15,10 +15,9 @@ and the games built on it share.
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
- 17.14 for its CLIENT target and `GridClientHalves`) and, for the Studio agent
21
- 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
22
21
  prerelease tier the ranges name each `X.Y.Z` line they admit, because npm
23
22
  matches a prerelease only against a comparator on the same `X.Y.Z`. A game can
24
23
  take a new CrowdyJS line only once a construct release names it (until then
@@ -78,7 +77,6 @@ plugins: constructHeaderPlugins({
78
77
 
79
78
  `@crowdedkingdoms/construct/platform/onboarding/steps` provisions an app
80
79
  (org, app, access tier, redirect URIs, grid claim policy, your ck-exec code
81
- (`deployExec`: `execBuild` + `execDeploy`), a Game Model if you keep one, Studio
82
- Common Files, agent policy). Pass your own `exec` (manifest, crates, and a
83
- client holding your session on the app's datacenter), `blueprints` and
84
- `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.3-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 || >=17.14.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
@@ -81,7 +81,6 @@ 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';
@@ -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: {
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Crowdy Studio, embedded — the in-game IDE where players write SERVER and
3
- * CLIENT Rust mods for the grid they stand on. A project runs as a ck-exec mod
4
- * (`serverEngine: 'ck-exec'`): its SERVER target is a `ckx-sdk` crate the
5
- * platform builds, deploys to the grid and switches on, which players in the
6
- * grid call as `mod:<name>`; its CLIENT target is a `crowdy-client-sdk` crate
3
+ * CLIENT Rust mods for the grid they stand on. A project runs as a ck-exec mod:
4
+ * its SERVER target is a `ckx-sdk` crate the platform builds, deploys to the
5
+ * grid and switches on, which players in the grid call as `mod:<name>`; its CLIENT target is a `crowdy-client-sdk` crate
7
6
  * the platform builds as that mod's CLIENT half, which the grid serves to the
8
7
  * players who stand in it.
9
8
  *
@@ -35,7 +34,6 @@ import {
35
34
  CrowdyStudioTextHud,
36
35
  type CrowdyStudioController,
37
36
  type CrowdyStudioEmbedContext,
38
- type CrowdyStudioPlayerCompute,
39
37
  } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
40
38
  import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
41
39
 
@@ -122,29 +120,6 @@ function createConstructTextHud(): CrowdyStudioTextHud {
122
120
  return hud;
123
121
  }
124
122
 
125
- /**
126
- * CrowdyJS 17's embed types a legacy player compute service, which it never
127
- * calls while both targets run on ck-exec; this one refuses, so nothing can
128
- * reach the switched-off engine.
129
- */
130
- const NO_PLAYER_COMPUTE: CrowdyStudioPlayerCompute = {
131
- deploy: refuseLegacyEngine,
132
- versions: refuseLegacyEngine,
133
- setEnabled: refuseLegacyEngine,
134
- setRequires: refuseLegacyEngine,
135
- artifactBytes: refuseLegacyEngine,
136
- usage: refuseLegacyEngine,
137
- runs: refuseLegacyEngine,
138
- logs: refuseLegacyEngine,
139
- invoke: refuseLegacyEngine,
140
- };
141
-
142
- function refuseLegacyEngine(): Promise<never> {
143
- return Promise.reject(
144
- new Error('Crowdy Studio runs mods on ck-exec here; legacy player compute is not used'),
145
- );
146
- }
147
-
148
123
  const GRID_REFRESH_MS = 8_000;
149
124
  const MODS_REFRESH_MS = 10_000;
150
125
  /**
@@ -232,8 +207,9 @@ export class StudioService {
232
207
  get crowdyStudio() {
233
208
  return network().game.crowdyStudio;
234
209
  },
235
- playerCompute: NO_PLAYER_COMPUTE,
236
- // Both targets' builds, deploys, switch and preview (`serverEngine` below).
210
+ // Both targets' builds, deploys, switch and preview: the SERVER target runs as the
211
+ // grid's mod and the CLIENT target as that mod's CLIENT half, which the grid serves to
212
+ // the players standing in it (GridClientHalves).
237
213
  get exec() {
238
214
  return network().game.exec;
239
215
  },
@@ -253,9 +229,6 @@ export class StudioService {
253
229
  },
254
230
  appId: () => this.session.appId,
255
231
  gameName: GAME_NAME,
256
- // The SERVER target runs as the grid's mod and the CLIENT target as that mod's CLIENT
257
- // half, which the grid serves to the players standing in it (GridClientHalves).
258
- serverEngine: 'ck-exec',
259
232
  closeKeyCode: 'KeyM',
260
233
  dsh: {
261
234
  graphql: network().game.graphql,
@@ -451,7 +424,14 @@ export class StudioService {
451
424
  ): (call: PlayerCodeHostCall) => Promise<unknown> {
452
425
  const game = this.session.network.game;
453
426
  const scope = game.grid(this.session.appId, gridId, toBrokerBounds(bounds));
454
- return createGridHostCalls({ scope, client: game });
427
+ return createGridHostCalls({
428
+ scope,
429
+ client: game,
430
+ local: {
431
+ gridPermissionKeys: () =>
432
+ this.currentGrid?.gridId === gridId ? this.currentGrid.permissions.effectiveKeys : [],
433
+ },
434
+ });
455
435
  }
456
436
 
457
437
  /**
@@ -64,6 +64,7 @@ function harness(
64
64
  self?: string;
65
65
  confirm?: (prompt: unknown, signal: AbortSignal) => Promise<boolean>;
66
66
  filter?: (mod: { name: string }) => boolean;
67
+ log?: (line: string) => void;
67
68
  } = {},
68
69
  ) {
69
70
  const listings = options.listings ?? [[half({ callerConsented: true })]];
@@ -95,6 +96,7 @@ function harness(
95
96
  options: {
96
97
  onHostCall(call: unknown): Promise<unknown>;
97
98
  onPresentation?(presentation: unknown): void;
99
+ onLog?(line: unknown): void;
98
100
  consentedHostCalls?: readonly string[];
99
101
  engine?: string;
100
102
  };
@@ -117,11 +119,16 @@ function harness(
117
119
  overlay: overlay as never,
118
120
  ...(options.confirm ? { confirm: options.confirm as never } : {}),
119
121
  ...(options.filter ? { filter: options.filter as never } : {}),
122
+ ...(options.log ? { log: options.log } : {}),
120
123
  onChange: (running) => changes.push(running.length),
121
124
  brokerFactory: (brokerOptions) => {
122
125
  const broker = { options: brokerOptions as never, stop: vi.fn() };
123
126
  brokers.push(broker);
124
- return { start: async () => {}, stop: broker.stop };
127
+ return {
128
+ start: async () => {},
129
+ stop: broker.stop,
130
+ invoke: async (payload: Uint8Array) => new Uint8Array([...payload].reverse()),
131
+ };
125
132
  },
126
133
  });
127
134
  return { halves, exec, brokers, hud, overlay, actorsInChunk, serverCall, serverCalls, changes };
@@ -159,6 +166,19 @@ describe('GridClientHalves', () => {
159
166
  expect(h.serverCalls).toHaveBeenCalledWith(GRID);
160
167
  });
161
168
 
169
+ it("forwards a CLIENT half's log lines and invokes its handle_invoke", async () => {
170
+ const lines: string[] = [];
171
+ const h = harness({ log: (line) => lines.push(line) });
172
+ h.halves.enterGrid(GRID);
173
+ await h.halves.refresh();
174
+ h.brokers[0]!.options.onLog?.({ level: 'info', message: 'ready', moduleName: 'greeter' });
175
+ expect(lines).toContain('CLIENT half of mod greeter info: ready');
176
+ await expect(h.halves.invoke('m1', new Uint8Array([1, 2, 3]))).resolves.toEqual(
177
+ new Uint8Array([3, 2, 1]),
178
+ );
179
+ await expect(h.halves.invoke('nope', new Uint8Array())).rejects.toThrow();
180
+ });
181
+
162
182
  it('asks about another author, and a yes trusts them at the hash the grid listed', async () => {
163
183
  const asked: unknown[] = [];
164
184
  const h = harness({
@@ -4,8 +4,7 @@
4
4
  * into the text HUD and the holodeck overlay. CrowdyJS's `ExecClientHalves`
5
5
  * lists what the grid serves, asks, fetches by digest and sandboxes each half
6
6
  * in a `PlayerCodeBroker` (`engine: 'ck-exec'`); this adds what only the game
7
- * knows. It replaces `runConsentedGridMod` + `ClientModLifecycle`, which run
8
- * the legacy grid-attached client mods.
7
+ * knows.
9
8
  *
10
9
  * The player's own CLIENT halves run without a question (on your own grid
11
10
  * every half is yours), unless one was installed from a marketplace listing:
@@ -22,6 +21,7 @@ import {
22
21
  type ExecGridClientMod,
23
22
  type PlayerCodeGridBounds,
24
23
  type PlayerCodeHostCall,
24
+ type PlayerCodeLogLine,
25
25
  type PlayerCodePresentation,
26
26
  } from '@crowdedkingdoms/crowdyjs';
27
27
  import {
@@ -80,6 +80,11 @@ export interface GridClientHalvesOptions {
80
80
  filter?: (mod: ExecGridClientMod) => boolean;
81
81
  /** After a CLIENT half starts or stops. */
82
82
  onChange?: (running: readonly ExecGridClientMod[]) => void;
83
+ /**
84
+ * A CLIENT half's `crowdy::log` lines (rate- and length-capped by the broker). The text is
85
+ * the mod author's: render it as text. Without it they go to `log`.
86
+ */
87
+ onLog?: (line: PlayerCodeLogLine, mod: ExecGridClientMod) => void;
83
88
  log?: (line: string) => void;
84
89
  brokerFactory?: ExecClientHalvesOptions['brokerFactory'];
85
90
  now?: () => number;
@@ -114,6 +119,10 @@ export class GridClientHalves {
114
119
  },
115
120
  onStopped: (mod, reason) => this.stopped(mod, reason),
116
121
  onError: (error) => this.options.log?.(this.describeError(error)),
122
+ onLog: (line, mod) => {
123
+ if (this.options.onLog) this.options.onLog(line, mod);
124
+ else this.options.log?.(`CLIENT half of mod ${mod.name} ${line.level}: ${line.message}`);
125
+ },
117
126
  ...(options.brokerFactory ? { brokerFactory: options.brokerFactory } : {}),
118
127
  ...(options.now ? { now: options.now } : {}),
119
128
  });
@@ -141,6 +150,18 @@ export class GridClientHalves {
141
150
  return this.runner.refresh();
142
151
  }
143
152
 
153
+ /**
154
+ * Calls the `handle_invoke` export of the running CLIENT half of mod `modId`. The reply is
155
+ * the mod author's bytes: treat them as untrusted input.
156
+ */
157
+ invoke(
158
+ modId: string,
159
+ payload: Uint8Array,
160
+ options?: { timeoutMs?: number },
161
+ ): Promise<Uint8Array> {
162
+ return this.runner.invoke(modId, payload, options);
163
+ }
164
+
144
165
  stop(): void {
145
166
  this.asking?.abort();
146
167
  this.asking = null;
@@ -1,6 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
-
3
- vi.mock('@crowdedkingdoms/crowdyjs', () => ({ PlayerCodeBroker: class {} }));
1
+ import { describe, expect, it } from 'vitest';
4
2
 
5
3
  const {
6
4
  DEFAULT_VOXEL_STATE,
@@ -10,19 +10,8 @@
10
10
  * return only what the running player can already lawfully see. Writes go
11
11
  * through ChunkStore.setVoxel (optimistic + UDP) and markDirty (durable
12
12
  * write-back), the same route Paint uses. Never hand a mod the raw client.
13
- *
14
- * `runConsentedGridMod` starts a legacy grid-attached client mod, until
15
- * CrowdyJS 18 removes them.
16
13
  */
17
- import {
18
- PlayerCodeBroker,
19
- type CrowdyClient,
20
- type PlayerCodeGridBounds,
21
- type PlayerCodeHostCall,
22
- type PlayerCodePresentation,
23
- } from '@crowdedkingdoms/crowdyjs';
24
- import type { CrowdyStudioTextHud } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
25
- import type { ModOverlayStore } from './modOverlay';
14
+ import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
26
15
 
27
16
  export interface ClientModHostReads {
28
17
  actorsInChunk(x: bigint, y: bigint, z: bigint): Array<Record<string, unknown>>;
@@ -242,119 +231,6 @@ export function bytesToBase64(bytes: Uint8Array): string {
242
231
  return btoa(binary);
243
232
  }
244
233
 
245
- interface CachedArtifact {
246
- bytes: Uint8Array;
247
- artifactHash?: string;
248
- fuelPerDispatch?: string | bigint;
249
- }
250
-
251
- const artifactCache = new Map<string, CachedArtifact>();
252
-
253
- /**
254
- * Fetch a consented grid-attached client mod's artifact and run it in a
255
- * hash-verified broker with the same allowlist, grid clamp, rate caps and
256
- * circuit breaker as the Studio's own Test/Run path. Its HUD output lands in
257
- * the persistent text HUD. Returns null (fails closed) when the artifact
258
- * cannot be fetched.
259
- *
260
- * @deprecated A legacy grid-attached client mod (`marketplace.gridClientMods`),
261
- * which CrowdyJS 18 removes. A ck-exec mod's CLIENT half runs through
262
- * `GridClientHalves` (`clientHalves.ts`).
263
- */
264
- export async function runConsentedGridMod(options: {
265
- client: CrowdyClient;
266
- appId: string;
267
- attachmentId: string;
268
- artifactCacheKey: string;
269
- hudSource: string;
270
- hudLabel: string;
271
- /** The module's name, which addressed grid events target (default `hudLabel`). */
272
- moduleName?: string;
273
- grid: PlayerCodeGridBounds;
274
- workerUrl: string;
275
- reads: ClientModHostReads;
276
- writes?: ClientModHostWrites;
277
- hud: CrowdyStudioTextHud;
278
- overlay?: ModOverlayStore;
279
- input?: ClientModHostInput;
280
- /**
281
- * Answers the host calls this game does not route itself (DN-10: the rest
282
- * of the client catalog, through CrowdyJS `createGridHostCalls`).
283
- */
284
- serverCalls?: (call: PlayerCodeHostCall) => Promise<unknown>;
285
- /**
286
- * How often the worker self-drives `on_tick`. Omitted/0 means invoke-only
287
- * and a HUD mod then never runs (measured 2026-09-07: broker started, HUD
288
- * stayed empty). Studio Test/Deploy reads `[package.metadata.crowdy]
289
- * tick_interval_ms` from the CLIENT Cargo.toml (default 1000, clamped
290
- * 16–1000). Pass this only for grid-attached visitors until that field is
291
- * stored on the compiled version.
292
- */
293
- tickIntervalMs?: number;
294
- }): Promise<{ stop: () => void } | null> {
295
- let fetched: { bytes: ArrayBuffer; artifactHash?: string; fuelPerDispatch?: string | bigint };
296
- const cached = artifactCache.get(options.artifactCacheKey);
297
- if (cached) {
298
- fetched = {
299
- bytes: cached.bytes.slice().buffer,
300
- artifactHash: cached.artifactHash,
301
- fuelPerDispatch: cached.fuelPerDispatch,
302
- };
303
- } else {
304
- try {
305
- fetched = await options.client.marketplace.clientArtifactBytes({
306
- appId: options.appId,
307
- attachmentId: options.attachmentId,
308
- });
309
- } catch {
310
- return null;
311
- }
312
- artifactCache.set(options.artifactCacheKey, {
313
- bytes: new Uint8Array(fetched.bytes).slice(),
314
- artifactHash: fetched.artifactHash,
315
- fuelPerDispatch: fetched.fuelPerDispatch,
316
- });
317
- }
318
- const broker = new PlayerCodeBroker({
319
- workerUrl: options.workerUrl,
320
- grid: options.grid,
321
- artifactHash: fetched.artifactHash,
322
- fuelPerDispatch: fetched.fuelPerDispatch != null ? BigInt(fetched.fuelPerDispatch) : undefined,
323
- tickIntervalMs: options.tickIntervalMs ?? 1000,
324
- moduleName: options.moduleName ?? options.hudLabel,
325
- onHostCall: (call) =>
326
- routeWithFallback(
327
- call,
328
- () => routeClientHostCall(call, options.reads, options.grid, options.writes, options.input),
329
- options.serverCalls,
330
- ),
331
- onPresentation: (presentation: PlayerCodePresentation) => {
332
- if (presentation.channel === 'hud') {
333
- options.hud.set({
334
- source: options.hudSource,
335
- label: options.hudLabel,
336
- payload: presentation.payload,
337
- });
338
- }
339
- if (presentation.channel === 'overlay') {
340
- options.overlay?.apply(options.hudSource, presentation.payload, options.grid);
341
- }
342
- },
343
- onCircuitOpen: () => {
344
- options.hud.remove(options.hudSource);
345
- options.overlay?.remove(options.hudSource);
346
- },
347
- });
348
- await broker.start(fetched.bytes);
349
- return {
350
- stop: () => {
351
- broker.stop();
352
- options.hud.remove(options.hudSource);
353
- options.overlay?.remove(options.hudSource);
354
- },
355
- };
356
- }
357
-
358
234
  /**
359
235
  * The game's own router first (local reads, optimistic writes); anything it
360
236
  * does not offer goes to `serverCalls` when the game supplied it.
@@ -1,56 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
-
3
- import { ClientModLifecycle } from './ClientModLifecycle';
4
-
5
- const d = (id: string, artifactHash = 'a', capabilityHash = 'c') => ({
6
- attachmentId: id,
7
- artifactHash,
8
- capabilityHash,
9
- });
10
-
11
- describe('ClientModLifecycle', () => {
12
- it('stops every worker when the grid changes', () => {
13
- const life = new ClientModLifecycle();
14
- expect(life.enterGrid('g1')).toBe(true);
15
- const scope = life.scope('g1')!;
16
- const stop = vi.fn();
17
- expect(life.track(scope, d('m1'), { stop })).toBe(true);
18
- expect(life.enterGrid('g1')).toBe(false);
19
- expect(life.enterGrid('g2')).toBe(true);
20
- expect(stop).toHaveBeenCalledTimes(1);
21
- expect(life.runningCount).toBe(0);
22
- });
23
-
24
- it('rejects a worker that finished starting after the grid changed', () => {
25
- const life = new ClientModLifecycle();
26
- life.enterGrid('g1');
27
- const stale = life.scope('g1')!;
28
- life.enterGrid('g2');
29
- const stop = vi.fn();
30
- expect(life.track(stale, d('m1'), { stop })).toBe(false);
31
- expect(stop).toHaveBeenCalled();
32
- });
33
-
34
- it('reconciles by immutable hashes', () => {
35
- const life = new ClientModLifecycle();
36
- life.enterGrid('g1');
37
- const scope = life.scope('g1')!;
38
- const stopA = vi.fn();
39
- const stopB = vi.fn();
40
- life.track(scope, d('a'), { stop: stopA });
41
- life.track(scope, d('b'), { stop: stopB });
42
- expect(life.reconcile(scope, [d('a'), d('b', 'a2')])).toBe(true);
43
- expect(stopA).not.toHaveBeenCalled();
44
- expect(stopB).toHaveBeenCalled();
45
- expect(life.has('a')).toBe(true);
46
- expect(life.has('b')).toBe(false);
47
- });
48
-
49
- it('is inert after shutdown', () => {
50
- const life = new ClientModLifecycle();
51
- life.enterGrid('g1');
52
- life.shutdown();
53
- expect(life.enterGrid('g2')).toBe(false);
54
- expect(life.scope('g2')).toBeNull();
55
- });
56
- });
@@ -1,107 +0,0 @@
1
- /**
2
- * Owns the worker handles of grid-attached CLIENT mods independently of any
3
- * scene. Every metadata refresh reconciles immutable hashes; changing grids
4
- * synchronously tears everything down; a worker that finishes starting after
5
- * a grid change (or shutdown) is stopped instead of attached stale.
6
- */
7
- export interface ClientModDescriptor {
8
- attachmentId: string;
9
- artifactHash: string;
10
- capabilityHash: string;
11
- }
12
-
13
- export interface ClientModHandle {
14
- stop: () => void;
15
- }
16
-
17
- export interface ClientModScope {
18
- gridId: string;
19
- generation: number;
20
- }
21
-
22
- interface RunningClientMod extends ClientModDescriptor, ClientModHandle {}
23
-
24
- /**
25
- * @deprecated For the legacy grid-attached client mods (`runConsentedGridMod`),
26
- * which CrowdyJS 18 removes; `GridClientHalves` keeps a ck-exec grid's CLIENT
27
- * halves.
28
- */
29
- export class ClientModLifecycle {
30
- private readonly running = new Map<string, RunningClientMod>();
31
- private activeGridId: string | null = null;
32
- private generation = 0;
33
- private stopped = false;
34
-
35
- /** Returns true when the grid actually changed (callers reset their caches). */
36
- enterGrid(gridId: string | null): boolean {
37
- if (this.stopped) return false;
38
- if (gridId === this.activeGridId) return false;
39
- this.stopAll();
40
- this.activeGridId = gridId;
41
- this.generation++;
42
- return true;
43
- }
44
-
45
- get currentGridId(): string | null {
46
- return this.activeGridId;
47
- }
48
-
49
- has(attachmentId: string): boolean {
50
- return this.running.has(attachmentId);
51
- }
52
-
53
- get runningCount(): number {
54
- return this.running.size;
55
- }
56
-
57
- scope(gridId: string): ClientModScope | null {
58
- if (this.stopped || gridId !== this.activeGridId) return null;
59
- return { gridId, generation: this.generation };
60
- }
61
-
62
- isCurrent(scope: ClientModScope): boolean {
63
- return (
64
- !this.stopped && scope.gridId === this.activeGridId && scope.generation === this.generation
65
- );
66
- }
67
-
68
- track(scope: ClientModScope, descriptor: ClientModDescriptor, handle: ClientModHandle): boolean {
69
- if (!this.isCurrent(scope) || this.running.has(descriptor.attachmentId)) {
70
- handle.stop();
71
- return false;
72
- }
73
- this.running.set(descriptor.attachmentId, { ...descriptor, ...handle });
74
- return true;
75
- }
76
-
77
- /** Stop mods that disappeared or changed hash; returns false if scope is stale. */
78
- reconcile(scope: ClientModScope, descriptors: readonly ClientModDescriptor[]): boolean {
79
- if (!this.isCurrent(scope)) return false;
80
- const current = new Map(descriptors.map((d) => [d.attachmentId, d]));
81
- for (const [attachmentId, running] of this.running) {
82
- const next = current.get(attachmentId);
83
- if (
84
- !next ||
85
- next.artifactHash !== running.artifactHash ||
86
- next.capabilityHash !== running.capabilityHash
87
- ) {
88
- running.stop();
89
- this.running.delete(attachmentId);
90
- }
91
- }
92
- return true;
93
- }
94
-
95
- private stopAll(): void {
96
- for (const running of this.running.values()) running.stop();
97
- this.running.clear();
98
- }
99
-
100
- shutdown(): void {
101
- if (this.stopped) return;
102
- this.stopped = true;
103
- this.activeGridId = null;
104
- this.generation++;
105
- this.stopAll();
106
- }
107
- }