@crowdedkingdoms/construct 0.3.7 → 0.3.9-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/README.md +1 -1
- package/package.json +2 -2
- package/src/engine/Input.test.ts +26 -0
- package/src/engine/Input.ts +22 -2
- package/src/platform/envScope.test.ts +23 -2
- package/src/platform/envScope.ts +23 -0
- package/src/platform/network/NetworkManager.ts +18 -10
- package/src/platform/realtime/actorCodec.test.ts +1 -0
- package/src/platform/realtime/actorCodec.ts +7 -2
- package/src/platform/studio/GridService.test.ts +17 -0
- package/src/platform/studio/GridService.ts +87 -3
- package/src/platform/studio/StudioService.ts +245 -14
- package/src/platform/studio/clientHalves.test.ts +6 -1
- package/src/platform/studio/clientHalves.ts +15 -2
- package/src/platform/studio/clientModHost.test.ts +204 -4
- package/src/platform/studio/clientModHost.ts +109 -17
- package/src/platform/studio/instanceSchema.test.ts +43 -0
- package/src/platform/studio/instanceSchema.ts +59 -0
- package/src/platform/studio/instanceStore.ts +3 -1
- package/src/platform/studio/modChunkRuntime.ts +753 -0
- package/src/platform/studio/modPose.test.ts +34 -0
- package/src/platform/studio/modPose.ts +111 -0
- package/src/platform/studio/modScene.test.ts +79 -0
- package/src/platform/studio/modScene.ts +349 -0
|
@@ -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 {
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
514
|
+
onHostCall: (call: PlayerCodeHostCall) => {
|
|
515
|
+
setModPoseGrid(bounds);
|
|
516
|
+
return routeWithFallback(
|
|
433
517
|
call,
|
|
434
|
-
() =>
|
|
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.
|
|
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
|
-
|
|
627
|
-
|
|
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)
|
|
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 = {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
() =>
|
|
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
|
}
|