@crowdedkingdoms/construct 0.2.2-dev.1 → 0.2.3-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,16 @@ 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
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
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
21
22
  prerelease tier the ranges name each `X.Y.Z` line they admit, because npm
22
23
  matches a prerelease only against a comparator on the same `X.Y.Z`. A game can
23
24
  take a new CrowdyJS line only once a construct release names it (until then
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.2.3-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,7 +45,7 @@
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",
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
49
  "@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1"
50
50
  },
51
51
  "peerDependenciesMeta": {
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
  */
@@ -85,4 +85,11 @@ export {
85
85
  type ClientModHostReads,
86
86
  type ClientModHostWrites,
87
87
  } from './platform/studio/clientModHost';
88
+ export {
89
+ GridClientHalves,
90
+ describeClientHalfPrompt,
91
+ isOwnClientHalfPrompt,
92
+ type ClientHalvesGrid,
93
+ type GridClientHalvesOptions,
94
+ } from './platform/studio/clientHalves';
88
95
  export { GridProgramRunner, type GridProgramRunnerOptions } from './grid/GridProgramRunner';
@@ -21,6 +21,11 @@ export interface ClientModScope {
21
21
 
22
22
  interface RunningClientMod extends ClientModDescriptor, ClientModHandle {}
23
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
+ */
24
29
  export class ClientModLifecycle {
25
30
  private readonly running = new Map<string, RunningClientMod>();
26
31
  private activeGridId: string | null = null;
@@ -1,31 +1,41 @@
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
+ * (`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
7
+ * the platform builds as that mod's CLIENT half, which the grid serves to the
8
+ * players who stand in it.
7
9
  *
8
10
  * The SDK's embed kit owns the chrome (dock, fullscreen fallback, Context
9
11
  * drawer, focus trap, HUD sink). This service supplies what only the game
10
12
  * knows:
11
13
  * - 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),
14
+ * - the same-origin glue worker that runs CLIENT halves,
15
+ * - the allowlisted host-call router (what a CLIENT half may read),
14
16
  * - 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
17
+ * - the grid's CLIENT halves wherever you stand (`GridClientHalves`): the
18
+ * ones you consented to, or whose author you trust, run for you, you are
19
+ * asked about the rest, and they stop when you leave the grid, and
17
20
  * - the Agentic Crowdy Studio host (Ask / Build / Play) when the platform
18
21
  * policy and `use_studio_agent` are armed.
19
22
  *
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.
23
+ * CLIENT halves need `crossOriginIsolated` (COOP + COEP headers on the host).
24
+ * The service checks that at construction and hides the CLIENT target when it
25
+ * is missing, saying so in the HUD, rather than letting a worker fail silently.
23
26
  */
24
- import { createGridHostCalls, type PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
27
+ import {
28
+ createGridHostCalls,
29
+ type ExecClientHalfPrompt,
30
+ type ExecGridClientMod,
31
+ type PlayerCodeHostCall,
32
+ } from '@crowdedkingdoms/crowdyjs';
25
33
  import {
26
34
  CrowdyStudioEmbed,
27
35
  CrowdyStudioTextHud,
36
+ type CrowdyStudioController,
28
37
  type CrowdyStudioEmbedContext,
38
+ type CrowdyStudioPlayerCompute,
29
39
  } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
30
40
  import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
31
41
 
@@ -36,7 +46,7 @@ import { messageOf } from '../network/NetworkManager';
36
46
  import { isTextEntry } from '../../engine/Input';
37
47
  import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
38
48
  import { AgentLocomotion } from './agentLocomotion';
39
- import { ClientModLifecycle, type ClientModScope } from './ClientModLifecycle';
49
+ import { describeClientHalfPrompt, GridClientHalves, studioPreviewModName } from './clientHalves';
40
50
  import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
41
51
  import { HumanInputMonitor } from './HumanInputMonitor';
42
52
  import {
@@ -44,14 +54,13 @@ import {
44
54
  DEFAULT_VOXEL_STATE,
45
55
  routeClientHostCall,
46
56
  routeWithFallback,
47
- runConsentedGridMod,
48
57
  type ClientModHostReads,
49
58
  type ClientModHostWrites,
50
59
  } from './clientModHost';
51
60
  import { PointerClickBuffer } from './pointerClicks';
52
61
  import { ModOverlayStore } from './modOverlay';
53
62
  import { GridService, type GridSnapshot } from './GridService';
54
- import { hasAnyStudioPermission, toBrokerBounds } from './permissions';
63
+ import { hasAnyStudioPermission, toBrokerBounds, type GridBounds } from './permissions';
55
64
  import { Emitter } from '../util/Emitter';
56
65
 
57
66
  export interface StudioState {
@@ -59,6 +68,7 @@ export interface StudioState {
59
68
  grid: GridSnapshot | null;
60
69
  /** COOP/COEP present and the build flag on. */
61
70
  clientModsAvailable: boolean;
71
+ /** The grid's CLIENT halves running for this player. */
62
72
  clientModsRunning: number;
63
73
  /** Why CLIENT mods are unavailable, when they are. */
64
74
  clientModsReason: string | null;
@@ -72,8 +82,17 @@ export interface StudioHooks {
72
82
  suppressGameplayInput(): () => void;
73
83
  onLayoutChange(rightInsetPx: number): void;
74
84
  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>;
85
+ /**
86
+ * Ask the player whether to run a grid's CLIENT halves; `summary` is the
87
+ * question as plain text. `signal` aborts when they leave the grid. Defaults
88
+ * to `confirm`.
89
+ */
90
+ confirmTrust?(
91
+ summary: string,
92
+ context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
93
+ ): Promise<boolean>;
94
+ /** Ask once per author (the default) or once per CLIENT half. */
95
+ clientHalfConsent?: 'author' | 'mod';
77
96
  /** Screenshot hook for the agent pane. */
78
97
  captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
79
98
  describeView?(): string | undefined;
@@ -103,6 +122,29 @@ function createConstructTextHud(): CrowdyStudioTextHud {
103
122
  return hud;
104
123
  }
105
124
 
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
+
106
148
  const GRID_REFRESH_MS = 8_000;
107
149
  const MODS_REFRESH_MS = 10_000;
108
150
  /**
@@ -121,14 +163,12 @@ export class StudioService {
121
163
  /** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
122
164
  readonly overlay = new ModOverlayStore();
123
165
  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
166
  private readonly agentHost: ConstructPlayerHostAdapter;
128
167
  private readonly humanInput = new HumanInputMonitor();
129
168
  private readonly pointerClicks = new PointerClickBuffer();
130
169
  /** JS grid programs the agent (or the player) runs on the current grid. */
131
170
  readonly programs: GridProgramRunner;
171
+ private halves: GridClientHalves | null = null;
132
172
  private gridEnteredAt = 0;
133
173
  private embed: CrowdyStudioEmbed | null = null;
134
174
  private hooks: StudioHooks | null = null;
@@ -137,8 +177,11 @@ export class StudioService {
137
177
  private lastGridChunkKey: string | null = null;
138
178
  private lastGridReadAt = 0;
139
179
  private lastModsReadAt = 0;
140
- private modsInFlight = false;
141
180
  private timer: ReturnType<typeof setInterval> | null = null;
181
+ /** The grid the open Studio edits, and the mod its project runs as. */
182
+ private studioGridId: string | null = null;
183
+ private studioMod: string | null = null;
184
+ private unwatchStudio: (() => void) | null = null;
142
185
 
143
186
  constructor(private readonly session: GameSession) {
144
187
  this.grids = new GridService(session.network);
@@ -189,11 +232,8 @@ export class StudioService {
189
232
  get crowdyStudio() {
190
233
  return network().game.crowdyStudio;
191
234
  },
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).
235
+ playerCompute: NO_PLAYER_COMPUTE,
236
+ // Both targets' builds, deploys, switch and preview (`serverEngine` below).
197
237
  get exec() {
198
238
  return network().game.exec;
199
239
  },
@@ -213,8 +253,8 @@ export class StudioService {
213
253
  },
214
254
  appId: () => this.session.appId,
215
255
  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.
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).
218
258
  serverEngine: 'ck-exec',
219
259
  closeKeyCode: 'KeyM',
220
260
  dsh: {
@@ -230,7 +270,8 @@ export class StudioService {
230
270
  studioOrigin: STUDIO_ORIGIN ?? undefined,
231
271
  openOnMount: true,
232
272
  },
233
- onAgentMounted: (_handle) => {
273
+ onAgentMounted: (handle) => {
274
+ this.watchStudio(handle.controller);
234
275
  this.state = { ...this.state, agentReady: true, agentReason: null };
235
276
  this.emit();
236
277
  },
@@ -239,6 +280,7 @@ export class StudioService {
239
280
  this.emit();
240
281
  },
241
282
  onAgentUnmounted: () => {
283
+ this.watchStudio(null);
242
284
  this.state = { ...this.state, agentReady: false, agentReason: null };
243
285
  this.emit();
244
286
  },
@@ -250,7 +292,9 @@ export class StudioService {
250
292
  this.overlay.remove(source);
251
293
  this.hud.remove(source);
252
294
  }
295
+ this.studioGridId = null;
253
296
  this.setOpen(false);
297
+ void this.refreshClientHalves();
254
298
  },
255
299
  });
256
300
  if (!this.timer) this.timer = setInterval(() => void this.poll(), 2000);
@@ -321,7 +365,9 @@ export class StudioService {
321
365
  dispose(): void {
322
366
  if (this.timer) clearInterval(this.timer);
323
367
  this.timer = null;
324
- this.lifecycle.shutdown();
368
+ this.halves?.stop();
369
+ this.halves = null;
370
+ this.watchStudio(null);
325
371
  this.programs.stopAll();
326
372
  this.embed?.destroy();
327
373
  this.embed = null;
@@ -337,6 +383,7 @@ export class StudioService {
337
383
  private openOn(grid: GridSnapshot): void {
338
384
  if (!this.embed) throw new Error('Studio not attached');
339
385
  this.embed.toggle(this.contextFor(grid));
386
+ this.studioGridId = this.embed.open ? grid.gridId : null;
340
387
  this.setOpen(true);
341
388
  }
342
389
 
@@ -345,7 +392,7 @@ export class StudioService {
345
392
  const clientOk = this.state.clientModsAvailable;
346
393
  const reads = this.reads();
347
394
  const writes = this.writes();
348
- const serverCalls = this.serverCallsFor(grid);
395
+ const serverCalls = this.serverCallsFor(grid.gridId, grid.bounds);
349
396
  const overlaySource = `studio:${grid.gridId}`;
350
397
  return {
351
398
  gridId: grid.gridId,
@@ -394,16 +441,87 @@ export class StudioService {
394
441
  }
395
442
 
396
443
  /**
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.
444
+ * The rest of the CLIENT host catalog for a mod on a grid (DN-10): channels,
445
+ * sessions and user state through CrowdyJS, confined to the grid. The local
446
+ * router still answers world reads and voxel writes.
400
447
  */
401
- private serverCallsFor(grid: GridSnapshot): (call: PlayerCodeHostCall) => Promise<unknown> {
448
+ private serverCallsFor(
449
+ gridId: string,
450
+ bounds: GridBounds,
451
+ ): (call: PlayerCodeHostCall) => Promise<unknown> {
402
452
  const game = this.session.network.game;
403
- const scope = game.grid(this.session.appId, grid.gridId, toBrokerBounds(grid.bounds));
453
+ const scope = game.grid(this.session.appId, gridId, toBrokerBounds(bounds));
404
454
  return createGridHostCalls({ scope, client: game });
405
455
  }
406
456
 
457
+ /**
458
+ * The grid's CLIENT halves, made on first use: the app is entered by then,
459
+ * and the whole service is rebuilt for another app.
460
+ */
461
+ private clientHalves(): GridClientHalves {
462
+ this.halves ??= new GridClientHalves({
463
+ exec: () => this.session.network.game.exec,
464
+ appId: this.session.appId,
465
+ workerUrl: glueWorkerAssetUrl,
466
+ selfUserId: () => this.session.network.user?.userId ?? null,
467
+ reads: this.reads(),
468
+ writes: this.writes(),
469
+ input: this.pointerClicks,
470
+ serverCalls: (grid) =>
471
+ this.serverCallsFor(grid.gridId, {
472
+ low: stringCorner(grid.bounds.low),
473
+ high: stringCorner(grid.bounds.high),
474
+ }),
475
+ hud: this.hud,
476
+ overlay: this.overlay,
477
+ ask: this.hooks?.clientHalfConsent ?? 'author',
478
+ confirm: (prompt, signal) =>
479
+ this.confirmTrust(describeClientHalfPrompt(prompt, this.authorName(prompt)), {
480
+ prompt,
481
+ signal,
482
+ }),
483
+ filter: (mod) => !this.previewedByStudio(mod),
484
+ onChange: (running) => {
485
+ this.state = { ...this.state, clientModsRunning: running.length };
486
+ this.emit();
487
+ },
488
+ log: (line) => this.session.network.log(line),
489
+ });
490
+ return this.halves;
491
+ }
492
+
493
+ /** The open Studio previews its project's CLIENT half itself; the grid's copy waits. */
494
+ private previewedByStudio(mod: ExecGridClientMod): boolean {
495
+ return (
496
+ this.isOpen &&
497
+ this.studioMod !== null &&
498
+ String(mod.gridId) === this.studioGridId &&
499
+ mod.name === this.studioMod
500
+ );
501
+ }
502
+
503
+ private watchStudio(controller: CrowdyStudioController | null): void {
504
+ this.unwatchStudio?.();
505
+ this.unwatchStudio = null;
506
+ const follow = (mod: string | null) => {
507
+ if (mod === this.studioMod) return;
508
+ this.studioMod = mod;
509
+ void this.refreshClientHalves();
510
+ };
511
+ if (!controller) {
512
+ follow(null);
513
+ return;
514
+ }
515
+ this.unwatchStudio = controller.subscribe((state) =>
516
+ follow(state.project ? studioPreviewModName(state.project) : null),
517
+ );
518
+ }
519
+
520
+ private authorName(prompt: ExecClientHalfPrompt): string | null {
521
+ const grid = this.currentGrid;
522
+ return grid && grid.gridId === String(prompt.gridId) ? (grid.ownerName ?? null) : null;
523
+ }
524
+
407
525
  private observationFrame() {
408
526
  const pose = this.session.joined ? this.session.world.self.state : null;
409
527
  const nearby = this.session.players().map((player) => ({
@@ -495,15 +613,19 @@ export class StudioService {
495
613
  }
496
614
 
497
615
  private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
616
+ const entered = (grid?.gridId ?? null) !== (this.currentGrid?.gridId ?? null);
498
617
  this.currentGrid = grid;
499
618
  this.lastGridChunkKey = chunkKey(chunk);
500
619
  this.lastGridReadAt = Date.now();
501
- if (this.lifecycle.enterGrid(grid?.gridId ?? null)) {
502
- this.declinedAuthors.clear();
503
- this.approvedAuthors.clear();
620
+ if (entered) {
504
621
  this.lastModsReadAt = 0;
505
622
  this.gridEnteredAt = Date.now();
506
623
  this.overlay.clear();
624
+ if (this.state.clientModsAvailable) {
625
+ this.clientHalves().enterGrid(
626
+ grid ? { gridId: grid.gridId, bounds: toBrokerBounds(grid.bounds) } : null,
627
+ );
628
+ }
507
629
  }
508
630
  this.state = { ...this.state, grid };
509
631
  this.emit();
@@ -522,7 +644,7 @@ export class StudioService {
522
644
  return Number.isFinite(px) ? px : 0;
523
645
  }
524
646
 
525
- /** Periodic: which grid are we on, and are there mods to run for us? */
647
+ /** Periodic: which grid are we on, and are there CLIENT halves to run for us? */
526
648
  private async poll(): Promise<void> {
527
649
  if (!this.session.joined || !this.session.network.hasGame) return;
528
650
  const selfChunk = this.session.world.self.chunk;
@@ -538,118 +660,31 @@ export class StudioService {
538
660
  this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
539
661
  }
540
662
  }
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
- }
663
+ if (now - this.lastModsReadAt >= MODS_REFRESH_MS) await this.refreshClientHalves();
549
664
  }
550
665
 
551
666
  /**
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.
667
+ * Reconcile the grid's CLIENT halves: start the consented ones, ask about
668
+ * the rest, stop what changed or went. Waits out the presence race after
669
+ * entering a grid.
555
670
  */
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;
671
+ private async refreshClientHalves(): Promise<void> {
672
+ const halves = this.halves;
673
+ if (!halves || !this.currentGrid || !this.state.clientModsAvailable) return;
674
+ if (Date.now() - this.gridEnteredAt < GRID_SETTLE_MS) return;
675
+ this.lastModsReadAt = Date.now();
563
676
  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
- }
677
+ await halves.refresh();
611
678
  } 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();
679
+ this.session.network.log(`grid CLIENT halves unavailable: ${messageOf(error)}`);
617
680
  }
618
681
  }
619
682
 
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);
648
- }
649
- }
650
-
651
- private async confirmTrust(summary: string): Promise<boolean> {
652
- if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary);
683
+ private async confirmTrust(
684
+ summary: string,
685
+ context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
686
+ ): Promise<boolean> {
687
+ if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary, context);
653
688
  return window.confirm(summary);
654
689
  }
655
690
 
@@ -658,29 +693,6 @@ export class StudioService {
658
693
  }
659
694
  }
660
695
 
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
- }
696
+ function stringCorner(corner: { x: bigint; y: bigint; z: bigint }) {
697
+ return { x: corner.x.toString(), y: corner.y.toString(), z: corner.z.toString() };
686
698
  }