@crowdedkingdoms/construct 0.2.1-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.
@@ -0,0 +1,328 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // The SDK barrel pulls Monaco, which does not load under happy-dom; the runner and the
4
+ // Studio's project model are plain modules, so the tests drive the real ones.
5
+ vi.mock('@crowdedkingdoms/crowdyjs', async () => {
6
+ const dist = '../../../../../node_modules/@crowdedkingdoms/crowdyjs/dist';
7
+ const halves = await import(/* @vite-ignore */ `${dist}/grid-mods/exec-client-halves.js`);
8
+ return { ExecClientHalves: halves.ExecClientHalves, PlayerCodeBroker: class {} };
9
+ });
10
+ vi.mock('@crowdedkingdoms/crowdyjs/crowdy-studio', async () => {
11
+ const dist = '../../../../../node_modules/@crowdedkingdoms/crowdyjs/dist';
12
+ const models = await import(/* @vite-ignore */ `${dist}/crowdy-studio/models.js`);
13
+ return { projectTargets: models.projectTargets };
14
+ });
15
+
16
+ const {
17
+ GridClientHalves,
18
+ clientHalfSource,
19
+ describeClientHalfPrompt,
20
+ isOwnClientHalfPrompt,
21
+ studioPreviewModName,
22
+ } = await import('./clientHalves');
23
+
24
+ const GRID = {
25
+ gridId: '42',
26
+ bounds: { low: { x: 5n, y: 0n, z: 5n }, high: { x: 5n, y: 0n, z: 5n } },
27
+ };
28
+ const SUMMARY = {
29
+ version: 1,
30
+ target: 'client' as const,
31
+ imports: ['ck.host_call'],
32
+ hostFunctions: ['hud_set', 'actors_list', 'user_state_get'],
33
+ capabilityGroups: ['present', 'world_read', 'state'],
34
+ presentationHooks: ['hud_set'],
35
+ exportedFunctions: ['init', 'tick'],
36
+ };
37
+
38
+ function half(overrides: Record<string, unknown> = {}) {
39
+ return {
40
+ modId: 'm1',
41
+ name: 'greeter',
42
+ gridId: '42',
43
+ authorId: '7',
44
+ listingId: null,
45
+ clientVersion: 1,
46
+ digest: 'd1',
47
+ capabilitySummaryJson: JSON.stringify(SUMMARY),
48
+ capabilitySummary: SUMMARY,
49
+ capabilityHash: 'h1',
50
+ tickIntervalMs: 1000,
51
+ callerConsented: false,
52
+ authorCapabilitySummaryJson: JSON.stringify(SUMMARY),
53
+ authorCapabilitySummary: SUMMARY,
54
+ authorCapabilityHash: 'ah1',
55
+ callerTrustsAuthor: false,
56
+ updatedAt: '2026-09-27T00:00:00Z',
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ function harness(
62
+ options: {
63
+ listings?: Array<Array<ReturnType<typeof half>>>;
64
+ self?: string;
65
+ confirm?: (prompt: unknown, signal: AbortSignal) => Promise<boolean>;
66
+ filter?: (mod: { name: string }) => boolean;
67
+ } = {},
68
+ ) {
69
+ const listings = options.listings ?? [[half({ callerConsented: true })]];
70
+ let listed = 0;
71
+ const exec = {
72
+ gridClientMods: vi.fn(async () => listings[Math.min(listed++, listings.length - 1)]!),
73
+ consentClientMod: vi.fn(async () => true),
74
+ trustAuthor: vi.fn(async () => true),
75
+ modClientArtifactBytes: vi.fn(async (_appId: string, modId: string) => {
76
+ const mod = listings.flat().find((m) => m.modId === modId)!;
77
+ return {
78
+ modId,
79
+ name: mod.name,
80
+ gridId: mod.gridId,
81
+ clientVersion: mod.clientVersion,
82
+ bytes: new ArrayBuffer(8),
83
+ digest: mod.digest,
84
+ sizeBytes: 8,
85
+ fuelPerDispatch: 1_000_000n,
86
+ tickIntervalMs: mod.tickIntervalMs,
87
+ capabilitySummaryJson: mod.capabilitySummaryJson,
88
+ capabilitySummary: SUMMARY,
89
+ capabilityHash: mod.capabilityHash,
90
+ abiVersion: 0,
91
+ };
92
+ }),
93
+ };
94
+ const brokers: Array<{
95
+ options: {
96
+ onHostCall(call: unknown): Promise<unknown>;
97
+ onPresentation?(presentation: unknown): void;
98
+ consentedHostCalls?: readonly string[];
99
+ engine?: string;
100
+ };
101
+ stop: ReturnType<typeof vi.fn>;
102
+ }> = [];
103
+ const hud = { set: vi.fn(), remove: vi.fn() };
104
+ const overlay = { apply: vi.fn(), remove: vi.fn() };
105
+ const actorsInChunk = vi.fn(() => [{ uuid: 'u1', name: 'neo' }]);
106
+ const serverCall = vi.fn(async () => ({ state: 'from the server' }));
107
+ const serverCalls = vi.fn(() => serverCall);
108
+ const changes: number[] = [];
109
+ const halves = new GridClientHalves({
110
+ exec: () => exec as never,
111
+ appId: '1',
112
+ workerUrl: '/glue.js',
113
+ selfUserId: () => options.self ?? '9',
114
+ reads: { actorsInChunk, chunkVoxels: () => null },
115
+ serverCalls,
116
+ hud: hud as never,
117
+ overlay: overlay as never,
118
+ ...(options.confirm ? { confirm: options.confirm as never } : {}),
119
+ ...(options.filter ? { filter: options.filter as never } : {}),
120
+ onChange: (running) => changes.push(running.length),
121
+ brokerFactory: (brokerOptions) => {
122
+ const broker = { options: brokerOptions as never, stop: vi.fn() };
123
+ brokers.push(broker);
124
+ return { start: async () => {}, stop: broker.stop };
125
+ },
126
+ });
127
+ return { halves, exec, brokers, hud, overlay, actorsInChunk, serverCall, serverCalls, changes };
128
+ }
129
+
130
+ describe('GridClientHalves', () => {
131
+ it('runs a consented CLIENT half into the HUD and answers its host calls through the game', async () => {
132
+ const h = harness();
133
+ h.halves.enterGrid(GRID);
134
+ await h.halves.refresh();
135
+
136
+ expect(h.halves.running.map((m) => m.modId)).toEqual(['m1']);
137
+ expect(h.changes).toEqual([1]);
138
+ const [broker] = h.brokers;
139
+ expect(broker!.options.engine).toBe('ck-exec');
140
+ expect(broker!.options.consentedHostCalls).toEqual(SUMMARY.hostFunctions);
141
+
142
+ broker!.options.onPresentation?.({ channel: 'hud', payload: { greeting: 'hi' } });
143
+ expect(h.hud.set).toHaveBeenCalledWith({
144
+ source: 'grid:m1',
145
+ label: 'greeter',
146
+ payload: { greeting: 'hi' },
147
+ });
148
+ broker!.options.onPresentation?.({ channel: 'overlay', payload: { objects: [] } });
149
+ expect(h.overlay.apply).toHaveBeenCalledWith('grid:m1', { objects: [] }, GRID.bounds);
150
+
151
+ await expect(
152
+ broker!.options.onHostCall({ fn: 'actors_list', args: { x: '5', y: '0', z: '5' } }),
153
+ ).resolves.toEqual({ actors: [{ uuid: 'u1', name: 'neo' }] });
154
+ expect(h.actorsInChunk).toHaveBeenCalledWith(5n, 0n, 5n);
155
+ // What the game's router does not offer goes to the grid's server calls.
156
+ await expect(
157
+ broker!.options.onHostCall({ fn: 'user_state_get', args: { userId: '9' } }),
158
+ ).resolves.toEqual({ state: 'from the server' });
159
+ expect(h.serverCalls).toHaveBeenCalledWith(GRID);
160
+ });
161
+
162
+ it('asks about another author, and a yes trusts them at the hash the grid listed', async () => {
163
+ const asked: unknown[] = [];
164
+ const h = harness({
165
+ listings: [[half()], [half({ callerTrustsAuthor: true, callerConsented: true })]],
166
+ confirm: async (prompt) => {
167
+ asked.push(prompt);
168
+ return true;
169
+ },
170
+ });
171
+ h.halves.enterGrid(GRID);
172
+ await h.halves.refresh();
173
+
174
+ expect(asked).toHaveLength(1);
175
+ expect(h.exec.trustAuthor).toHaveBeenCalledWith('1', '42', '7', 'ah1');
176
+ expect(h.halves.running.map((m) => m.modId)).toEqual(['m1']);
177
+ });
178
+
179
+ it('runs the player’s own CLIENT halves without asking', async () => {
180
+ const confirm = vi.fn(async () => false);
181
+ const h = harness({
182
+ self: '7',
183
+ listings: [[half()], [half({ callerTrustsAuthor: true, callerConsented: true })]],
184
+ confirm,
185
+ });
186
+ h.halves.enterGrid(GRID);
187
+ await h.halves.refresh();
188
+
189
+ expect(confirm).not.toHaveBeenCalled();
190
+ expect(h.exec.trustAuthor).toHaveBeenCalledWith('1', '42', '7', 'ah1');
191
+ expect(h.halves.running).toHaveLength(1);
192
+ });
193
+
194
+ it('asks the owner about a CLIENT half installed from someone’s listing', async () => {
195
+ const confirm = vi.fn(async () => false);
196
+ const h = harness({ self: '7', listings: [[half({ listingId: '55' })]], confirm });
197
+ h.halves.enterGrid(GRID);
198
+ await h.halves.refresh();
199
+
200
+ expect(confirm).toHaveBeenCalledTimes(1);
201
+ expect(h.exec.trustAuthor).not.toHaveBeenCalled();
202
+ expect(h.halves.running).toHaveLength(0);
203
+ });
204
+
205
+ it('runs nothing unconsented when the game asks nothing', async () => {
206
+ const h = harness({ listings: [[half()]] });
207
+ h.halves.enterGrid(GRID);
208
+ await h.halves.refresh();
209
+
210
+ expect(h.exec.trustAuthor).not.toHaveBeenCalled();
211
+ expect(h.brokers).toHaveLength(0);
212
+ });
213
+
214
+ it('leaves out what the filter refuses, such as the half Crowdy Studio previews', async () => {
215
+ const h = harness({ filter: (mod) => mod.name !== 'greeter' });
216
+ h.halves.enterGrid(GRID);
217
+ await h.halves.refresh();
218
+
219
+ expect(h.brokers).toHaveLength(0);
220
+ expect(h.exec.modClientArtifactBytes).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it('stops its CLIENT halves and clears their HUD when the player leaves the grid', async () => {
224
+ const h = harness();
225
+ h.halves.enterGrid(GRID);
226
+ await h.halves.refresh();
227
+ const [broker] = h.brokers;
228
+
229
+ h.halves.enterGrid(null);
230
+ expect(broker!.stop).toHaveBeenCalled();
231
+ expect(h.hud.remove).toHaveBeenCalledWith('grid:m1');
232
+ expect(h.overlay.remove).toHaveBeenCalledWith('grid:m1');
233
+ expect(h.changes).toEqual([1, 0]);
234
+ // A late call from the stopped half does not reach the game.
235
+ await expect(
236
+ broker!.options.onHostCall({ fn: 'actors_list', args: { x: '5', y: '0', z: '5' } }),
237
+ ).rejects.toThrow(/no longer in this CLIENT half/);
238
+ expect(h.actorsInChunk).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it('withdraws an unanswered question when the player leaves the grid', async () => {
242
+ let signal: AbortSignal | null = null;
243
+ const h = harness({
244
+ listings: [[half()]],
245
+ confirm: (_prompt, s) =>
246
+ new Promise((resolve) => {
247
+ signal = s;
248
+ s.addEventListener('abort', () => resolve(false));
249
+ }),
250
+ });
251
+ h.halves.enterGrid(GRID);
252
+ const refreshed = h.halves.refresh();
253
+ await vi.waitFor(() => expect(signal).not.toBeNull());
254
+
255
+ h.halves.enterGrid({ ...GRID, gridId: '43' });
256
+ expect(signal!.aborted).toBe(true);
257
+ await refreshed;
258
+ expect(h.exec.trustAuthor).not.toHaveBeenCalled();
259
+ });
260
+ });
261
+
262
+ describe('client half prompts', () => {
263
+ it('names the author, the halves, anything installed, and the host calls', () => {
264
+ const text = describeClientHalfPrompt(
265
+ {
266
+ kind: 'author',
267
+ gridId: '42',
268
+ authorId: '7',
269
+ capabilityHash: 'ah1',
270
+ capabilitySummaryJson: JSON.stringify(SUMMARY),
271
+ capabilitySummary: SUMMARY,
272
+ mods: [half(), half({ modId: 'm2', name: 'shop', listingId: '55' })] as never,
273
+ },
274
+ 'trinity',
275
+ );
276
+ expect(text.split('\n')[0]).toBe("Run trinity's code on this grid?");
277
+ expect(text).toContain('greeter, shop will run in a sandbox in your browser');
278
+ expect(text).toContain('Installed from the marketplace: shop.');
279
+ expect(text).toContain('• host calls: hud_set, actors_list, user_state_get');
280
+ expect(text).toContain('• groups: present, world_read, state');
281
+ });
282
+
283
+ it('asks about one CLIENT half, and shows a summary that does not parse as it is', () => {
284
+ const text = describeClientHalfPrompt({
285
+ kind: 'mod',
286
+ gridId: '42',
287
+ authorId: '7',
288
+ capabilityHash: 'h1',
289
+ capabilitySummaryJson: '{"odd":true}',
290
+ capabilitySummary: null,
291
+ mods: [half()] as never,
292
+ });
293
+ expect(text.split('\n')[0]).toBe('Run the CLIENT half of greeter by player 7?');
294
+ expect(text).toContain('{"odd":true}');
295
+ });
296
+
297
+ it('counts only the player’s own, self-written halves as theirs', () => {
298
+ const prompt = (mods: unknown[]) =>
299
+ ({ kind: 'author', gridId: '42', authorId: '7', mods }) as never;
300
+ expect(isOwnClientHalfPrompt(prompt([half()]), '7')).toBe(true);
301
+ expect(isOwnClientHalfPrompt(prompt([half()]), '8')).toBe(false);
302
+ expect(isOwnClientHalfPrompt(prompt([half()]), null)).toBe(false);
303
+ expect(isOwnClientHalfPrompt(prompt([half(), half({ listingId: '55' })]), '7')).toBe(false);
304
+ });
305
+
306
+ it('keys the HUD by mod id', () => {
307
+ expect(clientHalfSource({ modId: 'abc' })).toBe('grid:abc');
308
+ });
309
+ });
310
+
311
+ describe('studioPreviewModName', () => {
312
+ const project = (kind: string, metadata: Record<string, string>) => ({ kind, metadata }) as never;
313
+
314
+ it('is the server module of a full-stack project and the client module of a CLIENT one', () => {
315
+ expect(
316
+ studioPreviewModName(
317
+ project('FULL_STACK', { serverModuleName: 'shop-server', clientModuleName: 'shop-client' }),
318
+ ),
319
+ ).toBe('shop-server');
320
+ expect(studioPreviewModName(project('CLIENT', { clientModuleName: ' hud-client ' }))).toBe(
321
+ 'hud-client',
322
+ );
323
+ });
324
+
325
+ it('is none for a SERVER-only project, which previews no CLIENT half', () => {
326
+ expect(studioPreviewModName(project('SERVER', { serverModuleName: 'beacon' }))).toBeNull();
327
+ });
328
+ });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The CLIENT halves of ck-exec mods on the grid the player stands in, run the
3
+ * way this framework runs player code: through the game's host-call router,
4
+ * into the text HUD and the holodeck overlay. CrowdyJS's `ExecClientHalves`
5
+ * lists what the grid serves, asks, fetches by digest and sandboxes each half
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.
9
+ *
10
+ * The player's own CLIENT halves run without a question (on your own grid
11
+ * every half is yours), unless one was installed from a marketplace listing:
12
+ * that code is someone else's, so the owner is asked about it like a visitor.
13
+ */
14
+ import {
15
+ ExecClientHalves,
16
+ type ExecAPI,
17
+ type ExecClientCapabilitySummary,
18
+ type ExecClientHalfError,
19
+ type ExecClientHalfPrompt,
20
+ type ExecClientHalfStopReason,
21
+ type ExecClientHalvesOptions,
22
+ type ExecGridClientMod,
23
+ type PlayerCodeGridBounds,
24
+ type PlayerCodeHostCall,
25
+ type PlayerCodePresentation,
26
+ } from '@crowdedkingdoms/crowdyjs';
27
+ import {
28
+ projectTargets,
29
+ type CrowdyStudioProject,
30
+ type CrowdyStudioTextHud,
31
+ } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
32
+
33
+ import {
34
+ routeClientHostCall,
35
+ routeWithFallback,
36
+ type ClientModHostInput,
37
+ type ClientModHostReads,
38
+ type ClientModHostWrites,
39
+ } from './clientModHost';
40
+ import type { ModOverlayStore } from './modOverlay';
41
+
42
+ export type ClientHalvesExec = Pick<
43
+ ExecAPI,
44
+ 'gridClientMods' | 'consentClientMod' | 'trustAuthor' | 'modClientArtifactBytes'
45
+ >;
46
+
47
+ /** The grid the player stands in: its id and chunk box. */
48
+ export interface ClientHalvesGrid {
49
+ gridId: string;
50
+ bounds: PlayerCodeGridBounds;
51
+ }
52
+
53
+ export interface GridClientHalvesOptions {
54
+ /** The game client's `exec`, read at each call. */
55
+ exec: () => ClientHalvesExec;
56
+ appId: string;
57
+ /** The platform glue worker (`@crowdedkingdoms/crowdyjs/player-glue-worker`), same origin. */
58
+ workerUrl: string;
59
+ /** The signed-in player's user id. */
60
+ selfUserId: () => string | null;
61
+ reads: ClientModHostReads;
62
+ writes?: ClientModHostWrites;
63
+ input?: ClientModHostInput;
64
+ /**
65
+ * Answers the host calls the game's router does not (DN-10: `createGridHostCalls`
66
+ * over the grid's scope); made once per grid.
67
+ */
68
+ serverCalls?: (grid: ClientHalvesGrid) => (call: PlayerCodeHostCall) => Promise<unknown>;
69
+ hud: CrowdyStudioTextHud;
70
+ overlay?: ModOverlayStore;
71
+ /** One question per author (the default), or one per CLIENT half. */
72
+ ask?: 'author' | 'mod';
73
+ /**
74
+ * Asks the player about CLIENT halves they neither consented to nor trust the author of.
75
+ * `signal` aborts when they leave the grid. Without it only what the player already agreed
76
+ * to runs.
77
+ */
78
+ confirm?: (prompt: ExecClientHalfPrompt, signal: AbortSignal) => Promise<boolean>;
79
+ /** Leaves out the CLIENT halves it returns false for, e.g. the one Crowdy Studio previews. */
80
+ filter?: (mod: ExecGridClientMod) => boolean;
81
+ /** After a CLIENT half starts or stops. */
82
+ onChange?: (running: readonly ExecGridClientMod[]) => void;
83
+ log?: (line: string) => void;
84
+ brokerFactory?: ExecClientHalvesOptions['brokerFactory'];
85
+ now?: () => number;
86
+ }
87
+
88
+ export class GridClientHalves {
89
+ private readonly runner: ExecClientHalves;
90
+ private grid: ClientHalvesGrid | null = null;
91
+ private serverCalls: ((call: PlayerCodeHostCall) => Promise<unknown>) | null = null;
92
+ private asking: AbortController | null = null;
93
+
94
+ constructor(private readonly options: GridClientHalvesOptions) {
95
+ const exec = options.exec;
96
+ this.runner = new ExecClientHalves({
97
+ exec: {
98
+ gridClientMods: (appId, gridId) => exec().gridClientMods(appId, gridId),
99
+ consentClientMod: (appId, modId, hash) => exec().consentClientMod(appId, modId, hash),
100
+ trustAuthor: (appId, gridId, authorId, hash) =>
101
+ exec().trustAuthor(appId, gridId, authorId, hash),
102
+ modClientArtifactBytes: (appId, modId) => exec().modClientArtifactBytes(appId, modId),
103
+ },
104
+ appId: options.appId,
105
+ workerUrl: options.workerUrl,
106
+ ask: options.ask ?? 'author',
107
+ confirm: (prompt) => this.confirm(prompt),
108
+ filter: options.filter,
109
+ onHostCall: (call, mod) => this.hostCall(call, mod),
110
+ onPresentation: (presentation, mod) => this.present(presentation, mod),
111
+ onStarted: (mod) => {
112
+ this.options.log?.(`CLIENT half of mod ${mod.name} started (version ${mod.clientVersion})`);
113
+ this.options.onChange?.(this.running);
114
+ },
115
+ onStopped: (mod, reason) => this.stopped(mod, reason),
116
+ onError: (error) => this.options.log?.(this.describeError(error)),
117
+ ...(options.brokerFactory ? { brokerFactory: options.brokerFactory } : {}),
118
+ ...(options.now ? { now: options.now } : {}),
119
+ });
120
+ }
121
+
122
+ /** The CLIENT halves running now. */
123
+ get running(): readonly ExecGridClientMod[] {
124
+ return this.runner.running;
125
+ }
126
+
127
+ /** The grid the player stands in, or null; a change stops every CLIENT half of the old one. */
128
+ enterGrid(grid: ClientHalvesGrid | null): void {
129
+ if (sameGrid(this.grid, grid)) return;
130
+ this.asking?.abort();
131
+ this.asking = null;
132
+ this.grid = grid ? { gridId: grid.gridId, bounds: grid.bounds } : null;
133
+ this.serverCalls = null;
134
+ this.runner.enterGrid(
135
+ grid ? { gridId: grid.gridId, low: grid.bounds.low, high: grid.bounds.high } : null,
136
+ );
137
+ }
138
+
139
+ /** One reconcile of the grid's CLIENT halves; rejects when they cannot be listed. */
140
+ refresh(): Promise<void> {
141
+ return this.runner.refresh();
142
+ }
143
+
144
+ stop(): void {
145
+ this.asking?.abort();
146
+ this.asking = null;
147
+ this.grid = null;
148
+ this.serverCalls = null;
149
+ this.runner.stop();
150
+ }
151
+
152
+ private async confirm(prompt: ExecClientHalfPrompt): Promise<boolean> {
153
+ if (isOwnClientHalfPrompt(prompt, this.options.selfUserId())) return true;
154
+ if (!this.options.confirm) return false;
155
+ this.asking ??= new AbortController();
156
+ return this.options.confirm(prompt, this.asking.signal);
157
+ }
158
+
159
+ private hostCall(call: PlayerCodeHostCall, mod: ExecGridClientMod): Promise<unknown> {
160
+ const grid = this.grid;
161
+ if (!grid || String(mod.gridId) !== grid.gridId) {
162
+ return Promise.reject(new Error('the player is no longer in this CLIENT half’s grid'));
163
+ }
164
+ if (!this.serverCalls && this.options.serverCalls) {
165
+ this.serverCalls = this.options.serverCalls(grid);
166
+ }
167
+ const { reads, writes, input } = this.options;
168
+ return routeWithFallback(
169
+ call,
170
+ () => routeClientHostCall(call, reads, grid.bounds, writes, input),
171
+ this.serverCalls ?? undefined,
172
+ );
173
+ }
174
+
175
+ private present(presentation: PlayerCodePresentation, mod: ExecGridClientMod): void {
176
+ const grid = this.grid;
177
+ if (!grid || String(mod.gridId) !== grid.gridId) return;
178
+ const source = clientHalfSource(mod);
179
+ if (presentation.channel === 'hud') {
180
+ this.options.hud.set({ source, label: mod.name, payload: presentation.payload });
181
+ }
182
+ if (presentation.channel === 'overlay') {
183
+ this.options.overlay?.apply(source, presentation.payload, grid.bounds);
184
+ }
185
+ }
186
+
187
+ private stopped(mod: ExecGridClientMod, reason: ExecClientHalfStopReason): void {
188
+ const source = clientHalfSource(mod);
189
+ this.options.hud.remove(source);
190
+ this.options.overlay?.remove(source);
191
+ this.options.log?.(`CLIENT half of mod ${mod.name} stopped (${reason})`);
192
+ this.options.onChange?.(this.running);
193
+ }
194
+
195
+ private describeError(error: ExecClientHalfError): string {
196
+ const now = (this.options.now ?? Date.now)();
197
+ const again =
198
+ error.retryAt !== undefined
199
+ ? `; tried again in ${Math.max(0, Math.round((error.retryAt - now) / 1000))} s`
200
+ : '';
201
+ const detail = error.error instanceof Error ? error.error.message : String(error.error);
202
+ return `CLIENT half of mod ${error.mod.name}: ${error.stage} ${error.reason} (${detail})${again}`;
203
+ }
204
+ }
205
+
206
+ /** The HUD and overlay source a CLIENT half writes to. */
207
+ export function clientHalfSource(mod: Pick<ExecGridClientMod, 'modId'>): string {
208
+ return `grid:${mod.modId}`;
209
+ }
210
+
211
+ /** The player's own CLIENT halves, none installed from a listing: they run without a question. */
212
+ export function isOwnClientHalfPrompt(
213
+ prompt: ExecClientHalfPrompt,
214
+ selfUserId: string | null,
215
+ ): boolean {
216
+ return (
217
+ selfUserId !== null &&
218
+ String(prompt.authorId) === String(selfUserId) &&
219
+ prompt.mods.every((mod) => mod.listingId == null)
220
+ );
221
+ }
222
+
223
+ /** The question a player is asked about a grid's CLIENT halves, as plain text. */
224
+ export function describeClientHalfPrompt(
225
+ prompt: ExecClientHalfPrompt,
226
+ authorName?: string | null,
227
+ ): string {
228
+ const who = authorName?.trim() || `player ${prompt.authorId}`;
229
+ const names = prompt.mods.map((mod) => mod.name);
230
+ const installed = prompt.mods.filter((mod) => mod.listingId != null).map((mod) => mod.name);
231
+ const lines =
232
+ prompt.kind === 'author'
233
+ ? [
234
+ `Run ${who}'s code on this grid?`,
235
+ '',
236
+ `${names.join(', ')} will run in a sandbox in your browser while you stand here. ` +
237
+ `Trusting ${who} covers their CLIENT halves on this grid until they need more than this.`,
238
+ ]
239
+ : [
240
+ `Run the CLIENT half of ${names[0] ?? 'this mod'} by ${who}?`,
241
+ '',
242
+ 'It runs in a sandbox in your browser while you stand here.',
243
+ ];
244
+ if (installed.length > 0) {
245
+ lines.push('', `Installed from the marketplace: ${installed.join(', ')}.`);
246
+ }
247
+ lines.push(
248
+ '',
249
+ 'It may use:',
250
+ describeClientCapabilities(prompt.capabilitySummary, prompt.capabilitySummaryJson),
251
+ );
252
+ return lines.join('\n');
253
+ }
254
+
255
+ export function describeClientCapabilities(
256
+ summary: ExecClientCapabilitySummary | null,
257
+ json: string,
258
+ ): string {
259
+ if (!summary) return json || '(no capability summary)';
260
+ const rows = [
261
+ `• host calls: ${summary.hostFunctions.length > 0 ? summary.hostFunctions.join(', ') : 'none'}`,
262
+ ];
263
+ if (summary.capabilityGroups.length > 0) {
264
+ rows.push(`• groups: ${summary.capabilityGroups.join(', ')}`);
265
+ }
266
+ return rows.join('\n');
267
+ }
268
+
269
+ /**
270
+ * The mod whose CLIENT half a Studio project previews on ck-exec: its SERVER module for a
271
+ * full-stack project, the CLIENT module a CLIENT-only project rides, and none for a
272
+ * SERVER-only project.
273
+ */
274
+ export function studioPreviewModName(project: CrowdyStudioProject): string | null {
275
+ const targets = projectTargets(project.kind);
276
+ if (!targets.includes('CLIENT')) return null;
277
+ const name = targets.includes('SERVER')
278
+ ? project.metadata.serverModuleName
279
+ : project.metadata.clientModuleName;
280
+ return name?.trim() || null;
281
+ }
282
+
283
+ function sameGrid(a: ClientHalvesGrid | null, b: ClientHalvesGrid | null): boolean {
284
+ if (!a || !b) return a === b;
285
+ const corner = (c: { x: bigint; y: bigint; z: bigint }) => `${c.x},${c.y},${c.z}`;
286
+ return (
287
+ a.gridId === b.gridId &&
288
+ corner(a.bounds.low) === corner(b.bounds.low) &&
289
+ corner(a.bounds.high) === corner(b.bounds.high)
290
+ );
291
+ }
@@ -56,7 +56,7 @@ describe('routeClientHostCall', () => {
56
56
  expect(out).toEqual({ actors: [{ uuid: 'me', name: 'neo' }] });
57
57
  });
58
58
 
59
- it('clamps actors_list_radius to the grid and to radius 8', async () => {
59
+ it('clamps actors_list_radius to the grid', async () => {
60
60
  const r = reads();
61
61
  await routeClientHostCall(
62
62
  { fn: 'actors_list_radius', args: { x: '0', y: '0', z: '0', radius: 50 } } as never,
@@ -68,6 +68,23 @@ describe('routeClientHostCall', () => {
68
68
  expect(r.calls.every((c) => c.split(',').every((v) => Math.abs(Number(v)) <= 1))).toBe(true);
69
69
  });
70
70
 
71
+ it('reads crowdy-client-sdk radii and clamps them as the platform does (3 across, 1 up)', async () => {
72
+ const r = reads();
73
+ const wide = { low: { x: -9n, y: -9n, z: -9n }, high: { x: 9n, y: 9n, z: 9n } };
74
+ await routeClientHostCall(
75
+ {
76
+ fn: 'actors_list_radius',
77
+ args: { x: '0', y: '0', z: '0', radiusXz: 9, radiusY: 4 },
78
+ } as never,
79
+ r.reads,
80
+ wide,
81
+ );
82
+ expect(r.calls.length).toBe(7 * 3 * 7);
83
+ const ys = new Set(r.calls.map((c) => Number(c.split(',')[1])));
84
+ expect([...ys].sort()).toEqual([-1, 0, 1]);
85
+ expect(r.calls.every((c) => Math.abs(Number(c.split(',')[0])) <= 3)).toBe(true);
86
+ });
87
+
71
88
  it('serves chunk_get as base64 and voxels_list as sparse rows', async () => {
72
89
  const r = reads();
73
90
  const dense = (await routeClientHostCall(
@@ -192,6 +209,28 @@ describe('routeClientHostCall', () => {
192
209
  });
193
210
  });
194
211
 
212
+ it('parses the voxel_set arguments crowdy-client-sdk sends', () => {
213
+ expect(
214
+ parseVoxelSetArgs({
215
+ chunkX: 3,
216
+ chunkY: 0,
217
+ chunkZ: 3,
218
+ voxelX: 8,
219
+ voxelY: 1,
220
+ voxelZ: 4,
221
+ voxelType: 2,
222
+ stateBase64: null,
223
+ }),
224
+ ).toEqual({
225
+ chunk: { x: 3, y: 0, z: 3 },
226
+ x: 8,
227
+ y: 1,
228
+ z: 4,
229
+ voxelType: 2,
230
+ state: DEFAULT_VOXEL_STATE,
231
+ });
232
+ });
233
+
195
234
  it('decodes an empty grid to no rows', () => {
196
235
  expect(voxelsFromBase64(null)).toEqual([]);
197
236
  expect(voxelsFromBase64(bytesToBase64(new Uint8Array(4096)))).toEqual([]);