@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.
@@ -1,30 +1,38 @@
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's SERVER target is a
4
- * ck-exec mod (`serverEngine: 'ck-exec'`): a `ckx-sdk` crate the platform
5
- * builds, deploys to the grid and switches on, which players in the grid call
6
- * as `mod:<name>`. The CLIENT target runs in the browser sandbox as before.
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
6
+ * the platform builds as that mod's CLIENT half, which the grid serves to the
7
+ * players who stand in it.
7
8
  *
8
9
  * The SDK's embed kit owns the chrome (dock, fullscreen fallback, Context
9
10
  * drawer, focus trap, HUD sink). This service supplies what only the game
10
11
  * knows:
11
12
  * - the grid the player is on and their effective permissions on it,
12
- * - the same-origin glue worker that runs CLIENT mods,
13
- * - the allowlisted host-call router (what a mod may read),
13
+ * - the same-origin glue worker that runs CLIENT halves,
14
+ * - the allowlisted host-call router (what a CLIENT half may read),
14
15
  * - input suppression and layout hooks,
15
- * - the visitor lifecycle: when you stand on someone else's grid, their
16
- * trusted CLIENT mods run for you after you approve them, and
16
+ * - the grid's CLIENT halves wherever you stand (`GridClientHalves`): the
17
+ * ones you consented to, or whose author you trust, run for you, you are
18
+ * asked about the rest, and they stop when you leave the grid, and
17
19
  * - the Agentic Crowdy Studio host (Ask / Build / Play) when the platform
18
20
  * policy and `use_studio_agent` are armed.
19
21
  *
20
- * CLIENT mods need `crossOriginIsolated` (COOP + COEP headers on the host). The
21
- * service checks that at construction and hides the CLIENT half when it is
22
- * missing, saying so in the HUD, rather than letting a worker fail silently.
22
+ * CLIENT halves need `crossOriginIsolated` (COOP + COEP headers on the host).
23
+ * The service checks that at construction and hides the CLIENT target when it
24
+ * is missing, saying so in the HUD, rather than letting a worker fail silently.
23
25
  */
