@crowdedkingdoms/construct 0.3.7 → 0.3.8

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.
@@ -52,13 +52,23 @@ import {
52
52
  DEFAULT_VOXEL_STATE,
53
53
  routeClientHostCall,
54
54
  routeWithFallback,
55
+ voxelStateToWire,
55
56
  type ClientModHostReads,
56
57
  type ClientModHostWrites,
57
58
  } from './clientModHost';
58
59
  import { PointerClickBuffer } from './pointerClicks';
60
+ import { clearModPose, setModPoseGrid } from './modPose';
61
+ import type { Input } from '../../engine/Input';
59
62
  import { ModOverlayStore } from './modOverlay';
63
+ import { bindModGrid, bindModSession, releaseModChunk } from './modChunkRuntime';
64
+ import { ModSceneStore } from './modScene';
60
65
  import { GridService, type GridSnapshot } from './GridService';
61
- import { hasAnyStudioPermission, toBrokerBounds, type GridBounds } from './permissions';
66
+ import {
67
+ hasAnyStudioPermission,
68
+ studioPermissions,
69
+ toBrokerBounds,
70
+ type GridBounds,
71
+ } from './permissions';
62
72
  import { Emitter } from '../util/Emitter';
63
73
 
64
74
  export interface StudioState {
@@ -68,6 +78,8 @@ export interface StudioState {
68
78
  clientModsAvailable: boolean;
69
79
  /** The grid's CLIENT halves running for this player. */
70
80
  clientModsRunning: number;
81
+ /** The grid's client mods have finished their first fetch. Scene parts may still be arriving. */
82
+ clientModsReady: boolean;
71
83
  /** Those CLIENT halves, for the controls that take them back. */
72
84
  clientHalves: readonly RunningClientHalf[];
73
85
  /** Why CLIENT mods are unavailable, when they are. */
@@ -120,8 +132,19 @@ function createConstructTextHud(): CrowdyStudioTextHud {
120
132
  describe(payload: unknown) {
121
133
  if (typeof payload === 'string') return payload;
122
134
  if (payload && typeof payload === 'object') {
123
- const greeting = (payload as { greeting?: unknown }).greeting;
124
- if (typeof greeting === 'string' && greeting.length > 0) return greeting;
135
+ const rec = payload as {
136
+ greeting?: unknown;
137
+ turn?: unknown;
138
+ scores?: unknown;
139
+ state?: unknown;
140
+ who?: unknown;
141
+ };
142
+ const lines: string[] = [];
143
+ for (const key of ['greeting', 'turn', 'scores', 'state', 'who'] as const) {
144
+ const value = rec[key];
145
+ if (typeof value === 'string' && value.length > 0) lines.push(value);
146
+ }
147
+ if (lines.length > 0) return lines.join('\n');
125
148
  }
126
149
  try {
127
150
  return JSON.stringify(payload);
@@ -150,12 +173,15 @@ export class StudioService {
150
173
  readonly locomotion = new AgentLocomotion();
151
174
  /** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
152
175
  readonly overlay = new ModOverlayStore();
176
+ /** CLIENT scene_catalog / scene_instances. Templates are cloned in the page. */
177
+ readonly scene = new ModSceneStore();
153
178
  private readonly hud = createConstructTextHud();
154
179
  private readonly agentHost: ConstructPlayerHostAdapter;
155
180
  private readonly humanInput = new HumanInputMonitor();
156
181
  private readonly pointerClicks = new PointerClickBuffer();
157
182
  /** JS grid programs the agent (or the player) runs on the current grid. */
158
183
  readonly programs: GridProgramRunner;
184
+ private gameplay: Input | null = null;
159
185
  private halves: GridClientHalves | null = null;
160
186
  private gridEnteredAt = 0;
161
187
  private embed: CrowdyStudioEmbed | null = null;
@@ -166,6 +192,12 @@ export class StudioService {
166
192
  private lastGridReadAt = 0;
167
193
  private lastModsReadAt = 0;
168
194
  private timer: ReturnType<typeof setInterval> | null = null;
195
+ /** Last GraphQL-persisted mailbox payload per cell, so roster ticks do not hammer updateVoxel. */
196
+ private lastDurableVoxel = new Map<string, string>();
197
+ private voxelUpdateCache = new Map<
198
+ string,
199
+ { at: number; states: Array<{ x: number; y: number; z: number; state: string }> }
200
+ >();
169
201
  /** The grid the open Studio edits, and the mod its project runs as. */
170
202
  private studioGridId: string | null = null;
171
203
  private studioMod: string | null = null;
@@ -192,6 +224,7 @@ export class StudioService {
192
224
  grid: null,
193
225
  clientModsAvailable: CLIENT_MODS_ENABLED && isolated,
194
226
  clientModsRunning: 0,
227
+ clientModsReady: false,
195
228
  clientHalves: [],
196
229
  clientModsReason: !CLIENT_MODS_ENABLED
197
230
  ? 'CLIENT mods are disabled in this build (VITE_CONSTRUCT_CLIENT_MODS=0).'
@@ -210,6 +243,52 @@ export class StudioService {
210
243
  return this.currentGrid;
211
244
  }
212
245
 
246
+ /**
247
+ * True while the avatar is at a claim wall waiting for its mod. Poll must
248
+ * not drop that grid just because the body is still standing outside it.
249
+ */
250
+ private approachHold = false;
251
+
252
+ /** Begin loading an owned claim while the avatar is still outside it. */
253
+ approachOwned(gridId: string): void {
254
+ const record = this.grids.ownedGrids().find((grid) => grid.gridId === gridId);
255
+ if (!record) return;
256
+ this.approach({
257
+ gridId: record.gridId,
258
+ bounds: record.bounds,
259
+ permissions: studioPermissions(record.effectiveKeys),
260
+ owned: true,
261
+ });
262
+ }
263
+
264
+ /** Begin loading `grid` while the avatar is still outside it. */
265
+ approach(grid: GridSnapshot): void {
266
+ this.approachHold = true;
267
+ if (this.currentGrid?.gridId === grid.gridId) return;
268
+ const low = grid.bounds.low;
269
+ this.adoptGrid(grid, { x: Number(low.x), y: Number(low.y), z: Number(low.z) });
270
+ }
271
+
272
+ /** The avatar left the approach margin; the next poll may drop the grid. */
273
+ releaseApproach(): void {
274
+ this.approachHold = false;
275
+ }
276
+
277
+ /** Gameplay keys and stick for generic client-mod input calls. */
278
+ bindGameplayInput(input: Input): void {
279
+ this.gameplay = input;
280
+ }
281
+
282
+ private hostInput() {
283
+ return {
284
+ drainPointerClicks: () => this.pointerClicks.drainPointerClicks(),
285
+ axes: () =>
286
+ this.gameplay && !this.gameplay.suppressed ? this.gameplay.axes() : { x: 0, y: 0 },
287
+ look: () => this.gameplay?.takePointerDelta() ?? { dx: 0, dy: 0 },
288
+ keyDown: (code: string) => this.gameplay?.isDown(code) ?? false,
289
+ };
290
+ }
291
+
213
292
  /** Wire the engine-side hooks; call once after the loop exists. */
214
293
  attach(hooks: StudioHooks): void {
215
294
  this.hooks = hooks;
@@ -277,6 +356,7 @@ export class StudioService {
277
356
  if (this.currentGrid) {
278
357
  const source = `studio:${this.currentGrid.gridId}`;
279
358
  this.overlay.remove(source);
359
+ this.scene.remove(source);
280
360
  this.hud.remove(source);
281
361
  }
282
362
  this.studioGridId = null;
@@ -376,6 +456,7 @@ export class StudioService {
376
456
  if (this.currentGrid) {
377
457
  const source = `studio:${this.currentGrid.gridId}`;
378
458
  this.overlay.remove(source);
459
+ this.scene.remove(source);
379
460
  this.hud.remove(source);
380
461
  }
381
462
  }
@@ -394,6 +475,8 @@ export class StudioService {
394
475
  this.pointerClicks.dispose();
395
476
  this.hud.destroy();
396
477
  this.overlay.clear();
478
+ releaseModChunk();
479
+ this.scene.clear();
397
480
  }
398
481
 
399
482
  // ---------------------------------------------------------------------------
@@ -428,12 +511,18 @@ export class StudioService {
428
511
  ...(clientOk
429
512
  ? {
430
513
  workerUrl: glueWorkerAssetUrl,
431
- onHostCall: (call: PlayerCodeHostCall) =>
432
- routeWithFallback(
514
+ onHostCall: (call: PlayerCodeHostCall) => {
515
+ setModPoseGrid(bounds);
516
+ return routeWithFallback(
433
517
  call,
434
- () => routeClientHostCall(call, reads, bounds, writes, this.pointerClicks),
518
+ () =>
519
+ routeClientHostCall(call, reads, bounds, writes, this.hostInput(), {
520
+ source: overlaySource,
521
+ store: this.scene,
522
+ }),
435
523
  serverCalls,
436
- ),
524
+ );
525
+ },
437
526
  onPresentation: (presentation) => {
438
527
  if (presentation.channel === 'hud') {
439
528
  this.hud.set({
@@ -490,7 +579,8 @@ export class StudioService {
490
579
  selfUserId: () => this.session.network.user?.userId ?? null,
491
580
  reads: this.reads(),
492
581
  writes: this.writes(),
493
- input: this.pointerClicks,
582
+ input: this.hostInput(),
583
+ scene: this.scene,
494
584
  serverCalls: (grid) =>
495
585
  this.serverCallsFor(grid.gridId, {
496
586
  low: stringCorner(grid.bounds.low),
@@ -604,6 +694,8 @@ export class StudioService {
604
694
  x: s.x,
605
695
  y: s.y,
606
696
  z: s.z,
697
+ yaw: s.yaw,
698
+ pitch: s.pitch,
607
699
  program: s.program,
608
700
  });
609
701
  }
@@ -615,16 +707,39 @@ export class StudioService {
615
707
  x: p.pose.x,
616
708
  y: p.pose.y,
617
709
  z: p.pose.z,
710
+ yaw: p.pose.yaw,
711
+ pitch: p.pose.pitch,
618
712
  program: p.pose.program,
619
713
  });
620
714
  }
621
715
  }
622
716
  return rows;
623
717
  },
624
- chunkVoxels: (x, y, z) => {
718
+ chunkVoxels: async (x, y, z) => {
625
719
  const cached = this.session.world.chunks.get({ x: Number(x), y: Number(y), z: Number(z) });
626
- if (!cached?.voxels) return { voxelsBase64: null };
627
- return { voxelsBase64: bytesToBase64(cached.voxels) };
720
+ const states: Array<{ x: number; y: number; z: number; state: string }> = [];
721
+ if (cached) {
722
+ for (const [index, state] of cached.voxelStates) {
723
+ if (typeof state !== 'string' || state.length === 0) continue;
724
+ states.push({
725
+ x: index & 15,
726
+ y: (index >> 4) & 15,
727
+ z: (index >> 8) & 15,
728
+ state,
729
+ });
730
+ }
731
+ }
732
+ const durable = await this.listDurableVoxelStates(Number(x), Number(y), Number(z));
733
+ for (const extra of durable) {
734
+ const row = states.find((s) => s.x === extra.x && s.y === extra.y && s.z === extra.z);
735
+ if (row) row.state = extra.state;
736
+ else states.push(extra);
737
+ }
738
+ if (!cached?.voxels && states.length === 0) return { voxelsBase64: null };
739
+ return {
740
+ voxelsBase64: bytesToBase64(cached?.voxels ?? new Uint8Array(4096)),
741
+ states,
742
+ };
628
743
  },
629
744
  };
630
745
  }
@@ -641,12 +756,90 @@ export class StudioService {
641
756
  voxelType: input.voxelType,
642
757
  state: input.state ?? DEFAULT_VOXEL_STATE,
643
758
  });
644
- if (ok) chunks.markDirty(input.chunk);
759
+ if (ok) {
760
+ chunks.markDirty(input.chunk);
761
+ void this.persistVoxelUpdate(input);
762
+ }
645
763
  return ok;
646
764
  },
647
765
  };
648
766
  }
649
767
 
768
+ /**
769
+ * Draft SERVER voxel_set writes Postgres but suppresses Buddy fan-out, so the
770
+ * live chunk cache never sees BOARD/SHOT mailboxes. Pull those states from
771
+ * listVoxels (cached ~250ms) so the author's CLIENT can read them.
772
+ */
773
+ private async listDurableVoxelStates(
774
+ x: number,
775
+ y: number,
776
+ z: number,
777
+ ): Promise<Array<{ x: number; y: number; z: number; state: string }>> {
778
+ const game = this.session.network.game;
779
+ if (!game?.voxels?.list) return [];
780
+ const key = `${x},${y},${z}`;
781
+ const hit = this.voxelUpdateCache.get(key);
782
+ const now = Date.now();
783
+ if (hit && now - hit.at < 250) return hit.states;
784
+ try {
785
+ const rows = await game.voxels.list({
786
+ appId: this.session.appId,
787
+ coordinates: { x: String(x), y: String(y), z: String(z) },
788
+ });
789
+ const states: Array<{ x: number; y: number; z: number; state: string }> = [];
790
+ for (const row of rows ?? []) {
791
+ const loc = row.location;
792
+ if (!loc) continue;
793
+ const raw = row.state;
794
+ if (typeof raw !== 'string' || raw.length === 0) continue;
795
+ let decoded = raw;
796
+ try {
797
+ decoded = new TextDecoder().decode(Uint8Array.from(atob(raw), (c) => c.charCodeAt(0)));
798
+ } catch {
799
+ decoded = raw;
800
+ }
801
+ states.push({ x: loc.x, y: loc.y, z: loc.z, state: decoded });
802
+ }
803
+ this.voxelUpdateCache.set(key, { at: now, states });
804
+ return states;
805
+ } catch (error) {
806
+ this.session.network.log(`voxel list failed: ${messageOf(error)}`);
807
+ return hit?.states ?? [];
808
+ }
809
+ }
810
+ private async persistVoxelUpdate(input: {
811
+ chunk: { x: number; y: number; z: number };
812
+ x: number;
813
+ y: number;
814
+ z: number;
815
+ voxelType: number;
816
+ state?: string;
817
+ }): Promise<void> {
818
+ const game = this.session.network.game;
819
+ if (!game?.voxels?.update) return;
820
+ const wire = voxelStateToWire(input.state ?? DEFAULT_VOXEL_STATE);
821
+ const cell = `${input.chunk.x},${input.chunk.y},${input.chunk.z}:${input.x},${input.y},${input.z}`;
822
+ const payload = `${input.voxelType}:${wire}`;
823
+ if (this.lastDurableVoxel.get(cell) === payload) return;
824
+ this.lastDurableVoxel.set(cell, payload);
825
+ try {
826
+ await game.voxels.update({
827
+ appId: this.session.appId,
828
+ coordinates: {
829
+ x: String(input.chunk.x),
830
+ y: String(input.chunk.y),
831
+ z: String(input.chunk.z),
832
+ },
833
+ location: { x: input.x, y: input.y, z: input.z },
834
+ voxelType: input.voxelType,
835
+ state: wire,
836
+ });
837
+ } catch (error) {
838
+ this.lastDurableVoxel.delete(cell);
839
+ this.session.network.log(`voxel persist failed: ${messageOf(error)}`);
840
+ }
841
+ }
842
+
650
843
  private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
651
844
  const entered = (grid?.gridId ?? null) !== (this.currentGrid?.gridId ?? null);
652
845
  this.currentGrid = grid;
@@ -656,13 +849,21 @@ export class StudioService {
656
849
  this.lastModsReadAt = 0;
657
850
  this.gridEnteredAt = Date.now();
658
851
  this.overlay.clear();
852
+ this.scene.clear();
853
+ clearModPose();
854
+ if (grid) this.bindLiveMod(grid);
855
+ else releaseModChunk();
659
856
  if (this.state.clientModsAvailable) {
660
857
  this.clientHalves().enterGrid(
661
858
  grid ? { gridId: grid.gridId, bounds: toBrokerBounds(grid.bounds) } : null,
662
859
  );
663
860
  }
664
861
  }
665
- this.state = { ...this.state, grid };
862
+ this.state = {
863
+ ...this.state,
864
+ grid,
865
+ clientModsReady: entered ? false : this.state.clientModsReady,
866
+ };
666
867
  this.emit();
667
868
  }
668
869
 
@@ -690,7 +891,12 @@ export class StudioService {
690
891
  if (key !== this.lastGridChunkKey || now - this.lastGridReadAt > GRID_REFRESH_MS) {
691
892
  try {
692
893
  const grid = await this.grids.lookup(chunk);
693
- this.adoptGrid(grid, chunk);
894
+ if (!grid && this.approachHold && this.currentGrid) {
895
+ this.lastGridChunkKey = key;
896
+ this.lastGridReadAt = now;
897
+ } else {
898
+ this.adoptGrid(grid, chunk);
899
+ }
694
900
  } catch (error) {
695
901
  this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
696
902
  }
@@ -703,6 +909,29 @@ export class StudioService {
703
909
  * the rest, stop what changed or went. Waits out the presence race after
704
910
  * entering a grid.
705
911
  */
912
+ /** The live session gameplay calls (pose, events, voice) read from. */
913
+ private bindLiveMod(grid: GridSnapshot): void {
914
+ const bounds = toBrokerBounds(grid.bounds, grid.gridId);
915
+ bindModGrid(bounds);
916
+ bindModSession({
917
+ client: this.session.network.game,
918
+ appId: this.session.appId,
919
+ gridId: grid.gridId,
920
+ selfUuid: this.session.selfUuid,
921
+ userId: this.session.userId,
922
+ moveTo: (chunk) => this.session.moveTo(chunk),
923
+ players: () =>
924
+ this.session.players().map((player) => ({
925
+ uuid: player.uuid,
926
+ pose: { x: player.pose.x, y: player.pose.y, z: player.pose.z },
927
+ })),
928
+ voiceStart: () => this.session.voice.start(),
929
+ voiceStop: () => this.session.voice.stop(),
930
+ videoStart: () => this.session.webcam.start(),
931
+ videoStop: () => this.session.webcam.stop(),
932
+ });
933
+ }
934
+
706
935
  private async refreshClientHalves(): Promise<void> {
707
936
  const halves = this.halves;
708
937
  if (!halves || !this.currentGrid || !this.state.clientModsAvailable) return;
@@ -713,6 +942,8 @@ export class StudioService {
713
942
  } catch (error) {
714
943
  this.session.network.log(`grid CLIENT halves unavailable: ${messageOf(error)}`);
715
944
  }
945
+ this.state = { ...this.state, clientModsReady: true };
946
+ this.emit();
716
947
  }
717
948
 
718
949
  private async confirmTrust(
@@ -5,7 +5,12 @@ import { describe, expect, it, vi } from 'vitest';
5
5
  vi.mock('@crowdedkingdoms/crowdyjs', async () => {
6
6
  const dist = '../../../../../node_modules/@crowdedkingdoms/crowdyjs/dist';
7
7
  const halves = await import(/* @vite-ignore */ `${dist}/grid-mods/exec-client-halves.js`);
8
- return { ExecClientHalves: halves.ExecClientHalves, PlayerCodeBroker: class {} };
8
+ return {
9
+ ExecClientHalves: halves.ExecClientHalves,
10
+ PlayerCodeBroker: class {},
11
+ CROWDY_DEFAULT_HTTP_ORIGIN: 'https://ck.dev.crowdedkingdoms.com',
12
+ CROWDY_DEFAULT_TIER: 'dev',
13
+ };
9
14
  });
10
15
  vi.mock('@crowdedkingdoms/crowdyjs/crowdy-studio', async () => {
11
16
  const dist = '../../../../../node_modules/@crowdedkingdoms/crowdyjs/dist';
@@ -38,6 +38,7 @@ import {
38
38
  type ClientModHostWrites,
39
39
  } from './clientModHost';
40
40
  import type { ModOverlayStore } from './modOverlay';
41
+ import type { ModSceneStore } from './modScene';
41
42
 
42
43
  export type ClientHalvesExec = Pick<
43
44
  ExecAPI,
@@ -73,6 +74,8 @@ export interface GridClientHalvesOptions {
73
74
  serverCalls?: (grid: ClientHalvesGrid) => (call: PlayerCodeHostCall) => Promise<unknown>;
74
75
  hud: CrowdyStudioTextHud;
75
76
  overlay?: ModOverlayStore;
77
+ /** scene_catalog / scene_instances for each CLIENT half, keyed by its source. */
78
+ scene?: ModSceneStore;
76
79
  /** One question per author (the default), or one per CLIENT half. */
77
80
  ask?: 'author' | 'mod';
78
81
  /**
@@ -211,10 +214,19 @@ export class GridClientHalves {
211
214
  if (!this.serverCalls && this.options.serverCalls) {
212
215
  this.serverCalls = this.options.serverCalls(grid);
213
216
  }
214
- const { reads, writes, input } = this.options;
217
+ const { reads, writes, input, scene } = this.options;
218
+ const source = clientHalfSource(mod);
215
219
  return routeWithFallback(
216
220
  call,
217
- () => routeClientHostCall(call, reads, grid.bounds, writes, input),
221
+ () =>
222
+ routeClientHostCall(
223
+ call,
224
+ reads,
225
+ grid.bounds,
226
+ writes,
227
+ input,
228
+ scene ? { source, store: scene } : undefined,
229
+ ),
218
230
  this.serverCalls ?? undefined,
219
231
  );
220
232
  }
@@ -235,6 +247,7 @@ export class GridClientHalves {
235
247
  const source = clientHalfSource(mod);
236
248
  this.options.hud.remove(source);
237
249
  this.options.overlay?.remove(source);
250
+ this.options.scene?.remove(source);
238
251
  this.options.log?.(`CLIENT half of mod ${mod.name} stopped (${reason})`);
239
252
  this.options.onChange?.(this.running);
240
253
  }