@crowdedkingdoms/construct 0.3.1-dev.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/construct",
3
- "version": "0.3.1-dev.1",
3
+ "version": "0.3.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,8 +45,8 @@
45
45
  "./grid/*": "./src/grid/*.ts"
46
46
  },
47
47
  "peerDependencies": {
48
- "@crowdedkingdoms/crowdyjs": ">=18.0.0-dev.0 <19 || >=18.0.1-dev.0 <19",
49
- "@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1"
48
+ "@crowdedkingdoms/crowdyjs": ">=18.0.3-dev.0 <19",
49
+ "@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1 || >=0.4.3-dev.0 <1"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@crowdedkingdoms/crowdy-dsh": {
package/src/index.ts CHANGED
@@ -91,4 +91,5 @@ export {
91
91
  type ClientHalvesGrid,
92
92
  type GridClientHalvesOptions,
93
93
  } from './platform/studio/clientHalves';
94
+ export { gridServerCalls, type GridServerCallsOptions } from './platform/studio/gridServerCalls';
94
95
  export { GridProgramRunner, type GridProgramRunnerOptions } from './grid/GridProgramRunner';
@@ -24,7 +24,6 @@
24
24
  * is missing, saying so in the HUD, rather than letting a worker fail silently.
25
25
  */
26
26
  import {
27
- createGridHostCalls,
28
27
  type ExecClientHalfPrompt,
29
28
  type ExecGridClientMod,
30
29
  type PlayerCodeHostCall,
@@ -39,6 +38,7 @@ import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?wor
39
38
 
40
39
  import { API_HTTP_URL, API_WS_URL, CLIENT_MODS_ENABLED, GAME_NAME, STUDIO_ORIGIN } from '../config';
41
40
  import { GridProgramRunner } from '../../grid/GridProgramRunner';
41
+ import { gridServerCalls } from './gridServerCalls';
42
42
  import type { GameSession } from '../GameSession';
43
43
  import { messageOf } from '../network/NetworkManager';
44
44
  import { isTextEntry } from '../../engine/Input';
@@ -68,6 +68,8 @@ export interface StudioState {
68
68
  clientModsAvailable: boolean;
69
69
  /** The grid's CLIENT halves running for this player. */
70
70
  clientModsRunning: number;
71
+ /** Those CLIENT halves, for the controls that take them back. */
72
+ clientHalves: readonly RunningClientHalf[];
71
73
  /** Why CLIENT mods are unavailable, when they are. */
72
74
  clientModsReason: string | null;
73
75
  /** Agent dock mounted (policy + permission + host). */
@@ -76,6 +78,17 @@ export interface StudioState {
76
78
  agentReason: string | null;
77
79
  }
78
80
 
81
+ /** A CLIENT half running for this player, and whose it is. */
82
+ export interface RunningClientHalf {
83
+ modId: string;
84
+ name: string;
85
+ authorId: string;
86
+ /** The grid owner's display name, when the game knows it. */
87
+ authorName: string | null;
88
+ /** Written by this player (their own grid, not installed from a listing). */
89
+ own: boolean;
90
+ }
91
+
79
92
  export interface StudioHooks {
80
93
  suppressGameplayInput(): () => void;
81
94
  onLayoutChange(rightInsetPx: number): void;
@@ -179,6 +192,7 @@ export class StudioService {
179
192
  grid: null,
180
193
  clientModsAvailable: CLIENT_MODS_ENABLED && isolated,
181
194
  clientModsRunning: 0,
195
+ clientHalves: [],
182
196
  clientModsReason: !CLIENT_MODS_ENABLED
183
197
  ? 'CLIENT mods are disabled in this build (VITE_CONSTRUCT_CLIENT_MODS=0).'
184
198
  : isolated
@@ -326,6 +340,37 @@ export class StudioService {
326
340
  this.openOn(grid);
327
341
  }
328
342
 
343
+ /** "Stop running this": the player takes back their consent to one CLIENT half here. */
344
+ async stopClientHalf(modId: string): Promise<void> {
345
+ await this.takeBack(`stopped the CLIENT half of mod ${modId}`, (halves) =>
346
+ halves.revoke(modId),
347
+ );
348
+ }
349
+
350
+ /** "Forget this author": the player takes back their trust in an author on this grid. */
351
+ async forgetClientHalfAuthor(authorId: string): Promise<void> {
352
+ await this.takeBack(`forgot author ${authorId} on this grid`, (halves) =>
353
+ halves.forgetAuthor(authorId),
354
+ );
355
+ }
356
+
357
+ private async takeBack(
358
+ done: string,
359
+ work: (halves: GridClientHalves) => Promise<boolean>,
360
+ ): Promise<void> {
361
+ const halves = this.halves;
362
+ if (!halves) return;
363
+ try {
364
+ await work(halves);
365
+ this.session.network.log(`CLIENT halves: ${done}`);
366
+ } catch (error) {
367
+ this.hooks?.notify(
368
+ `Stopped here, but the platform did not record it (${messageOf(error)}); it may run on your next visit.`,
369
+ 'warn',
370
+ );
371
+ }
372
+ }
373
+
329
374
  close(): void {
330
375
  this.embed?.close();
331
376
  if (this.currentGrid) {
@@ -422,15 +467,14 @@ export class StudioService {
422
467
  gridId: string,
423
468
  bounds: GridBounds,
424
469
  ): (call: PlayerCodeHostCall) => Promise<unknown> {
425
- const game = this.session.network.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
- },
470
+ return gridServerCalls({
471
+ game: this.session.network.game,
472
+ appId: this.session.appId,
473
+ gridId,
474
+ bounds,
475
+ userId: this.session.network.user?.userId,
476
+ permissionKeys: () =>
477
+ this.currentGrid?.gridId === gridId ? this.currentGrid.permissions.effectiveKeys : [],
434
478
  });
435
479
  }
436
480
 
@@ -462,7 +506,18 @@ export class StudioService {
462
506
  }),
463
507
  filter: (mod) => !this.previewedByStudio(mod),
464
508
  onChange: (running) => {
465
- this.state = { ...this.state, clientModsRunning: running.length };
509
+ const self = this.session.network.user?.userId ?? null;
510
+ const clientHalves = running.map((mod) => ({
511
+ modId: String(mod.modId),
512
+ name: mod.name,
513
+ authorId: String(mod.authorId),
514
+ authorName:
515
+ this.currentGrid?.gridId === String(mod.gridId)
516
+ ? (this.currentGrid.ownerName ?? null)
517
+ : null,
518
+ own: self !== null && String(mod.authorId) === String(self) && mod.listingId == null,
519
+ }));
520
+ this.state = { ...this.state, clientModsRunning: running.length, clientHalves };
466
521
  this.emit();
467
522
  },
468
523
  log: (line) => this.session.network.log(line),
@@ -16,6 +16,7 @@ vi.mock('@crowdedkingdoms/crowdyjs/crowdy-studio', async () => {
16
16
  const {
17
17
  GridClientHalves,
18
18
  clientHalfSource,
19
+ describeClientCapabilities,
19
20
  describeClientHalfPrompt,
20
21
  isOwnClientHalfPrompt,
21
22
  studioPreviewModName,
@@ -73,6 +74,8 @@ function harness(
73
74
  gridClientMods: vi.fn(async () => listings[Math.min(listed++, listings.length - 1)]!),
74
75
  consentClientMod: vi.fn(async () => true),
75
76
  trustAuthor: vi.fn(async () => true),
77
+ revokeClientModConsent: vi.fn(async () => true),
78
+ revokeAuthorTrust: vi.fn(async () => true),
76
79
  modClientArtifactBytes: vi.fn(async (_appId: string, modId: string) => {
77
80
  const mod = listings.flat().find((m) => m.modId === modId)!;
78
81
  return {
@@ -166,6 +169,31 @@ describe('GridClientHalves', () => {
166
169
  expect(h.serverCalls).toHaveBeenCalledWith(GRID);
167
170
  });
168
171
 
172
+ it('stops a CLIENT half the player takes back, and forgets its author, through the game client', async () => {
173
+ const h = harness({
174
+ listings: [
175
+ [
176
+ half({ callerConsented: true }),
177
+ half({ modId: 'm2', name: 'shop', digest: 'd2', callerConsented: true }),
178
+ ],
179
+ ],
180
+ });
181
+ h.halves.enterGrid(GRID);
182
+ await h.halves.refresh();
183
+ expect(h.halves.running.map((m) => m.modId)).toEqual(['m1', 'm2']);
184
+
185
+ await expect(h.halves.revoke('m1')).resolves.toBe(true);
186
+ expect(h.exec.revokeClientModConsent).toHaveBeenCalledWith('1', 'm1');
187
+ expect(h.halves.running.map((m) => m.modId)).toEqual(['m2']);
188
+ expect(h.hud.remove).toHaveBeenCalledWith('grid:m1');
189
+
190
+ await expect(h.halves.forgetAuthor('7')).resolves.toBe(true);
191
+ expect(h.exec.revokeAuthorTrust).toHaveBeenCalledWith('1', '42', '7');
192
+ expect(h.halves.running).toEqual([]);
193
+ await h.halves.refresh();
194
+ expect(h.halves.running).toEqual([]);
195
+ });
196
+
169
197
  it("forwards a CLIENT half's log lines and invokes its handle_invoke", async () => {
170
198
  const lines: string[] = [];
171
199
  const h = harness({ log: (line) => lines.push(line) });
@@ -298,6 +326,20 @@ describe('client half prompts', () => {
298
326
  expect(text).toContain('Installed from the marketplace: shop.');
299
327
  expect(text).toContain('• host calls: hud_set, actors_list, user_state_get');
300
328
  expect(text).toContain('• groups: present, world_read, state');
329
+ expect(text).toContain('• it can read your saved state in this game');
330
+ expect(text).not.toContain('replace your saved state');
331
+ });
332
+
333
+ it('says in plain words what the calls that act as the player can do', () => {
334
+ const summary = {
335
+ ...SUMMARY,
336
+ hostFunctions: ['user_state_set', 'voxel_set', 'emit_spatial', 'constructor'],
337
+ };
338
+ const text = describeClientCapabilities(summary as never, JSON.stringify(summary));
339
+ expect(text).toContain('• it can replace your saved state in this game');
340
+ expect(text).toContain('• it can change blocks on this grid as you');
341
+ expect(text).toContain('• it can send messages and effects to players near you');
342
+ expect(text.split('\n').filter((line) => line.startsWith('• it can'))).toHaveLength(3);
301
343
  });
302
344
 
303
345
  it('asks about one CLIENT half, and shows a summary that does not parse as it is', () => {
@@ -41,7 +41,12 @@ import type { ModOverlayStore } from './modOverlay';
41
41
 
42
42
  export type ClientHalvesExec = Pick<
43
43
  ExecAPI,
44
- 'gridClientMods' | 'consentClientMod' | 'trustAuthor' | 'modClientArtifactBytes'
44
+ | 'gridClientMods'
45
+ | 'consentClientMod'
46
+ | 'trustAuthor'
47
+ | 'modClientArtifactBytes'
48
+ | 'revokeClientModConsent'
49
+ | 'revokeAuthorTrust'
45
50
  >;
46
51
 
47
52
  /** The grid the player stands in: its id and chunk box. */
@@ -105,6 +110,9 @@ export class GridClientHalves {
105
110
  trustAuthor: (appId, gridId, authorId, hash) =>
106
111
  exec().trustAuthor(appId, gridId, authorId, hash),
107
112
  modClientArtifactBytes: (appId, modId) => exec().modClientArtifactBytes(appId, modId),
113
+ revokeClientModConsent: (appId, modId) => exec().revokeClientModConsent(appId, modId),
114
+ revokeAuthorTrust: (appId, gridId, authorId) =>
115
+ exec().revokeAuthorTrust(appId, gridId, authorId),
108
116
  },
109
117
  appId: options.appId,
110
118
  workerUrl: options.workerUrl,
@@ -162,6 +170,24 @@ export class GridClientHalves {
162
170
  return this.runner.invoke(modId, payload, options);
163
171
  }
164
172
 
173
+ /**
174
+ * "Stop running this": stops the CLIENT half of mod `modId` and takes back the player's consent
175
+ * to it (and their trust in its author, when it ran through that trust, keeping the author's
176
+ * other halves). Not asked about again on this grid until it changes. Rejects when the platform
177
+ * refuses; the half stays stopped here.
178
+ */
179
+ revoke(modId: string): Promise<boolean> {
180
+ return this.runner.revoke(modId);
181
+ }
182
+
183
+ /**
184
+ * "Forget this author": stops every CLIENT half of `authorId` on this grid and takes back the
185
+ * player's trust in them here, with every consent to their halves.
186
+ */
187
+ forgetAuthor(authorId: string): Promise<boolean> {
188
+ return this.runner.forgetAuthor(authorId);
189
+ }
190
+
165
191
  stop(): void {
166
192
  this.asking?.abort();
167
193
  this.asking = null;
@@ -273,6 +299,16 @@ export function describeClientHalfPrompt(
273
299
  return lines.join('\n');
274
300
  }
275
301
 
302
+ /** What the host calls that act as the player let a CLIENT half do, in the player's words. */
303
+ const ACTS_AS_YOU: Record<string, string> = {
304
+ user_state_get: 'read your saved state in this game',
305
+ user_state_set: 'replace your saved state in this game',
306
+ voxel_set: 'change blocks on this grid as you',
307
+ emit_spatial: 'send messages and effects to players near you, from your game',
308
+ emit_channel: "post in this grid's channels, from your game",
309
+ pointer_clicks: 'see your mouse clicks',
310
+ };
311
+
276
312
  export function describeClientCapabilities(
277
313
  summary: ExecClientCapabilitySummary | null,
278
314
  json: string,
@@ -284,6 +320,9 @@ export function describeClientCapabilities(
284
320
  if (summary.capabilityGroups.length > 0) {
285
321
  rows.push(`• groups: ${summary.capabilityGroups.join(', ')}`);
286
322
  }
323
+ for (const fn of summary.hostFunctions) {
324
+ if (Object.hasOwn(ACTS_AS_YOU, fn)) rows.push(`• it can ${ACTS_AS_YOU[fn]}`);
325
+ }
287
326
  return rows.join('\n');
288
327
  }
289
328
 
@@ -185,6 +185,54 @@ describe('routeClientHostCall', () => {
185
185
  ]);
186
186
  });
187
187
 
188
+ it('writes no voxel outside the grid, whatever second chunk the call carries', async () => {
189
+ const r = reads();
190
+ const written: Array<{ chunk: { x: number; y: number; z: number } }> = [];
191
+ const writes = {
192
+ setVoxel: async (input: { chunk: { x: number; y: number; z: number } }) => {
193
+ written.push(input);
194
+ return true;
195
+ },
196
+ };
197
+ const voxel = { voxelX: 1, voxelY: 0, voxelZ: 2, voxelType: 7 };
198
+ // The broker checks chunkX/Y/Z against the grid; a call must not steer the write elsewhere.
199
+ for (const args of [
200
+ { chunkX: 0, chunkY: 0, chunkZ: 0, chunk: { x: 999, y: 0, z: 0 }, ...voxel },
201
+ { chunkX: 0, chunkY: 0, chunkZ: 0, chunk: [999, 0, 0], ...voxel },
202
+ { chunkX: 0, chunkY: 0, chunkZ: 0, chunk_x: 999, ...voxel },
203
+ ]) {
204
+ await routeClientHostCall({ fn: 'voxel_set', args } as never, r.reads, grid, writes);
205
+ }
206
+ expect(written.map((w) => w.chunk)).toEqual([
207
+ { x: 0, y: 0, z: 0 },
208
+ { x: 0, y: 0, z: 0 },
209
+ { x: 0, y: 0, z: 0 },
210
+ ]);
211
+ // Named only the second way, the chunk is checked against the grid at the write.
212
+ for (const args of [
213
+ { chunk: { x: 999, y: 0, z: 0 }, ...voxel },
214
+ { chunk_x: 5, chunk_y: 0, chunk_z: 0, ...voxel },
215
+ { chunkX: 2, chunkY: 0, chunkZ: 0, ...voxel },
216
+ ]) {
217
+ expect(
218
+ await routeClientHostCall({ fn: 'voxel_set', args } as never, r.reads, grid, writes),
219
+ ).toEqual({ ok: false, error: 'out_of_grid' });
220
+ }
221
+ expect(written.length).toBe(3);
222
+ });
223
+
224
+ it('refuses voxel_set without the grid to confine it to', async () => {
225
+ const r = reads();
226
+ await expect(
227
+ routeClientHostCall(
228
+ { fn: 'voxel_set', args: { chunkX: 0, chunkY: 0, chunkZ: 0, voxelType: 1 } } as never,
229
+ r.reads,
230
+ undefined,
231
+ { setVoxel: async () => true },
232
+ ),
233
+ ).rejects.toBeInstanceOf(HostCallRefusedError);
234
+ });
235
+
188
236
  it('parses flattened chunkX host-call args the broker clamps', () => {
189
237
  expect(
190
238
  parseVoxelSetArgs({
@@ -110,9 +110,10 @@ export async function routeClientHostCall(
110
110
  : { voxels: voxelsFromBase64(chunk.voxelsBase64) };
111
111
  }
112
112
  case 'voxel_set': {
113
- if (!writes) throw new HostCallRefusedError(fn);
113
+ if (!writes || !grid) throw new HostCallRefusedError(fn);
114
114
  const parsed = parseVoxelSetArgs(args);
115
115
  if (!parsed) return { ok: false, error: 'invalid voxel_set arguments' };
116
+ if (!chunkInGrid(parsed.chunk, grid)) return { ok: false, error: 'out_of_grid' };
116
117
  const ok = await writes.setVoxel({
117
118
  chunk: parsed.chunk,
118
119
  x: parsed.x,
@@ -163,7 +164,11 @@ function asCoord3(value: unknown): { x: unknown; y: unknown; z: unknown } | null
163
164
  return null;
164
165
  }
165
166
 
166
- /** Accept the SDKs' flattened host-call JSON (the broker clamps its chunk) and the tuple shape. */
167
+ /**
168
+ * Accept the SDKs' flattened host-call JSON and the tuple shape. The chunk is `chunkX/Y/Z`, the
169
+ * fields the broker checks against the grid, whenever any of them is present; `args.chunk` only
170
+ * when none is. The caller still checks the chunk against the grid.
171
+ */
167
172
  export function parseVoxelSetArgs(args: Record<string, unknown>): {
168
173
  chunk: { x: number; y: number; z: number };
169
174
  x: number;
@@ -172,13 +177,15 @@ export function parseVoxelSetArgs(args: Record<string, unknown>): {
172
177
  voxelType: number;
173
178
  state: string;
174
179
  } | null {
175
- const chunkRaw =
176
- asCoord3(args.chunk) ??
177
- ({
178
- x: coordField(args, 'chunkX', 'chunk_x'),
179
- y: coordField(args, 'chunkY', 'chunk_y'),
180
- z: coordField(args, 'chunkZ', 'chunk_z'),
181
- } as { x: unknown; y: unknown; z: unknown });
180
+ const flat = ['chunkX', 'chunkY', 'chunkZ'].some((key) => args[key] !== undefined);
181
+ const chunkRaw = flat
182
+ ? { x: args.chunkX, y: args.chunkY, z: args.chunkZ }
183
+ : (asCoord3(args.chunk) ??
184
+ ({
185
+ x: coordField(args, 'chunk_x'),
186
+ y: coordField(args, 'chunk_y'),
187
+ z: coordField(args, 'chunk_z'),
188
+ } as { x: unknown; y: unknown; z: unknown }));
182
189
  const voxelRaw =
183
190
  asCoord3(args.voxel) ??
184
191
  ({
@@ -248,6 +255,23 @@ export async function routeWithFallback(
248
255
  }
249
256
  }
250
257
 
258
+ function chunkInGrid(
259
+ chunk: { x: number; y: number; z: number },
260
+ grid: PlayerCodeGridBounds,
261
+ ): boolean {
262
+ const x = BigInt(chunk.x);
263
+ const y = BigInt(chunk.y);
264
+ const z = BigInt(chunk.z);
265
+ return (
266
+ x >= grid.low.x &&
267
+ x <= grid.high.x &&
268
+ y >= grid.low.y &&
269
+ y <= grid.high.y &&
270
+ z >= grid.low.z &&
271
+ z <= grid.high.z
272
+ );
273
+ }
274
+
251
275
  function minBig(a: bigint, b: bigint): bigint {
252
276
  return a < b ? a : b;
253
277
  }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // The SDK barrel pulls Monaco, which does not load under happy-dom; the host-call module is
4
+ // plain, so the test drives the real one.
5
+ vi.mock('@crowdedkingdoms/crowdyjs', async () => {
6
+ const dist = '../../../../../node_modules/@crowdedkingdoms/crowdyjs/dist';
7
+ const calls = await import(/* @vite-ignore */ `${dist}/grid-mods/grid-host-calls.js`);
8
+ return { createGridHostCalls: calls.createGridHostCalls };
9
+ });
10
+
11
+ const { gridServerCalls } = await import('./gridServerCalls');
12
+
13
+ const BOUNDS = { low: { x: '5', y: '0', z: '5' }, high: { x: '5', y: '0', z: '5' } };
14
+
15
+ function game() {
16
+ const grid = vi.fn((appId: string, gridId: string, box: unknown) => ({
17
+ appId,
18
+ gridId,
19
+ bounds: box,
20
+ assertContains: () => {},
21
+ channels: { list: async () => [] },
22
+ }));
23
+ return { grid } as never;
24
+ }
25
+
26
+ const check = (userId: string, permissionKey: string) => ({
27
+ fn: 'grid_permission_check',
28
+ args: { userId, gridId: '42', permissionKey },
29
+ });
30
+
31
+ describe('gridServerCalls', () => {
32
+ it("answers grid_permission_check for the signed-in player from the grid's keys", async () => {
33
+ const calls = gridServerCalls({
34
+ game: game(),
35
+ appId: '1',
36
+ gridId: '42',
37
+ bounds: BOUNDS,
38
+ userId: '9',
39
+ permissionKeys: () => ['write_client_code', 'run_client_code'],
40
+ });
41
+ await expect(calls(check('9', 'run_client_code') as never)).resolves.toBe(true);
42
+ await expect(calls(check('9', 'write_server_code') as never)).resolves.toBe(false);
43
+ await expect(calls(check('8', 'run_client_code') as never)).rejects.toThrow(/another player/);
44
+ });
45
+
46
+ it('refuses it when no player is signed in', async () => {
47
+ const calls = gridServerCalls({
48
+ game: game(),
49
+ appId: '1',
50
+ gridId: '42',
51
+ bounds: BOUNDS,
52
+ userId: null,
53
+ permissionKeys: () => ['run_client_code'],
54
+ });
55
+ await expect(calls(check('9', 'run_client_code') as never)).rejects.toThrow();
56
+ });
57
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The CLIENT host calls a game's own router does not answer (DN-10): channels, sessions, user
3
+ * and avatar state, spatial sends and `grid_permission_check`, through CrowdyJS's
4
+ * `createGridHostCalls`, confined to one grid and answered as the player running the module.
5
+ * `grid_permission_check` needs both the player's user id and the keys the game holds for them
6
+ * on the grid; without either the SDK refuses it.
7
+ */
8
+ import {
9
+ createGridHostCalls,
10
+ type CrowdyClient,
11
+ type PlayerCodeHostCall,
12
+ } from '@crowdedkingdoms/crowdyjs';
13
+
14
+ import { toBrokerBounds, type GridBounds } from './permissions';
15
+
16
+ export interface GridServerCallsOptions {
17
+ game: CrowdyClient;
18
+ appId: string;
19
+ gridId: string;
20
+ bounds: GridBounds;
21
+ /** The signed-in player, the only one `grid_permission_check` answers about. */
22
+ userId: string | null | undefined;
23
+ /** The permission keys the game holds for that player on this grid. */
24
+ permissionKeys: () => Iterable<string>;
25
+ }
26
+
27
+ export function gridServerCalls(
28
+ options: GridServerCallsOptions,
29
+ ): (call: PlayerCodeHostCall) => Promise<unknown> {
30
+ const { game, appId, gridId, bounds, userId, permissionKeys } = options;
31
+ return createGridHostCalls({
32
+ scope: game.grid(appId, gridId, toBrokerBounds(bounds)),
33
+ client: game,
34
+ ...(userId ? { userId: String(userId) } : {}),
35
+ local: { gridPermissionKeys: permissionKeys },
36
+ });
37
+ }