24
- import { createGridHostCalls, type PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
26
+ import {
27
+ createGridHostCalls,
28
+ type ExecClientHalfPrompt,
29
+ type ExecGridClientMod,
30
+ type PlayerCodeHostCall,
31
+ } from '@crowdedkingdoms/crowdyjs';
25
32
  import {
26
33
  CrowdyStudioEmbed,
27
34
  CrowdyStudioTextHud,
35
+ type CrowdyStudioController,
28
36
  type CrowdyStudioEmbedContext,
29
37
  } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
30
38
  import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
@@ -36,7 +44,7 @@ import { messageOf } from '../network/NetworkManager';
36
44
  import { isTextEntry } from '../../engine/Input';
37
45
  import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
38
46
  import { AgentLocomotion } from './agentLocomotion';
39
- import { ClientModLifecycle, type ClientModScope } from './ClientModLifecycle';
47
+ import { describeClientHalfPrompt, GridClientHalves, studioPreviewModName } from './clientHalves';
40
48
  import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
41
49
  import { HumanInputMonitor } from './HumanInputMonitor';
42
50
  import {
@@ -44,14 +52,13 @@ import {
44
52
  DEFAULT_VOXEL_STATE,
45
53
  routeClientHostCall,
46
54
  routeWithFallback,
47
- runConsentedGridMod,
48
55
  type ClientModHostReads,
49
56
  type ClientModHostWrites,
50
57
  } from './clientModHost';
51
58
  import { PointerClickBuffer } from './pointerClicks';
52
59
  import { ModOverlayStore } from './modOverlay';
53
60
  import { GridService, type GridSnapshot } from './GridService';
54
- import { hasAnyStudioPermission, toBrokerBounds } from './permissions';
61
+ import { hasAnyStudioPermission, toBrokerBounds, type GridBounds } from './permissions';
55
62
  import { Emitter } from '../util/Emitter';
56
63
 
57
64
  export interface StudioState {
@@ -59,6 +66,7 @@ export interface StudioState {
59
66
  grid: GridSnapshot | null;
60
67
  /** COOP/COEP present and the build flag on. */
61
68
  clientModsAvailable: boolean;
69
+ /** The grid's CLIENT halves running for this player. */
62
70
  clientModsRunning: number;
63
71
  /** Why CLIENT mods are unavailable, when they are. */
64
72
  clientModsReason: string | null;
@@ -72,8 +80,17 @@ export interface StudioHooks {
72
80
  suppressGameplayInput(): () => void;
73
81
  onLayoutChange(rightInsetPx: number): void;
74
82
  notify(text: string, tone?: 'info' | 'warn' | 'error'): void;
75
- /** Ask the player whether to trust an author's mods; defaults to `confirm`. */
76
- confirmTrust?(summary: string): Promise<boolean>;
83
+ /**
84
+ * Ask the player whether to run a grid's CLIENT halves; `summary` is the
85
+ * question as plain text. `signal` aborts when they leave the grid. Defaults
86
+ * to `confirm`.
87
+ */
88
+ confirmTrust?(
89
+ summary: string,
90
+ context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
91
+ ): Promise<boolean>;
92
+ /** Ask once per author (the default) or once per CLIENT half. */
93
+ clientHalfConsent?: 'author' | 'mod';
77
94
  /** Screenshot hook for the agent pane. */
78
95
  captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
79
96
  describeView?(): string | undefined;
@@ -121,14 +138,12 @@ export class StudioService {
121
138
  /** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
122
139
  readonly overlay = new ModOverlayStore();
123
140
  private readonly hud = createConstructTextHud();
124
- private readonly lifecycle = new ClientModLifecycle();
125
- private readonly declinedAuthors = new Set<string>();
126
- private readonly approvedAuthors = new Set<string>();
127
141
  private readonly agentHost: ConstructPlayerHostAdapter;
128
142
  private readonly humanInput = new HumanInputMonitor();
129
143
  private readonly pointerClicks = new PointerClickBuffer();
130
144
  /** JS grid programs the agent (or the player) runs on the current grid. */
131
145
  readonly programs: GridProgramRunner;
146
+ private halves: GridClientHalves | null = null;
132
147
  private gridEnteredAt = 0;
133
148
  private embed: CrowdyStudioEmbed | null = null;
134
149
  private hooks: StudioHooks | null = null;
@@ -137,8 +152,11 @@ export class StudioService {
137
152
  private lastGridChunkKey: string | null = null;
138
153
  private lastGridReadAt = 0;
139
154
  private lastModsReadAt = 0;
140
- private modsInFlight = false;
141
155
  private timer: ReturnType<typeof setInterval> | null = null;
156
+ /** The grid the open Studio edits, and the mod its project runs as. */
157
+ private studioGridId: string | null = null;
158
+ private studioMod: string | null = null;
159
+ private unwatchStudio: (() => void) | null = null;
142
160
 
143
161
  constructor(private readonly session: GameSession) {
144
162
  this.grids = new GridService(session.network);
@@ -189,11 +207,9 @@ export class StudioService {
189
207
  get crowdyStudio() {
190
208
  return network().game.crowdyStudio;
191
209
  },
192
- // Still the CLIENT target's compile and artifact service.
193
- get playerCompute() {
194
- return network().game.playerCompute;
195
- },
196
- // The SERVER target's mod builds, deploys and switch (`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).
197
213
  get exec() {
198
214
  return network().game.exec;
199
215
  },
@@ -213,9 +229,6 @@ export class StudioService {
213
229
  },
214
230
  appId: () => this.session.appId,
215
231
  gameName: GAME_NAME,
216
- // A mod has no CLIENT pairing, so ck-api attaches no CLIENT half of such a project for
217
- // visitors (docs/MODDING.md); the author still runs it in their own tab.
218
- serverEngine: 'ck-exec',
219
232
  closeKeyCode: 'KeyM',
220
233
  dsh: {
221
234
  graphql: network().game.graphql,
@@ -230,7 +243,8 @@ export class StudioService {
230
243
  studioOrigin: STUDIO_ORIGIN ?? undefined,
231
244
  openOnMount: true,
232
245
  },
233
- onAgentMounted: (_handle) => {
246
+ onAgentMounted: (handle) => {
247
+ this.watchStudio(handle.controller);
234
248
  this.state = { ...this.state, agentReady: true, agentReason: null };
235
249
  this.emit();
236
250
  },
@@ -239,6 +253,7 @@ export class StudioService {
239
253
  this.emit();
240
254
  },
241
255
  onAgentUnmounted: () => {
256
+ this.watchStudio(null);
242
257
  this.state = { ...this.state, agentReady: false, agentReason: null };
243
258
  this.emit();
244
259
  },
@@ -250,7 +265,9 @@ export class StudioService {
250
265
  this.overlay.remove(source);
251
266
  this.hud.remove(source);
252
267
  }
268
+ this.studioGridId = null;
253
269
  this.setOpen(false);
270
+ void this.refreshClientHalves();
254
271
  },
255
272
  });
256
273
  if (!this.timer) this.timer = setInterval(() => void this.poll(), 2000);
@@ -321,7 +338,9 @@ export class StudioService {
321
338
  dispose(): void {
322
339
  if (this.timer) clearInterval(this.timer);
323
340
  this.timer = null;
324
- this.lifecycle.shutdown();
341
+ this.halves?.stop();
342
+ this.halves = null;
343
+ this.watchStudio(null);
325
344
  this.programs.stopAll();
326
345
  this.embed?.destroy();
327
346
  this.embed = null;
@@ -337,6 +356,7 @@ export class StudioService {
337
356
  private openOn(grid: GridSnapshot): void {
338
357
  if (!this.embed) throw new Error('Studio not attached');
339
358
  this.embed.toggle(this.contextFor(grid));
359
+ this.studioGridId = this.embed.open ? grid.gridId : null;
340
360
  this.setOpen(true);
341
361
  }
342
362
 
@@ -345,7 +365,7 @@ export class StudioService {
345
365
  const clientOk = this.state.clientModsAvailable;
346
366
  const reads = this.reads();
347
367
  const writes = this.writes();
348
- const serverCalls = this.serverCallsFor(grid);
368
+ const serverCalls = this.serverCallsFor(grid.gridId, grid.bounds);
349
369
  const overlaySource = `studio:${grid.gridId}`;
350
370
  return {
351
371
  gridId: grid.gridId,
@@ -394,14 +414,92 @@ export class StudioService {
394
414
  }
395
415
 
396
416
  /**
397
- * The rest of the CLIENT host catalog for a mod on `grid` (DN-10): channels,
398
- * the player-tier model, sessions and user state through CrowdyJS, confined
399
- * to the grid. The local router still answers world reads and voxel writes.
417
+ * The rest of the CLIENT host catalog for a mod on a grid (DN-10): channels,
418
+ * sessions and user state through CrowdyJS, confined to the grid. The local
419
+ * router still answers world reads and voxel writes.
400
420
  */
401
- private serverCallsFor(grid: GridSnapshot): (call: PlayerCodeHostCall) => Promise<unknown> {
421
+ private serverCallsFor(
422
+ gridId: string,
423
+ bounds: GridBounds,
424
+ ): (call: PlayerCodeHostCall) => Promise<unknown> {
402
425
  const game = this.session.network.game;
403
- const scope = game.grid(this.session.appId, grid.gridId, toBrokerBounds(grid.bounds));
404
- return createGridHostCalls({ scope, client: game });
426
+ const scope = game.grid(this.session.appId, gridId, toBrokerBounds(bounds));
427
+ return createGridHostCalls({
428
+ scope,
429
+ client: game,
430
+ local: {
431
+ gridPermissionKeys: () =>
432
+ this.currentGrid?.gridId === gridId ? this.currentGrid.permissions.effectiveKeys : [],
433
+ },
434
+ });
435
+ }
436
+
437
+ /**
438
+ * The grid's CLIENT halves, made on first use: the app is entered by then,
439
+ * and the whole service is rebuilt for another app.
440
+ */
441
+ private clientHalves(): GridClientHalves {
442
+ this.halves ??= new GridClientHalves({
443
+ exec: () => this.session.network.game.exec,
444
+ appId: this.session.appId,
445
+ workerUrl: glueWorkerAssetUrl,
446
+ selfUserId: () => this.session.network.user?.userId ?? null,
447
+ reads: this.reads(),
448
+ writes: this.writes(),
449
+ input: this.pointerClicks,
450
+ serverCalls: (grid) =>
451
+ this.serverCallsFor(grid.gridId, {
452
+ low: stringCorner(grid.bounds.low),
453
+ high: stringCorner(grid.bounds.high),
454
+ }),
455
+ hud: this.hud,
456
+ overlay: this.overlay,
457
+ ask: this.hooks?.clientHalfConsent ?? 'author',
458
+ confirm: (prompt, signal) =>
459
+ this.confirmTrust(describeClientHalfPrompt(prompt, this.authorName(prompt)), {
460
+ prompt,
461
+ signal,
462
+ }),
463
+ filter: (mod) => !this.previewedByStudio(mod),
464
+ onChange: (running) => {
465
+ this.state = { ...this.state, clientModsRunning: running.length };
466
+ this.emit();
467
+ },
468
+ log: (line) => this.session.network.log(line),
469
+ });
470
+ return this.halves;
471
+ }
472
+
473
+ /** The open Studio previews its project's CLIENT half itself; the grid's copy waits. */
474
+ private previewedByStudio(mod: ExecGridClientMod): boolean {
475
+ return (
476
+ this.isOpen &&
477
+ this.studioMod !== null &&
478
+ String(mod.gridId) === this.studioGridId &&
479
+ mod.name === this.studioMod
480
+ );
481
+ }
482
+
483
+ private watchStudio(controller: CrowdyStudioController | null): void {
484
+ this.unwatchStudio?.();
485
+ this.unwatchStudio = null;
486
+ const follow = (mod: string | null) => {
487
+ if (mod === this.studioMod) return;
488
+ this.studioMod = mod;
489
+ void this.refreshClientHalves();
490
+ };
491
+ if (!controller) {
492
+ follow(null);
493
+ return;
494
+ }
495
+ this.unwatchStudio = controller.subscribe((state) =>
496
+ follow(state.project ? studioPreviewModName(state.project) : null),
497
+ );
498
+ }
499
+
500
+ private authorName(prompt: ExecClientHalfPrompt): string | null {
501
+ const grid = this.currentGrid;
502
+ return grid && grid.gridId === String(prompt.gridId) ? (grid.ownerName ?? null) : null;
405
503
  }
406
504
 
407
505
  private observationFrame() {
@@ -495,15 +593,19 @@ export class StudioService {
495
593
  }
496
594
 
497
595
  private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
596
+ const entered = (grid?.gridId ?? null) !== (this.currentGrid?.gridId ?? null);
498
597
  this.currentGrid = grid;
499
598
  this.lastGridChunkKey = chunkKey(chunk);
500
599
  this.lastGridReadAt = Date.now();
501
- if (this.lifecycle.enterGrid(grid?.gridId ?? null)) {
502
- this.declinedAuthors.clear();
503
- this.approvedAuthors.clear();
600
+ if (entered) {
504
601
  this.lastModsReadAt = 0;
505
602
  this.gridEnteredAt = Date.now();
506
603
  this.overlay.clear();
604
+ if (this.state.clientModsAvailable) {
605
+ this.clientHalves().enterGrid(
606
+ grid ? { gridId: grid.gridId, bounds: toBrokerBounds(grid.bounds) } : null,
607
+ );
608
+ }
507
609
  }
508
610
  this.state = { ...this.state, grid };
509
611
  this.emit();
@@ -522,7 +624,7 @@ export class StudioService {
522
624
  return Number.isFinite(px) ? px : 0;
523
625
  }
524
626
 
525
- /** Periodic: which grid are we on, and are there mods to run for us? */
627
+ /** Periodic: which grid are we on, and are there CLIENT halves to run for us? */
526
628
  private async poll(): Promise<void> {
527
629
  if (!this.session.joined || !this.session.network.hasGame) return;
528
630
  const selfChunk = this.session.world.self.chunk;
@@ -538,118 +640,31 @@ export class StudioService {
538
640
  this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
539
641
  }
540
642
  }
541
- if (
542
- this.state.clientModsAvailable &&
543
- this.currentGrid &&
544
- !this.currentGrid.owned &&
545
- now - this.gridEnteredAt >= GRID_SETTLE_MS
546
- ) {
547
- await this.syncGridClientMods(this.currentGrid);
548
- }
643
+ if (now - this.lastModsReadAt >= MODS_REFRESH_MS) await this.refreshClientHalves();
549
644
  }
550
645
 
551
646
  /**
552
- * Visitor path: fetch the grid's attached CLIENT mods, ask once per author
553
- * whether to trust them, and run the trusted/consented ones. Re-run on a
554
- * cadence so a hash change stops the old worker.
647
+ * Reconcile the grid's CLIENT halves: start the consented ones, ask about
648
+ * the rest, stop what changed or went. Waits out the presence race after
649
+ * entering a grid.
555
650
  */
556
- private async syncGridClientMods(grid: GridSnapshot): Promise<void> {
557
- const now = Date.now();
558
- if (this.modsInFlight || now - this.lastModsReadAt < MODS_REFRESH_MS) return;
559
- const scope = this.lifecycle.scope(grid.gridId);
560
- if (!scope) return;
561
- this.lastModsReadAt = now;
562
- this.modsInFlight = true;
651
+ private async refreshClientHalves(): Promise<void> {
652
+ const halves = this.halves;
653
+ if (!halves || !this.currentGrid || !this.state.clientModsAvailable) return;
654
+ if (Date.now() - this.gridEnteredAt < GRID_SETTLE_MS) return;
655
+ this.lastModsReadAt = Date.now();
563
656
  try {
564
- const client = this.session.network.game;
565
- const appId = this.session.appId;
566
- let mods = await client.marketplace.gridClientMods({ appId, gridId: grid.gridId });
567
- if (!this.lifecycle.reconcile(scope, mods.map(descriptorOf))) return;
568
-
569
- let trustedAny = false;
570
- for (const mod of mods) {
571
- if (mod.callerTrustsAuthor) continue;
572
- const authorKey = `${mod.authorKind}:${mod.authorRef}:${mod.authorCapabilityHash}`;
573
- if (this.declinedAuthors.has(authorKey)) continue;
574
- if (!this.approvedAuthors.has(authorKey)) {
575
- const approved = await this.confirmTrust(
576
- `Trust ${String(mod.authorKind).toLowerCase()} ${mod.authorRef}'s mods on this grid?\n\n` +
577
- 'Their client code will run in a sandbox while you are here.\n\n' +
578
- prettyCapabilities(mod.authorCapabilitySummaryJson),
579
- );
580
- if (!approved) {
581
- this.declinedAuthors.add(authorKey);
582
- continue;
583
- }
584
- // Remembered so a transient refusal (presence not registered yet)
585
- // retries silently instead of asking the player again.
586
- this.approvedAuthors.add(authorKey);
587
- }
588
- await client.marketplace.trustGridAuthor({
589
- appId,
590
- gridId: grid.gridId,
591
- authorKind: mod.authorKind,
592
- authorRef: mod.authorRef,
593
- consentCapabilityHash: mod.authorCapabilityHash,
594
- });
595
- if (!this.lifecycle.isCurrent(scope)) return;
596
- trustedAny = true;
597
- }
598
- if (trustedAny) {
599
- mods = await client.marketplace.gridClientMods({ appId, gridId: grid.gridId });
600
- if (!this.lifecycle.isCurrent(scope)) return;
601
- }
602
- for (const mod of mods) {
603
- if (!this.lifecycle.isCurrent(scope)) return;
604
- if (
605
- (mod.callerTrustsAuthor || mod.callerConsented) &&
606
- !this.lifecycle.has(mod.attachmentId)
607
- ) {
608
- await this.runGridMod(mod, grid, scope);
609
- }
610
- }
657
+ await halves.refresh();
611
658
  } catch (error) {
612
- this.session.network.log(`grid client-mod refresh failed: ${messageOf(error)}`);
613
- } finally {
614
- this.modsInFlight = false;
615
- this.state = { ...this.state, clientModsRunning: this.lifecycle.runningCount };
616
- this.emit();
617
- }
618
- }
619
-
620
- private async runGridMod(
621
- mod: {
622
- attachmentId: string;
623
- clientArtifactHash: string;
624
- capabilityHash: string;
625
- listingName?: string | null;
626
- },
627
- grid: GridSnapshot,
628
- scope: ClientModScope,
629
- ): Promise<void> {
630
- const handle = await runConsentedGridMod({
631
- client: this.session.network.game,
632
- appId: this.session.appId,
633
- attachmentId: mod.attachmentId,
634
- artifactCacheKey: mod.clientArtifactHash,
635
- hudSource: `grid:${mod.attachmentId}`,
636
- hudLabel: mod.listingName || 'Grid mod',
637
- grid: toBrokerBounds(grid.bounds, grid.gridId),
638
- workerUrl: glueWorkerAssetUrl,
639
- serverCalls: this.serverCallsFor(grid),
640
- reads: this.reads(),
641
- writes: this.writes(),
642
- hud: this.hud,
643
- overlay: this.overlay,
644
- input: this.pointerClicks,
645
- });
646
- if (handle) {
647
- this.lifecycle.track(scope, descriptorOf(mod), handle);
659
+ this.session.network.log(`grid CLIENT halves unavailable: ${messageOf(error)}`);
648
660
  }
649
661
  }
650
662
 
651
- private async confirmTrust(summary: string): Promise<boolean> {
652
- if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary);
663
+ private async confirmTrust(
664
+ summary: string,
665
+ context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
666
+ ): Promise<boolean> {
667
+ if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary, context);
653
668
  return window.confirm(summary);
654
669
  }
655
670
 
@@ -658,29 +673,6 @@ export class StudioService {
658
673
  }
659
674
  }
660
675
 
661
- function descriptorOf(mod: {
662
- attachmentId: string;
663
- clientArtifactHash: string;
664
- capabilityHash: string;
665
- }) {
666
- return {
667
- attachmentId: mod.attachmentId,
668
- artifactHash: mod.clientArtifactHash,
669
- capabilityHash: mod.capabilityHash,
670
- };
671
- }
672
-
673
- function prettyCapabilities(json: string | null | undefined): string {
674
- if (!json) return '(no capability summary)';
675
- try {
676
- const parsed = JSON.parse(json) as unknown;
677
- if (parsed && typeof parsed === 'object') {
678
- return Object.entries(parsed as Record<string, unknown>)
679
- .map(([k, v]) => `• ${k}: ${Array.isArray(v) ? v.join(', ') : String(v)}`)
680
- .join('\n');
681
- }
682
- return String(parsed);
683
- } catch {
684
- return json;
685
- }
676
+ function stringCorner(corner: { x: bigint; y: bigint; z: bigint }) {
677
+ return { x: corner.x.toString(), y: corner.y.toString(), z: corner.z.toString() };
686
678
  }