@crowdedkingdoms/construct 0.0.0 → 0.2.1-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.
Files changed (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +48 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. package/src/platform/util/Emitter.ts +32 -0
@@ -0,0 +1,686 @@
1
+ /**
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.
7
+ *
8
+ * The SDK's embed kit owns the chrome (dock, fullscreen fallback, Context
9
+ * drawer, focus trap, HUD sink). This service supplies what only the game
10
+ * knows:
11
+ * - 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
+ * - 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 Agentic Crowdy Studio host (Ask / Build / Play) when the platform
18
+ * policy and `use_studio_agent` are armed.
19
+ *
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
+ */
24
+ import { createGridHostCalls, type PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
25
+ import {
26
+ CrowdyStudioEmbed,
27
+ CrowdyStudioTextHud,
28
+ type CrowdyStudioEmbedContext,
29
+ } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
30
+ import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
31
+
32
+ import { API_HTTP_URL, API_WS_URL, CLIENT_MODS_ENABLED, GAME_NAME, STUDIO_ORIGIN } from '../config';
33
+ import { GridProgramRunner } from '../../grid/GridProgramRunner';
34
+ import type { GameSession } from '../GameSession';
35
+ import { messageOf } from '../network/NetworkManager';
36
+ import { isTextEntry } from '../../engine/Input';
37
+ import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
38
+ import { AgentLocomotion } from './agentLocomotion';
39
+ import { ClientModLifecycle, type ClientModScope } from './ClientModLifecycle';
40
+ import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
41
+ import { HumanInputMonitor } from './HumanInputMonitor';
42
+ import {
43
+ bytesToBase64,
44
+ DEFAULT_VOXEL_STATE,
45
+ routeClientHostCall,
46
+ routeWithFallback,
47
+ runConsentedGridMod,
48
+ type ClientModHostReads,
49
+ type ClientModHostWrites,
50
+ } from './clientModHost';
51
+ import { PointerClickBuffer } from './pointerClicks';
52
+ import { ModOverlayStore } from './modOverlay';
53
+ import { GridService, type GridSnapshot } from './GridService';
54
+ import { hasAnyStudioPermission, toBrokerBounds } from './permissions';
55
+ import { Emitter } from '../util/Emitter';
56
+
57
+ export interface StudioState {
58
+ open: boolean;
59
+ grid: GridSnapshot | null;
60
+ /** COOP/COEP present and the build flag on. */
61
+ clientModsAvailable: boolean;
62
+ clientModsRunning: number;
63
+ /** Why CLIENT mods are unavailable, when they are. */
64
+ clientModsReason: string | null;
65
+ /** Agent dock mounted (policy + permission + host). */
66
+ agentReady: boolean;
67
+ /** Why the agent dock is hidden or dead, when it is. */
68
+ agentReason: string | null;
69
+ }
70
+
71
+ export interface StudioHooks {
72
+ suppressGameplayInput(): () => void;
73
+ onLayoutChange(rightInsetPx: number): void;
74
+ 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>;
77
+ /** Screenshot hook for the agent pane. */
78
+ captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
79
+ describeView?(): string | undefined;
80
+ }
81
+
82
+ /**
83
+ * CrowdyJS TextHud JSON.stringifies payloads and clips them at 200 characters,
84
+ * which turns an ASCII table into a one-line `{greeting:"BILL…`. Prefer a
85
+ * `greeting` string (still textContent, never HTML) and do not clip it.
86
+ */
87
+ function createConstructTextHud(): CrowdyStudioTextHud {
88
+ const hud = new CrowdyStudioTextHud();
89
+ Object.assign(hud, {
90
+ describe(payload: unknown) {
91
+ if (typeof payload === 'string') return payload;
92
+ if (payload && typeof payload === 'object') {
93
+ const greeting = (payload as { greeting?: unknown }).greeting;
94
+ if (typeof greeting === 'string' && greeting.length > 0) return greeting;
95
+ }
96
+ try {
97
+ return JSON.stringify(payload);
98
+ } catch {
99
+ return '(unrenderable payload)';
100
+ }
101
+ },
102
+ });
103
+ return hud;
104
+ }
105
+
106
+ const GRID_REFRESH_MS = 8_000;
107
+ const MODS_REFRESH_MS = 10_000;
108
+ /**
109
+ * Presence is written by Buddy when the actor's chunk changes; the trust and
110
+ * artifact gates check it server-side. Asking within the first seconds of
111
+ * entering a grid races that write ("Grid author bundle not found" on
112
+ * 2026-09-07), so wait a moment before the first sync.
113
+ */
114
+ const GRID_SETTLE_MS = 4_000;
115
+
116
+ export class StudioService {
117
+ readonly events = new Emitter<{ state: StudioState }>();
118
+ readonly grids: GridService;
119
+ /** Play locomotion wishes; HolodeckScene samples these each frame. */
120
+ readonly locomotion = new AgentLocomotion();
121
+ /** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
122
+ readonly overlay = new ModOverlayStore();
123
+ 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
+ private readonly agentHost: ConstructPlayerHostAdapter;
128
+ private readonly humanInput = new HumanInputMonitor();
129
+ private readonly pointerClicks = new PointerClickBuffer();
130
+ /** JS grid programs the agent (or the player) runs on the current grid. */
131
+ readonly programs: GridProgramRunner;
132
+ private gridEnteredAt = 0;
133
+ private embed: CrowdyStudioEmbed | null = null;
134
+ private hooks: StudioHooks | null = null;
135
+ private state: StudioState;
136
+ private currentGrid: GridSnapshot | null = null;
137
+ private lastGridChunkKey: string | null = null;
138
+ private lastGridReadAt = 0;
139
+ private lastModsReadAt = 0;
140
+ private modsInFlight = false;
141
+ private timer: ReturnType<typeof setInterval> | null = null;
142
+
143
+ constructor(private readonly session: GameSession) {
144
+ this.grids = new GridService(session.network);
145
+ this.programs = new GridProgramRunner({
146
+ client: () => this.session.network.game,
147
+ appId: () => this.session.appId,
148
+ graphqlUrl: `${API_HTTP_URL}/graphql`,
149
+ graphqlWsUrl: `${API_WS_URL}/graphql`,
150
+ sandboxUrl: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}grid-program.html`,
151
+ owns: (gridId) => this.currentGrid?.gridId === gridId && this.currentGrid.owned,
152
+ });
153
+ this.agentHost = new ConstructPlayerHostAdapter({
154
+ frame: () => this.observationFrame(),
155
+ locomotion: this.locomotion,
156
+ sendChat: (text) => this.session.chat.send(text),
157
+ });
158
+ const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
159
+ this.state = {
160
+ open: false,
161
+ grid: null,
162
+ clientModsAvailable: CLIENT_MODS_ENABLED && isolated,
163
+ clientModsRunning: 0,
164
+ clientModsReason: !CLIENT_MODS_ENABLED
165
+ ? 'CLIENT mods are disabled in this build (VITE_CONSTRUCT_CLIENT_MODS=0).'
166
+ : isolated
167
+ ? null
168
+ : 'CLIENT mods are off: this page is not cross-origin isolated. The host must send ' +
169
+ 'Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless ' +
170
+ '(see docs/HOSTING.md).',
171
+ agentReady: false,
172
+ agentReason: null,
173
+ };
174
+ }
175
+
176
+ /** Grid the local player is standing on, if known. */
177
+ get grid(): GridSnapshot | null {
178
+ return this.currentGrid;
179
+ }
180
+
181
+ /** Wire the engine-side hooks; call once after the loop exists. */
182
+ attach(hooks: StudioHooks): void {
183
+ this.hooks = hooks;
184
+ const network = () => this.session.network;
185
+ this.embed = new CrowdyStudioEmbed({
186
+ // Resolved lazily: the game client exists only after enterApp, and is
187
+ // rebuilt if the player switches apps.
188
+ client: {
189
+ get crowdyStudio() {
190
+ return network().game.crowdyStudio;
191
+ },
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).
197
+ get exec() {
198
+ return network().game.exec;
199
+ },
200
+ get playerWallet() {
201
+ return network().game.playerWallet;
202
+ },
203
+ // The GitHub card rides the game's app token on purpose: the API
204
+ // scopes every crowdyStudioGitHub* working-tree field to projects
205
+ // this token's user owns, so no identity session is needed here
206
+ // (and this page never holds one — it signs in through hosted
207
+ // /authorize). Connect / Bind / Unbind are identity-only and happen
208
+ // in hosted Studio. GitHub is optional; a project saves in Studio
209
+ // either way.
210
+ get crowdyStudioGitHub() {
211
+ return network().game.crowdyStudioGitHub;
212
+ },
213
+ },
214
+ appId: () => this.session.appId,
215
+ 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
+ closeKeyCode: 'KeyM',
220
+ dsh: {
221
+ graphql: network().game.graphql,
222
+ // Under Vite's base, so a build served from a sub-path (`vite build
223
+ // --base /the-construct/`) finds its own harness instead of the
224
+ // origin's root. The default base is '/', which keeps '/dsh/'.
225
+ webBase: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}dsh/`,
226
+ graphqlUrl: `${API_HTTP_URL}/graphql`,
227
+ apiOrigin: API_HTTP_URL,
228
+ getToken: () => network().game.getToken(),
229
+ persistScope: `${this.session.appId}/${this.session.selfUuid}`,
230
+ studioOrigin: STUDIO_ORIGIN ?? undefined,
231
+ openOnMount: true,
232
+ },
233
+ onAgentMounted: (_handle) => {
234
+ this.state = { ...this.state, agentReady: true, agentReason: null };
235
+ this.emit();
236
+ },
237
+ onAgentUnavailable: (message) => {
238
+ this.state = { ...this.state, agentReady: false, agentReason: message };
239
+ this.emit();
240
+ },
241
+ onAgentUnmounted: () => {
242
+ this.state = { ...this.state, agentReady: false, agentReason: null };
243
+ this.emit();
244
+ },
245
+ suppressGameplayInput: () => hooks.suppressGameplayInput(),
246
+ onLayoutChange: () => hooks.onLayoutChange(this.readRightInset()),
247
+ onClosed: () => {
248
+ if (this.currentGrid) {
249
+ const source = `studio:${this.currentGrid.gridId}`;
250
+ this.overlay.remove(source);
251
+ this.hud.remove(source);
252
+ }
253
+ this.setOpen(false);
254
+ },
255
+ });
256
+ if (!this.timer) this.timer = setInterval(() => void this.poll(), 2000);
257
+ this.pointerClicks.attach();
258
+ this.emit();
259
+ }
260
+
261
+ get snapshot(): StudioState {
262
+ return this.state;
263
+ }
264
+
265
+ get isOpen(): boolean {
266
+ return this.embed?.open ?? false;
267
+ }
268
+
269
+ /** Claim the chunk under `position`, then open the studio on it. */
270
+ async claimHereAndOpen(position: Vec3): Promise<GridSnapshot> {
271
+ const chunk = worldToChunk(position);
272
+ this.hooks?.notify('Claiming this chunk…');
273
+ const grid = await this.grids.claimHere(chunk, this.session.displayName);
274
+ this.adoptGrid(grid, chunk);
275
+ if (!hasAnyStudioPermission(grid.permissions)) {
276
+ throw new Error(
277
+ `Claimed grid ${grid.gridId}, but your access tier grants no code permissions. ` +
278
+ 'Re-run Setup (it creates a Constructor tier with the code keys) or grant them in CK Studio.',
279
+ );
280
+ }
281
+ this.hooks?.notify(`Claimed grid ${grid.gridId}. Opening Crowdy Studio…`);
282
+ this.openOn(grid);
283
+ return grid;
284
+ }
285
+
286
+ /** M key: toggle the studio on the grid under the player, if permitted. */
287
+ async toggle(position: Vec3): Promise<void> {
288
+ if (!this.embed) return;
289
+ if (this.embed.open) {
290
+ this.embed.close();
291
+ return;
292
+ }
293
+ const chunk = worldToChunk(position);
294
+ let grid =
295
+ this.currentGrid && chunkKey(chunk) === this.lastGridChunkKey ? this.currentGrid : null;
296
+ if (!grid) {
297
+ grid = await this.grids.lookup(chunk);
298
+ this.adoptGrid(grid, chunk);
299
+ }
300
+ if (!grid || !hasAnyStudioPermission(grid.permissions)) {
301
+ this.hooks?.notify(
302
+ grid
303
+ ? 'You have no code permission on this grid. Stand on your own claimed chunk.'
304
+ : 'Stand on a claimed chunk (use the Claim pad) to open Crowdy Studio.',
305
+ 'warn',
306
+ );
307
+ return;
308
+ }
309
+ this.openOn(grid);
310
+ }
311
+
312
+ close(): void {
313
+ this.embed?.close();
314
+ if (this.currentGrid) {
315
+ const source = `studio:${this.currentGrid.gridId}`;
316
+ this.overlay.remove(source);
317
+ this.hud.remove(source);
318
+ }
319
+ }
320
+
321
+ dispose(): void {
322
+ if (this.timer) clearInterval(this.timer);
323
+ this.timer = null;
324
+ this.lifecycle.shutdown();
325
+ this.programs.stopAll();
326
+ this.embed?.destroy();
327
+ this.embed = null;
328
+ this.locomotion.clear();
329
+ this.humanInput.dispose();
330
+ this.pointerClicks.dispose();
331
+ this.hud.destroy();
332
+ this.overlay.clear();
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+
337
+ private openOn(grid: GridSnapshot): void {
338
+ if (!this.embed) throw new Error('Studio not attached');
339
+ this.embed.toggle(this.contextFor(grid));
340
+ this.setOpen(true);
341
+ }
342
+
343
+ private contextFor(grid: GridSnapshot): CrowdyStudioEmbedContext {
344
+ const bounds = toBrokerBounds(grid.bounds, grid.gridId);
345
+ const clientOk = this.state.clientModsAvailable;
346
+ const reads = this.reads();
347
+ const writes = this.writes();
348
+ const serverCalls = this.serverCallsFor(grid);
349
+ const overlaySource = `studio:${grid.gridId}`;
350
+ return {
351
+ gridId: grid.gridId,
352
+ grid: bounds,
353
+ targetPermissions: {
354
+ SERVER: grid.permissions.server,
355
+ // Keys say what you MAY do; isolation says what the browser CAN do.
356
+ CLIENT: clientOk
357
+ ? grid.permissions.client
358
+ : { canWrite: grid.permissions.client.canWrite, canRun: false },
359
+ },
360
+ permissionsNote: clientOk
361
+ ? 'Authoritative effective grid keys'
362
+ : `Authoritative effective grid keys. ${this.state.clientModsReason ?? ''}`.trim(),
363
+ ...(clientOk
364
+ ? {
365
+ workerUrl: glueWorkerAssetUrl,
366
+ onHostCall: (call: PlayerCodeHostCall) =>
367
+ routeWithFallback(
368
+ call,
369
+ () => routeClientHostCall(call, reads, bounds, writes, this.pointerClicks),
370
+ serverCalls,
371
+ ),
372
+ onPresentation: (presentation) => {
373
+ if (presentation.channel === 'hud') {
374
+ this.hud.set({
375
+ source: overlaySource,
376
+ label: 'Crowdy Studio preview',
377
+ payload: presentation.payload,
378
+ });
379
+ }
380
+ if (presentation.channel === 'overlay') {
381
+ this.overlay.apply(overlaySource, presentation.payload, bounds);
382
+ }
383
+ },
384
+ hud: this.hud,
385
+ }
386
+ : {}),
387
+ playerHost: this.agentHost,
388
+ dshHost: {
389
+ captureFrame: async () => (await this.hooks?.captureFrame?.()) ?? null,
390
+ describeView: () => this.hooks?.describeView?.(),
391
+ grid: this.programs,
392
+ },
393
+ };
394
+ }
395
+
396
+ /**
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.
400
+ */
401
+ private serverCallsFor(grid: GridSnapshot): (call: PlayerCodeHostCall) => Promise<unknown> {
402
+ 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 });
405
+ }
406
+
407
+ private observationFrame() {
408
+ const pose = this.session.joined ? this.session.world.self.state : null;
409
+ const nearby = this.session.players().map((player) => ({
410
+ actorId: player.uuid,
411
+ position: { x: player.pose.x, y: player.pose.y, z: player.pose.z },
412
+ label: player.pose.name || undefined,
413
+ }));
414
+ return {
415
+ playerId: this.session.joined ? this.session.selfUuid : 'local',
416
+ position: pose ? { x: pose.x, y: pose.y, z: pose.z } : { x: 0, y: 0, z: 0 },
417
+ velocity: pose ? { x: pose.vx, y: pose.vy, z: pose.vz } : { x: 0, y: 0, z: 0 },
418
+ yaw: pose?.yaw ?? 0,
419
+ pitch: pose?.pitch ?? 0,
420
+ grid: this.currentGrid,
421
+ nearbyActors: nearby,
422
+ humanInputActive: this.humanInput.active(),
423
+ textInputFocused: isTextEntry(
424
+ typeof document === 'undefined' ? null : document.activeElement,
425
+ ),
426
+ modalOpen: this.embed?.modal ?? false,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * What a CLIENT mod may read: players in a chunk (from the replicated lane
432
+ * plus ourselves) and the cached voxel grid of a chunk. Nothing else.
433
+ */
434
+ private reads(): ClientModHostReads {
435
+ return {
436
+ actorsInChunk: (x, y, z) => {
437
+ const rows: Array<Record<string, unknown>> = [];
438
+ const world = this.session.world;
439
+ const selfChunk = world.self.chunk;
440
+ if (
441
+ selfChunk &&
442
+ BigInt(selfChunk.x) === x &&
443
+ BigInt(selfChunk.y) === y &&
444
+ BigInt(selfChunk.z) === z
445
+ ) {
446
+ const s = world.self.state;
447
+ rows.push({
448
+ uuid: world.self.uuid,
449
+ self: true,
450
+ name: s.name,
451
+ x: s.x,
452
+ y: s.y,
453
+ z: s.z,
454
+ program: s.program,
455
+ });
456
+ }
457
+ for (const p of this.session.players()) {
458
+ if (BigInt(p.chunk.x) === x && BigInt(p.chunk.y) === y && BigInt(p.chunk.z) === z) {
459
+ rows.push({
460
+ uuid: p.uuid,
461
+ name: p.pose.name,
462
+ x: p.pose.x,
463
+ y: p.pose.y,
464
+ z: p.pose.z,
465
+ program: p.pose.program,
466
+ });
467
+ }
468
+ }
469
+ return rows;
470
+ },
471
+ chunkVoxels: (x, y, z) => {
472
+ const cached = this.session.world.chunks.get({ x: Number(x), y: Number(y), z: Number(z) });
473
+ if (!cached?.voxels) return { voxelsBase64: null };
474
+ return { voxelsBase64: bytesToBase64(cached.voxels) };
475
+ },
476
+ };
477
+ }
478
+
479
+ private writes(): ClientModHostWrites {
480
+ return {
481
+ setVoxel: async (input) => {
482
+ const chunks = this.session.world.chunks;
483
+ const ok = await chunks.setVoxel({
484
+ chunk: input.chunk,
485
+ x: input.x,
486
+ y: input.y,
487
+ z: input.z,
488
+ voxelType: input.voxelType,
489
+ state: input.state ?? DEFAULT_VOXEL_STATE,
490
+ });
491
+ if (ok) chunks.markDirty(input.chunk);
492
+ return ok;
493
+ },
494
+ };
495
+ }
496
+
497
+ private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
498
+ this.currentGrid = grid;
499
+ this.lastGridChunkKey = chunkKey(chunk);
500
+ this.lastGridReadAt = Date.now();
501
+ if (this.lifecycle.enterGrid(grid?.gridId ?? null)) {
502
+ this.declinedAuthors.clear();
503
+ this.approvedAuthors.clear();
504
+ this.lastModsReadAt = 0;
505
+ this.gridEnteredAt = Date.now();
506
+ this.overlay.clear();
507
+ }
508
+ this.state = { ...this.state, grid };
509
+ this.emit();
510
+ }
511
+
512
+ private setOpen(open: boolean): void {
513
+ if (this.state.open === open) return;
514
+ this.state = { ...this.state, open };
515
+ this.session.studioOpen = open;
516
+ this.emit();
517
+ }
518
+
519
+ private readRightInset(): number {
520
+ const raw = document.body.style.getPropertyValue('--ck-game-right-inset');
521
+ const px = Number.parseFloat(raw);
522
+ return Number.isFinite(px) ? px : 0;
523
+ }
524
+
525
+ /** Periodic: which grid are we on, and are there mods to run for us? */
526
+ private async poll(): Promise<void> {
527
+ if (!this.session.joined || !this.session.network.hasGame) return;
528
+ const selfChunk = this.session.world.self.chunk;
529
+ if (!selfChunk) return;
530
+ const chunk = { x: Number(selfChunk.x), y: Number(selfChunk.y), z: Number(selfChunk.z) };
531
+ const key = chunkKey(chunk);
532
+ const now = Date.now();
533
+ if (key !== this.lastGridChunkKey || now - this.lastGridReadAt > GRID_REFRESH_MS) {
534
+ try {
535
+ const grid = await this.grids.lookup(chunk);
536
+ this.adoptGrid(grid, chunk);
537
+ } catch (error) {
538
+ this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
539
+ }
540
+ }
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
+ }
549
+ }
550
+
551
+ /**
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.
555
+ */
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;
563
+ 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
+ }
611
+ } 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);
648
+ }
649
+ }
650
+
651
+ private async confirmTrust(summary: string): Promise<boolean> {
652
+ if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary);
653
+ return window.confirm(summary);
654
+ }
655
+
656
+ private emit(): void {
657
+ this.events.emit('state', this.state);
658
+ }
659
+ }
660
+
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
+ }
686
+ }