@crowdedkingdoms/construct 0.2.2-dev.1 → 0.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
-
3
- vi.mock('@crowdedkingdoms/crowdyjs', () => ({ PlayerCodeBroker: class {} }));
1
+ import { describe, expect, it } from 'vitest';
4
2
 
5
3
  const {
6
4
  DEFAULT_VOXEL_STATE,
@@ -56,7 +54,7 @@ describe('routeClientHostCall', () => {
56
54
  expect(out).toEqual({ actors: [{ uuid: 'me', name: 'neo' }] });
57
55
  });
58
56
 
59
- it('clamps actors_list_radius to the grid and to radius 8', async () => {
57
+ it('clamps actors_list_radius to the grid', async () => {
60
58
  const r = reads();
61
59
  await routeClientHostCall(
62
60
  { fn: 'actors_list_radius', args: { x: '0', y: '0', z: '0', radius: 50 } } as never,
@@ -68,6 +66,23 @@ describe('routeClientHostCall', () => {
68
66
  expect(r.calls.every((c) => c.split(',').every((v) => Math.abs(Number(v)) <= 1))).toBe(true);
69
67
  });
70
68
 
69
+ it('reads crowdy-client-sdk radii and clamps them as the platform does (3 across, 1 up)', async () => {
70
+ const r = reads();
71
+ const wide = { low: { x: -9n, y: -9n, z: -9n }, high: { x: 9n, y: 9n, z: 9n } };
72
+ await routeClientHostCall(
73
+ {
74
+ fn: 'actors_list_radius',
75
+ args: { x: '0', y: '0', z: '0', radiusXz: 9, radiusY: 4 },
76
+ } as never,
77
+ r.reads,
78
+ wide,
79
+ );
80
+ expect(r.calls.length).toBe(7 * 3 * 7);
81
+ const ys = new Set(r.calls.map((c) => Number(c.split(',')[1])));
82
+ expect([...ys].sort()).toEqual([-1, 0, 1]);
83
+ expect(r.calls.every((c) => Math.abs(Number(c.split(',')[0])) <= 3)).toBe(true);
84
+ });
85
+
71
86
  it('serves chunk_get as base64 and voxels_list as sparse rows', async () => {
72
87
  const r = reads();
73
88
  const dense = (await routeClientHostCall(
@@ -192,6 +207,28 @@ describe('routeClientHostCall', () => {
192
207
  });
193
208
  });
194
209
 
210
+ it('parses the voxel_set arguments crowdy-client-sdk sends', () => {
211
+ expect(
212
+ parseVoxelSetArgs({
213
+ chunkX: 3,
214
+ chunkY: 0,
215
+ chunkZ: 3,
216
+ voxelX: 8,
217
+ voxelY: 1,
218
+ voxelZ: 4,
219
+ voxelType: 2,
220
+ stateBase64: null,
221
+ }),
222
+ ).toEqual({
223
+ chunk: { x: 3, y: 0, z: 3 },
224
+ x: 8,
225
+ y: 1,
226
+ z: 4,
227
+ voxelType: 2,
228
+ state: DEFAULT_VOXEL_STATE,
229
+ });
230
+ });
231
+
195
232
  it('decodes an empty grid to no rows', () => {
196
233
  expect(voxelsFromBase64(null)).toEqual([]);
197
234
  expect(voxelsFromBase64(bytesToBase64(new Uint8Array(4096)))).toEqual([]);
@@ -1,25 +1,17 @@
1
1
  /**
2
- * The host side of CLIENT mods: what a sandboxed player module may ask the
3
- * game for, and how a consented grid-attached mod is started for a visitor.
2
+ * The host side of CLIENT mods: what a sandboxed player module (the Studio's
3
+ * preview, or a grid's CLIENT half, `clientHalves.ts`) may ask the game for.
4
4
  *
5
5
  * The SDK's `PlayerCodeBroker` has already validated every call against the
6
6
  * platform allowlist, clamped chunk coordinates to the mod's grid AABB, rate-
7
7
  * limited it, and answered `grid_info` / `hud_set` / `overlay_draw` itself.
8
8
  * What reaches `routeClientHostCall` is world_read, `voxel_set`, and
9
- * `pointer_clicks`. Reads return only what the running player can already
10
- * lawfully see. Writes go through ChunkStore.setVoxel (optimistic + UDP) and
11
- * markDirty (durable write-back), the same route Paint uses. Never hand a mod
12
- * the raw client.
9
+ * `pointer_clicks`, in the argument shapes `crowdy-client-sdk` sends. Reads
10
+ * return only what the running player can already lawfully see. Writes go
11
+ * through ChunkStore.setVoxel (optimistic + UDP) and markDirty (durable
12
+ * write-back), the same route Paint uses. Never hand a mod the raw client.
13
13
  */
14
- import {
15
- PlayerCodeBroker,
16
- type CrowdyClient,
17
- type PlayerCodeGridBounds,
18
- type PlayerCodeHostCall,
19
- type PlayerCodePresentation,
20
- } from '@crowdedkingdoms/crowdyjs';
21
- import type { CrowdyStudioTextHud } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
22
- import type { ModOverlayStore } from './modOverlay';
14
+ import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
23
15
 
24
16
  export interface ClientModHostReads {
25
17
  actorsInChunk(x: bigint, y: bigint, z: bigint): Array<Record<string, unknown>>;
@@ -54,12 +46,19 @@ export const OFFERED_HOST_CALLS = [
54
46
  /** One zero byte, base64: the smallest non-empty voxel state the API accepts. */
55
47
  export const DEFAULT_VOXEL_STATE = 'AA==';
56
48
 
57
- const MAX_RADIUS = 8;
49
+ /** `actors_list_radius` reaches this far, as the platform clamps it. */
50
+ const MAX_RADIUS_XZ = 3;
51
+ const MAX_RADIUS_Y = 1;
58
52
 
59
53
  function toBigInt(value: unknown): bigint {
60
54
  return BigInt(value as string | number | bigint);
61
55
  }
62
56
 
57
+ function radius(value: unknown, max: number): bigint {
58
+ const requested = Number(value ?? 0);
59
+ return BigInt(Number.isFinite(requested) ? Math.max(0, Math.min(max, Math.floor(requested))) : 0);
60
+ }
61
+
63
62
  export class HostCallRefusedError extends Error {
64
63
  constructor(readonly fn: string) {
65
64
  super(`host call '${fn}' is not offered by this game`);
@@ -80,22 +79,20 @@ export async function routeClientHostCall(
80
79
  return { actors: reads.actorsInChunk(toBigInt(args.x), toBigInt(args.y), toBigInt(args.z)) };
81
80
  case 'actors_list_radius': {
82
81
  const origin = { x: toBigInt(args.x), y: toBigInt(args.y), z: toBigInt(args.z) };
83
- const requested = Number(args.radius ?? args.r ?? 0);
84
- const radius = BigInt(
85
- Number.isFinite(requested) ? Math.max(0, Math.min(MAX_RADIUS, Math.floor(requested))) : 0,
86
- );
82
+ const rXz = radius(args.radiusXz ?? args.radius ?? args.r, MAX_RADIUS_XZ);
83
+ const rY = radius(args.radiusY, MAX_RADIUS_Y);
87
84
  const low = grid
88
85
  ? {
89
- x: maxBig(grid.low.x, origin.x - radius),
90
- y: maxBig(grid.low.y, origin.y - radius),
91
- z: maxBig(grid.low.z, origin.z - radius),
86
+ x: maxBig(grid.low.x, origin.x - rXz),
87
+ y: maxBig(grid.low.y, origin.y - rY),
88
+ z: maxBig(grid.low.z, origin.z - rXz),
92
89
  }
93
90
  : origin;
94
91
  const high = grid
95
92
  ? {
96
- x: minBig(grid.high.x, origin.x + radius),
97
- y: minBig(grid.high.y, origin.y + radius),
98
- z: minBig(grid.high.z, origin.z + radius),
93
+ x: minBig(grid.high.x, origin.x + rXz),
94
+ y: minBig(grid.high.y, origin.y + rY),
95
+ z: minBig(grid.high.z, origin.z + rXz),
99
96
  }
100
97
  : origin;
101
98
  const actors: Array<Record<string, unknown>> = [];
@@ -166,7 +163,7 @@ function asCoord3(value: unknown): { x: unknown; y: unknown; z: unknown } | null
166
163
  return null;
167
164
  }
168
165
 
169
- /** Accept the SDK tuple shape and the flattened host-call JSON the broker clamps. */
166
+ /** Accept the SDKs' flattened host-call JSON (the broker clamps its chunk) and the tuple shape. */
170
167
  export function parseVoxelSetArgs(args: Record<string, unknown>): {
171
168
  chunk: { x: number; y: number; z: number };
172
169
  x: number;
@@ -185,9 +182,9 @@ export function parseVoxelSetArgs(args: Record<string, unknown>): {
185
182
  const voxelRaw =
186
183
  asCoord3(args.voxel) ??
187
184
  ({
188
- x: coordField(args, 'x', 'vx'),
189
- y: coordField(args, 'y', 'vy'),
190
- z: coordField(args, 'z', 'vz'),
185
+ x: coordField(args, 'voxelX', 'x', 'vx'),
186
+ y: coordField(args, 'voxelY', 'y', 'vy'),
187
+ z: coordField(args, 'voxelZ', 'z', 'vz'),
191
188
  } as { x: unknown; y: unknown; z: unknown });
192
189
  const cx = intInRange(chunkRaw.x, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
193
190
  const cy = intInRange(chunkRaw.y, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
@@ -234,115 +231,6 @@ export function bytesToBase64(bytes: Uint8Array): string {
234
231
  return btoa(binary);
235
232
  }
236
233
 
237
- interface CachedArtifact {
238
- bytes: Uint8Array;
239
- artifactHash?: string;
240
- fuelPerDispatch?: string | bigint;
241
- }
242
-
243
- const artifactCache = new Map<string, CachedArtifact>();
244
-
245
- /**
246
- * Fetch a consented grid-attached client mod's artifact and run it in a
247
- * hash-verified broker with the same allowlist, grid clamp, rate caps and
248
- * circuit breaker as the Studio's own Test/Run path. Its HUD output lands in
249
- * the persistent text HUD. Returns null (fails closed) when the artifact
250
- * cannot be fetched.
251
- */
252
- export async function runConsentedGridMod(options: {
253
- client: CrowdyClient;
254
- appId: string;
255
- attachmentId: string;
256
- artifactCacheKey: string;
257
- hudSource: string;
258
- hudLabel: string;
259
- /** The module's name, which addressed grid events target (default `hudLabel`). */
260
- moduleName?: string;
261
- grid: PlayerCodeGridBounds;
262
- workerUrl: string;
263
- reads: ClientModHostReads;
264
- writes?: ClientModHostWrites;
265
- hud: CrowdyStudioTextHud;
266
- overlay?: ModOverlayStore;
267
- input?: ClientModHostInput;
268
- /**
269
- * Answers the host calls this game does not route itself (DN-10: the rest
270
- * of the client catalog, through CrowdyJS `createGridHostCalls`).
271
- */
272
- serverCalls?: (call: PlayerCodeHostCall) => Promise<unknown>;
273
- /**
274
- * How often the worker self-drives `on_tick`. Omitted/0 means invoke-only
275
- * and a HUD mod then never runs (measured 2026-09-07: broker started, HUD
276
- * stayed empty). Studio Test/Deploy reads `[package.metadata.crowdy]
277
- * tick_interval_ms` from the CLIENT Cargo.toml (default 1000, clamped
278
- * 16–1000). Pass this only for grid-attached visitors until that field is
279
- * stored on the compiled version.
280
- */
281
- tickIntervalMs?: number;
282
- }): Promise<{ stop: () => void } | null> {
283
- let fetched: { bytes: ArrayBuffer; artifactHash?: string; fuelPerDispatch?: string | bigint };
284
- const cached = artifactCache.get(options.artifactCacheKey);
285
- if (cached) {
286
- fetched = {
287
- bytes: cached.bytes.slice().buffer,
288
- artifactHash: cached.artifactHash,
289
- fuelPerDispatch: cached.fuelPerDispatch,
290
- };
291
- } else {
292
- try {
293
- fetched = await options.client.marketplace.clientArtifactBytes({
294
- appId: options.appId,
295
- attachmentId: options.attachmentId,
296
- });
297
- } catch {
298
- return null;
299
- }
300
- artifactCache.set(options.artifactCacheKey, {
301
- bytes: new Uint8Array(fetched.bytes).slice(),
302
- artifactHash: fetched.artifactHash,
303
- fuelPerDispatch: fetched.fuelPerDispatch,
304
- });
305
- }
306
- const broker = new PlayerCodeBroker({
307
- workerUrl: options.workerUrl,
308
- grid: options.grid,
309
- artifactHash: fetched.artifactHash,
310
- fuelPerDispatch: fetched.fuelPerDispatch != null ? BigInt(fetched.fuelPerDispatch) : undefined,
311
- tickIntervalMs: options.tickIntervalMs ?? 1000,
312
- moduleName: options.moduleName ?? options.hudLabel,
313
- onHostCall: (call) =>
314
- routeWithFallback(
315
- call,
316
- () => routeClientHostCall(call, options.reads, options.grid, options.writes, options.input),
317
- options.serverCalls,
318
- ),
319
- onPresentation: (presentation: PlayerCodePresentation) => {
320
- if (presentation.channel === 'hud') {
321
- options.hud.set({
322
- source: options.hudSource,
323
- label: options.hudLabel,
324
- payload: presentation.payload,
325
- });
326
- }
327
- if (presentation.channel === 'overlay') {
328
- options.overlay?.apply(options.hudSource, presentation.payload, options.grid);
329
- }
330
- },
331
- onCircuitOpen: () => {
332
- options.hud.remove(options.hudSource);
333
- options.overlay?.remove(options.hudSource);
334
- },
335
- });
336
- await broker.start(fetched.bytes);
337
- return {
338
- stop: () => {
339
- broker.stop();
340
- options.hud.remove(options.hudSource);
341
- options.overlay?.remove(options.hudSource);
342
- },
343
- };
344
- }
345
-
346
234
  /**
347
235
  * The game's own router first (local reads, optimistic writes); anything it
348
236
  * does not offer goes to `serverCalls` when the game supplied it.
@@ -1,56 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
-
3
- import { ClientModLifecycle } from './ClientModLifecycle';
4
-
5
- const d = (id: string, artifactHash = 'a', capabilityHash = 'c') => ({
6
- attachmentId: id,
7
- artifactHash,
8
- capabilityHash,
9
- });
10
-
11
- describe('ClientModLifecycle', () => {
12
- it('stops every worker when the grid changes', () => {
13
- const life = new ClientModLifecycle();
14
- expect(life.enterGrid('g1')).toBe(true);
15
- const scope = life.scope('g1')!;
16
- const stop = vi.fn();
17
- expect(life.track(scope, d('m1'), { stop })).toBe(true);
18
- expect(life.enterGrid('g1')).toBe(false);
19
- expect(life.enterGrid('g2')).toBe(true);
20
- expect(stop).toHaveBeenCalledTimes(1);
21
- expect(life.runningCount).toBe(0);
22
- });
23
-
24
- it('rejects a worker that finished starting after the grid changed', () => {
25
- const life = new ClientModLifecycle();
26
- life.enterGrid('g1');
27
- const stale = life.scope('g1')!;
28
- life.enterGrid('g2');
29
- const stop = vi.fn();
30
- expect(life.track(stale, d('m1'), { stop })).toBe(false);
31
- expect(stop).toHaveBeenCalled();
32
- });
33
-
34
- it('reconciles by immutable hashes', () => {
35
- const life = new ClientModLifecycle();
36
- life.enterGrid('g1');
37
- const scope = life.scope('g1')!;
38
- const stopA = vi.fn();
39
- const stopB = vi.fn();
40
- life.track(scope, d('a'), { stop: stopA });
41
- life.track(scope, d('b'), { stop: stopB });
42
- expect(life.reconcile(scope, [d('a'), d('b', 'a2')])).toBe(true);
43
- expect(stopA).not.toHaveBeenCalled();
44
- expect(stopB).toHaveBeenCalled();
45
- expect(life.has('a')).toBe(true);
46
- expect(life.has('b')).toBe(false);
47
- });
48
-
49
- it('is inert after shutdown', () => {
50
- const life = new ClientModLifecycle();
51
- life.enterGrid('g1');
52
- life.shutdown();
53
- expect(life.enterGrid('g2')).toBe(false);
54
- expect(life.scope('g2')).toBeNull();
55
- });
56
- });
@@ -1,102 +0,0 @@
1
- /**
2
- * Owns the worker handles of grid-attached CLIENT mods independently of any
3
- * scene. Every metadata refresh reconciles immutable hashes; changing grids
4
- * synchronously tears everything down; a worker that finishes starting after
5
- * a grid change (or shutdown) is stopped instead of attached stale.
6
- */
7
- export interface ClientModDescriptor {
8
- attachmentId: string;
9
- artifactHash: string;
10
- capabilityHash: string;
11
- }
12
-
13
- export interface ClientModHandle {
14
- stop: () => void;
15
- }
16
-
17
- export interface ClientModScope {
18
- gridId: string;
19
- generation: number;
20
- }
21
-
22
- interface RunningClientMod extends ClientModDescriptor, ClientModHandle {}
23
-
24
- export class ClientModLifecycle {
25
- private readonly running = new Map<string, RunningClientMod>();
26
- private activeGridId: string | null = null;
27
- private generation = 0;
28
- private stopped = false;
29
-
30
- /** Returns true when the grid actually changed (callers reset their caches). */
31
- enterGrid(gridId: string | null): boolean {
32
- if (this.stopped) return false;
33
- if (gridId === this.activeGridId) return false;
34
- this.stopAll();
35
- this.activeGridId = gridId;
36
- this.generation++;
37
- return true;
38
- }
39
-
40
- get currentGridId(): string | null {
41
- return this.activeGridId;
42
- }
43
-
44
- has(attachmentId: string): boolean {
45
- return this.running.has(attachmentId);
46
- }
47
-
48
- get runningCount(): number {
49
- return this.running.size;
50
- }
51
-
52
- scope(gridId: string): ClientModScope | null {
53
- if (this.stopped || gridId !== this.activeGridId) return null;
54
- return { gridId, generation: this.generation };
55
- }
56
-
57
- isCurrent(scope: ClientModScope): boolean {
58
- return (
59
- !this.stopped && scope.gridId === this.activeGridId && scope.generation === this.generation
60
- );
61
- }
62
-
63
- track(scope: ClientModScope, descriptor: ClientModDescriptor, handle: ClientModHandle): boolean {
64
- if (!this.isCurrent(scope) || this.running.has(descriptor.attachmentId)) {
65
- handle.stop();
66
- return false;
67
- }
68
- this.running.set(descriptor.attachmentId, { ...descriptor, ...handle });
69
- return true;
70
- }
71
-
72
- /** Stop mods that disappeared or changed hash; returns false if scope is stale. */
73
- reconcile(scope: ClientModScope, descriptors: readonly ClientModDescriptor[]): boolean {
74
- if (!this.isCurrent(scope)) return false;
75
- const current = new Map(descriptors.map((d) => [d.attachmentId, d]));
76
- for (const [attachmentId, running] of this.running) {
77
- const next = current.get(attachmentId);
78
- if (
79
- !next ||
80
- next.artifactHash !== running.artifactHash ||
81
- next.capabilityHash !== running.capabilityHash
82
- ) {
83
- running.stop();
84
- this.running.delete(attachmentId);
85
- }
86
- }
87
- return true;
88
- }
89
-
90
- private stopAll(): void {
91
- for (const running of this.running.values()) running.stop();
92
- this.running.clear();
93
- }
94
-
95
- shutdown(): void {
96
- if (this.stopped) return;
97
- this.stopped = true;
98
- this.activeGridId = null;
99
- this.generation++;
100
- this.stopAll();
101
- }
102
- }