@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.
- package/README.md +7 -8
- package/package.json +3 -3
- package/src/grid/GridProgramRunner.ts +3 -9
- package/src/index.ts +9 -3
- package/src/platform/onboarding/steps.d.mts +0 -7
- package/src/platform/onboarding/steps.mjs +25 -56
- package/src/platform/onboarding/steps.test.ts +41 -31
- package/src/platform/studio/StudioService.ts +162 -170
- package/src/platform/studio/clientHalves.test.ts +348 -0
- package/src/platform/studio/clientHalves.ts +312 -0
- package/src/platform/studio/clientModHost.test.ts +41 -4
- package/src/platform/studio/clientModHost.ts +27 -139
- package/src/platform/studio/ClientModLifecycle.test.ts +0 -56
- package/src/platform/studio/ClientModLifecycle.ts +0 -102
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Crowdy Studio, embedded — the in-game IDE where players write SERVER and
|
|
3
|
-
* CLIENT Rust mods for the grid they stand on. A project
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* as
|
|
3
|
+
* CLIENT Rust mods for the grid they stand on. A project runs as a ck-exec mod:
|
|
4
|
+
* its SERVER target is a `ckx-sdk` crate the platform builds, deploys to the
|
|
5
|
+
* grid and switches on, which players in the grid call as `mod:<name>`; its CLIENT target is a `crowdy-client-sdk` crate
|
|
6
|
+
* the platform builds as that mod's CLIENT half, which the grid serves to the
|
|
7
|
+
* players who stand in it.
|
|
7
8
|
*
|
|
8
9
|
* The SDK's embed kit owns the chrome (dock, fullscreen fallback, Context
|
|
9
10
|
* drawer, focus trap, HUD sink). This service supplies what only the game
|
|
10
11
|
* knows:
|
|
11
12
|
* - the grid the player is on and their effective permissions on it,
|
|
12
|
-
* - the same-origin glue worker that runs CLIENT
|
|
13
|
-
* - the allowlisted host-call router (what a
|
|
13
|
+
* - the same-origin glue worker that runs CLIENT halves,
|
|
14
|
+
* - the allowlisted host-call router (what a CLIENT half may read),
|
|
14
15
|
* - input suppression and layout hooks,
|
|
15
|
-
* - the
|
|
16
|
-
*
|
|
16
|
+
* - the grid's CLIENT halves wherever you stand (`GridClientHalves`): the
|
|
17
|
+
* ones you consented to, or whose author you trust, run for you, you are
|
|
18
|
+
* asked about the rest, and they stop when you leave the grid, and
|
|
17
19
|
* - the Agentic Crowdy Studio host (Ask / Build / Play) when the platform
|
|
18
20
|
* policy and `use_studio_agent` are armed.
|
|
19
21
|
*
|
|
20
|
-
* CLIENT
|
|
21
|
-
* service checks that at construction and hides the CLIENT
|
|
22
|
-
* missing, saying so in the HUD, rather than letting a worker fail silently.
|
|
22
|
+
* CLIENT halves need `crossOriginIsolated` (COOP + COEP headers on the host).
|
|
23
|
+
* The service checks that at construction and hides the CLIENT target when it
|
|
24
|
+
* is missing, saying so in the HUD, rather than letting a worker fail silently.
|
|
23
25
|
*/
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createGridHostCalls,
|
|
28
|
+
type ExecClientHalfPrompt,
|
|
29
|
+
type ExecGridClientMod,
|
|
30
|
+
type PlayerCodeHostCall,
|
|
31
|
+
} from '@crowdedkingdoms/crowdyjs';
|
|
25
32
|
import {
|
|
26
33
|
CrowdyStudioEmbed,
|
|
27
34
|
CrowdyStudioTextHud,
|
|
35
|
+
type CrowdyStudioController,
|
|
28
36
|
type CrowdyStudioEmbedContext,
|
|
29
37
|
} from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
30
38
|
import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
|
|
@@ -36,7 +44,7 @@ import { messageOf } from '../network/NetworkManager';
|
|
|
36
44
|
import { isTextEntry } from '../../engine/Input';
|
|
37
45
|
import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
|
|
38
46
|
import { AgentLocomotion } from './agentLocomotion';
|
|
39
|
-
import {
|
|
47
|
+
import { describeClientHalfPrompt, GridClientHalves, studioPreviewModName } from './clientHalves';
|
|
40
48
|
import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
|
|
41
49
|
import { HumanInputMonitor } from './HumanInputMonitor';
|
|
42
50
|
import {
|
|
@@ -44,14 +52,13 @@ import {
|
|
|
44
52
|
DEFAULT_VOXEL_STATE,
|
|
45
53
|
routeClientHostCall,
|
|
46
54
|
routeWithFallback,
|
|
47
|
-
runConsentedGridMod,
|
|
48
55
|
type ClientModHostReads,
|
|
49
56
|
type ClientModHostWrites,
|
|
50
57
|
} from './clientModHost';
|
|
51
58
|
import { PointerClickBuffer } from './pointerClicks';
|
|
52
59
|
import { ModOverlayStore } from './modOverlay';
|
|
53
60
|
import { GridService, type GridSnapshot } from './GridService';
|
|
54
|
-
import { hasAnyStudioPermission, toBrokerBounds } from './permissions';
|
|
61
|
+
import { hasAnyStudioPermission, toBrokerBounds, type GridBounds } from './permissions';
|
|
55
62
|
import { Emitter } from '../util/Emitter';
|
|
56
63
|
|
|
57
64
|
export interface StudioState {
|
|
@@ -59,6 +66,7 @@ export interface StudioState {
|
|
|
59
66
|
grid: GridSnapshot | null;
|
|
60
67
|
/** COOP/COEP present and the build flag on. */
|
|
61
68
|
clientModsAvailable: boolean;
|
|
69
|
+
/** The grid's CLIENT halves running for this player. */
|
|
62
70
|
clientModsRunning: number;
|
|
63
71
|
/** Why CLIENT mods are unavailable, when they are. */
|
|
64
72
|
clientModsReason: string | null;
|
|
@@ -72,8 +80,17 @@ export interface StudioHooks {
|
|
|
72
80
|
suppressGameplayInput(): () => void;
|
|
73
81
|
onLayoutChange(rightInsetPx: number): void;
|
|
74
82
|
notify(text: string, tone?: 'info' | 'warn' | 'error'): void;
|
|
75
|
-
/**
|
|
76
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Ask the player whether to run a grid's CLIENT halves; `summary` is the
|
|
85
|
+
* question as plain text. `signal` aborts when they leave the grid. Defaults
|
|
86
|
+
* to `confirm`.
|
|
87
|
+
*/
|
|
88
|
+
confirmTrust?(
|
|
89
|
+
summary: string,
|
|
90
|
+
context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
|
|
91
|
+
): Promise<boolean>;
|
|
92
|
+
/** Ask once per author (the default) or once per CLIENT half. */
|
|
93
|
+
clientHalfConsent?: 'author' | 'mod';
|
|
77
94
|
/** Screenshot hook for the agent pane. */
|
|
78
95
|
captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
|
|
79
96
|
describeView?(): string | undefined;
|
|
@@ -121,14 +138,12 @@ export class StudioService {
|
|
|
121
138
|
/** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
|
|
122
139
|
readonly overlay = new ModOverlayStore();
|
|
123
140
|
private readonly hud = createConstructTextHud();
|
|
124
|
-
private readonly lifecycle = new ClientModLifecycle();
|
|
125
|
-
private readonly declinedAuthors = new Set<string>();
|
|
126
|
-
private readonly approvedAuthors = new Set<string>();
|
|
127
141
|
private readonly agentHost: ConstructPlayerHostAdapter;
|
|
128
142
|
private readonly humanInput = new HumanInputMonitor();
|
|
129
143
|
private readonly pointerClicks = new PointerClickBuffer();
|
|
130
144
|
/** JS grid programs the agent (or the player) runs on the current grid. */
|
|
131
145
|
readonly programs: GridProgramRunner;
|
|
146
|
+
private halves: GridClientHalves | null = null;
|
|
132
147
|
private gridEnteredAt = 0;
|
|
133
148
|
private embed: CrowdyStudioEmbed | null = null;
|
|
134
149
|
private hooks: StudioHooks | null = null;
|
|
@@ -137,8 +152,11 @@ export class StudioService {
|
|
|
137
152
|
private lastGridChunkKey: string | null = null;
|
|
138
153
|
private lastGridReadAt = 0;
|
|
139
154
|
private lastModsReadAt = 0;
|
|
140
|
-
private modsInFlight = false;
|
|
141
155
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
156
|
+
/** The grid the open Studio edits, and the mod its project runs as. */
|
|
157
|
+
private studioGridId: string | null = null;
|
|
158
|
+
private studioMod: string | null = null;
|
|
159
|
+
private unwatchStudio: (() => void) | null = null;
|
|
142
160
|
|
|
143
161
|
constructor(private readonly session: GameSession) {
|
|
144
162
|
this.grids = new GridService(session.network);
|
|
@@ -189,11 +207,9 @@ export class StudioService {
|
|
|
189
207
|
get crowdyStudio() {
|
|
190
208
|
return network().game.crowdyStudio;
|
|
191
209
|
},
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
},
|
|
196
|
-
// The SERVER target's mod builds, deploys and switch (`serverEngine` below).
|
|
210
|
+
// Both targets' builds, deploys, switch and preview: the SERVER target runs as the
|
|
211
|
+
// grid's mod and the CLIENT target as that mod's CLIENT half, which the grid serves to
|
|
212
|
+
// the players standing in it (GridClientHalves).
|
|
197
213
|
get exec() {
|
|
198
214
|
return network().game.exec;
|
|
199
215
|
},
|
|
@@ -213,9 +229,6 @@ export class StudioService {
|
|
|
213
229
|
},
|
|
214
230
|
appId: () => this.session.appId,
|
|
215
231
|
gameName: GAME_NAME,
|
|
216
|
-
// A mod has no CLIENT pairing, so ck-api attaches no CLIENT half of such a project for
|
|
217
|
-
// visitors (docs/MODDING.md); the author still runs it in their own tab.
|
|
218
|
-
serverEngine: 'ck-exec',
|
|
219
232
|
closeKeyCode: 'KeyM',
|
|
220
233
|
dsh: {
|
|
221
234
|
graphql: network().game.graphql,
|
|
@@ -230,7 +243,8 @@ export class StudioService {
|
|
|
230
243
|
studioOrigin: STUDIO_ORIGIN ?? undefined,
|
|
231
244
|
openOnMount: true,
|
|
232
245
|
},
|
|
233
|
-
onAgentMounted: (
|
|
246
|
+
onAgentMounted: (handle) => {
|
|
247
|
+
this.watchStudio(handle.controller);
|
|
234
248
|
this.state = { ...this.state, agentReady: true, agentReason: null };
|
|
235
249
|
this.emit();
|
|
236
250
|
},
|
|
@@ -239,6 +253,7 @@ export class StudioService {
|
|
|
239
253
|
this.emit();
|
|
240
254
|
},
|
|
241
255
|
onAgentUnmounted: () => {
|
|
256
|
+
this.watchStudio(null);
|
|
242
257
|
this.state = { ...this.state, agentReady: false, agentReason: null };
|
|
243
258
|
this.emit();
|
|
244
259
|
},
|
|
@@ -250,7 +265,9 @@ export class StudioService {
|
|
|
250
265
|
this.overlay.remove(source);
|
|
251
266
|
this.hud.remove(source);
|
|
252
267
|
}
|
|
268
|
+
this.studioGridId = null;
|
|
253
269
|
this.setOpen(false);
|
|
270
|
+
void this.refreshClientHalves();
|
|
254
271
|
},
|
|
255
272
|
});
|
|
256
273
|
if (!this.timer) this.timer = setInterval(() => void this.poll(), 2000);
|
|
@@ -321,7 +338,9 @@ export class StudioService {
|
|
|
321
338
|
dispose(): void {
|
|
322
339
|
if (this.timer) clearInterval(this.timer);
|
|
323
340
|
this.timer = null;
|
|
324
|
-
this.
|
|
341
|
+
this.halves?.stop();
|
|
342
|
+
this.halves = null;
|
|
343
|
+
this.watchStudio(null);
|
|
325
344
|
this.programs.stopAll();
|
|
326
345
|
this.embed?.destroy();
|
|
327
346
|
this.embed = null;
|
|
@@ -337,6 +356,7 @@ export class StudioService {
|
|
|
337
356
|
private openOn(grid: GridSnapshot): void {
|
|
338
357
|
if (!this.embed) throw new Error('Studio not attached');
|
|
339
358
|
this.embed.toggle(this.contextFor(grid));
|
|
359
|
+
this.studioGridId = this.embed.open ? grid.gridId : null;
|
|
340
360
|
this.setOpen(true);
|
|
341
361
|
}
|
|
342
362
|
|
|
@@ -345,7 +365,7 @@ export class StudioService {
|
|
|
345
365
|
const clientOk = this.state.clientModsAvailable;
|
|
346
366
|
const reads = this.reads();
|
|
347
367
|
const writes = this.writes();
|
|
348
|
-
const serverCalls = this.serverCallsFor(grid);
|
|
368
|
+
const serverCalls = this.serverCallsFor(grid.gridId, grid.bounds);
|
|
349
369
|
const overlaySource = `studio:${grid.gridId}`;
|
|
350
370
|
return {
|
|
351
371
|
gridId: grid.gridId,
|
|
@@ -394,14 +414,92 @@ export class StudioService {
|
|
|
394
414
|
}
|
|
395
415
|
|
|
396
416
|
/**
|
|
397
|
-
* The rest of the CLIENT host catalog for a mod on
|
|
398
|
-
*
|
|
399
|
-
*
|
|
417
|
+
* The rest of the CLIENT host catalog for a mod on a grid (DN-10): channels,
|
|
418
|
+
* sessions and user state through CrowdyJS, confined to the grid. The local
|
|
419
|
+
* router still answers world reads and voxel writes.
|
|
400
420
|
*/
|
|
401
|
-
private serverCallsFor(
|
|
421
|
+
private serverCallsFor(
|
|
422
|
+
gridId: string,
|
|
423
|
+
bounds: GridBounds,
|
|
424
|
+
): (call: PlayerCodeHostCall) => Promise<unknown> {
|
|
402
425
|
const game = this.session.network.game;
|
|
403
|
-
const scope = game.grid(this.session.appId,
|
|
404
|
-
return createGridHostCalls({
|
|
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
|
+
},
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The grid's CLIENT halves, made on first use: the app is entered by then,
|
|
439
|
+
* and the whole service is rebuilt for another app.
|
|
440
|
+
*/
|
|
441
|
+
private clientHalves(): GridClientHalves {
|
|
442
|
+
this.halves ??= new GridClientHalves({
|
|
443
|
+
exec: () => this.session.network.game.exec,
|
|
444
|
+
appId: this.session.appId,
|
|
445
|
+
workerUrl: glueWorkerAssetUrl,
|
|
446
|
+
selfUserId: () => this.session.network.user?.userId ?? null,
|
|
447
|
+
reads: this.reads(),
|
|
448
|
+
writes: this.writes(),
|
|
449
|
+
input: this.pointerClicks,
|
|
450
|
+
serverCalls: (grid) =>
|
|
451
|
+
this.serverCallsFor(grid.gridId, {
|
|
452
|
+
low: stringCorner(grid.bounds.low),
|
|
453
|
+
high: stringCorner(grid.bounds.high),
|
|
454
|
+
}),
|
|
455
|
+
hud: this.hud,
|
|
456
|
+
overlay: this.overlay,
|
|
457
|
+
ask: this.hooks?.clientHalfConsent ?? 'author',
|
|
458
|
+
confirm: (prompt, signal) =>
|
|
459
|
+
this.confirmTrust(describeClientHalfPrompt(prompt, this.authorName(prompt)), {
|
|
460
|
+
prompt,
|
|
461
|
+
signal,
|
|
462
|
+
}),
|
|
463
|
+
filter: (mod) => !this.previewedByStudio(mod),
|
|
464
|
+
onChange: (running) => {
|
|
465
|
+
this.state = { ...this.state, clientModsRunning: running.length };
|
|
466
|
+
this.emit();
|
|
467
|
+
},
|
|
468
|
+
log: (line) => this.session.network.log(line),
|
|
469
|
+
});
|
|
470
|
+
return this.halves;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The open Studio previews its project's CLIENT half itself; the grid's copy waits. */
|
|
474
|
+
private previewedByStudio(mod: ExecGridClientMod): boolean {
|
|
475
|
+
return (
|
|
476
|
+
this.isOpen &&
|
|
477
|
+
this.studioMod !== null &&
|
|
478
|
+
String(mod.gridId) === this.studioGridId &&
|
|
479
|
+
mod.name === this.studioMod
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private watchStudio(controller: CrowdyStudioController | null): void {
|
|
484
|
+
this.unwatchStudio?.();
|
|
485
|
+
this.unwatchStudio = null;
|
|
486
|
+
const follow = (mod: string | null) => {
|
|
487
|
+
if (mod === this.studioMod) return;
|
|
488
|
+
this.studioMod = mod;
|
|
489
|
+
void this.refreshClientHalves();
|
|
490
|
+
};
|
|
491
|
+
if (!controller) {
|
|
492
|
+
follow(null);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
this.unwatchStudio = controller.subscribe((state) =>
|
|
496
|
+
follow(state.project ? studioPreviewModName(state.project) : null),
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private authorName(prompt: ExecClientHalfPrompt): string | null {
|
|
501
|
+
const grid = this.currentGrid;
|
|
502
|
+
return grid && grid.gridId === String(prompt.gridId) ? (grid.ownerName ?? null) : null;
|
|
405
503
|
}
|
|
406
504
|
|
|
407
505
|
private observationFrame() {
|
|
@@ -495,15 +593,19 @@ export class StudioService {
|
|
|
495
593
|
}
|
|
496
594
|
|
|
497
595
|
private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
|
|
596
|
+
const entered = (grid?.gridId ?? null) !== (this.currentGrid?.gridId ?? null);
|
|
498
597
|
this.currentGrid = grid;
|
|
499
598
|
this.lastGridChunkKey = chunkKey(chunk);
|
|
500
599
|
this.lastGridReadAt = Date.now();
|
|
501
|
-
if (
|
|
502
|
-
this.declinedAuthors.clear();
|
|
503
|
-
this.approvedAuthors.clear();
|
|
600
|
+
if (entered) {
|
|
504
601
|
this.lastModsReadAt = 0;
|
|
505
602
|
this.gridEnteredAt = Date.now();
|
|
506
603
|
this.overlay.clear();
|
|
604
|
+
if (this.state.clientModsAvailable) {
|
|
605
|
+
this.clientHalves().enterGrid(
|
|
606
|
+
grid ? { gridId: grid.gridId, bounds: toBrokerBounds(grid.bounds) } : null,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
507
609
|
}
|
|
508
610
|
this.state = { ...this.state, grid };
|
|
509
611
|
this.emit();
|
|
@@ -522,7 +624,7 @@ export class StudioService {
|
|
|
522
624
|
return Number.isFinite(px) ? px : 0;
|
|
523
625
|
}
|
|
524
626
|
|
|
525
|
-
/** Periodic: which grid are we on, and are there
|
|
627
|
+
/** Periodic: which grid are we on, and are there CLIENT halves to run for us? */
|
|
526
628
|
private async poll(): Promise<void> {
|
|
527
629
|
if (!this.session.joined || !this.session.network.hasGame) return;
|
|
528
630
|
const selfChunk = this.session.world.self.chunk;
|
|
@@ -538,118 +640,31 @@ export class StudioService {
|
|
|
538
640
|
this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
|
|
539
641
|
}
|
|
540
642
|
}
|
|
541
|
-
if (
|
|
542
|
-
this.state.clientModsAvailable &&
|
|
543
|
-
this.currentGrid &&
|
|
544
|
-
!this.currentGrid.owned &&
|
|
545
|
-
now - this.gridEnteredAt >= GRID_SETTLE_MS
|
|
546
|
-
) {
|
|
547
|
-
await this.syncGridClientMods(this.currentGrid);
|
|
548
|
-
}
|
|
643
|
+
if (now - this.lastModsReadAt >= MODS_REFRESH_MS) await this.refreshClientHalves();
|
|
549
644
|
}
|
|
550
645
|
|
|
551
646
|
/**
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
647
|
+
* Reconcile the grid's CLIENT halves: start the consented ones, ask about
|
|
648
|
+
* the rest, stop what changed or went. Waits out the presence race after
|
|
649
|
+
* entering a grid.
|
|
555
650
|
*/
|
|
556
|
-
private async
|
|
557
|
-
const
|
|
558
|
-
if (this.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
this.lastModsReadAt = now;
|
|
562
|
-
this.modsInFlight = true;
|
|
651
|
+
private async refreshClientHalves(): Promise<void> {
|
|
652
|
+
const halves = this.halves;
|
|
653
|
+
if (!halves || !this.currentGrid || !this.state.clientModsAvailable) return;
|
|
654
|
+
if (Date.now() - this.gridEnteredAt < GRID_SETTLE_MS) return;
|
|
655
|
+
this.lastModsReadAt = Date.now();
|
|
563
656
|
try {
|
|
564
|
-
|
|
565
|
-
const appId = this.session.appId;
|
|
566
|
-
let mods = await client.marketplace.gridClientMods({ appId, gridId: grid.gridId });
|
|
567
|
-
if (!this.lifecycle.reconcile(scope, mods.map(descriptorOf))) return;
|
|
568
|
-
|
|
569
|
-
let trustedAny = false;
|
|
570
|
-
for (const mod of mods) {
|
|
571
|
-
if (mod.callerTrustsAuthor) continue;
|
|
572
|
-
const authorKey = `${mod.authorKind}:${mod.authorRef}:${mod.authorCapabilityHash}`;
|
|
573
|
-
if (this.declinedAuthors.has(authorKey)) continue;
|
|
574
|
-
if (!this.approvedAuthors.has(authorKey)) {
|
|
575
|
-
const approved = await this.confirmTrust(
|
|
576
|
-
`Trust ${String(mod.authorKind).toLowerCase()} ${mod.authorRef}'s mods on this grid?\n\n` +
|
|
577
|
-
'Their client code will run in a sandbox while you are here.\n\n' +
|
|
578
|
-
prettyCapabilities(mod.authorCapabilitySummaryJson),
|
|
579
|
-
);
|
|
580
|
-
if (!approved) {
|
|
581
|
-
this.declinedAuthors.add(authorKey);
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
// Remembered so a transient refusal (presence not registered yet)
|
|
585
|
-
// retries silently instead of asking the player again.
|
|
586
|
-
this.approvedAuthors.add(authorKey);
|
|
587
|
-
}
|
|
588
|
-
await client.marketplace.trustGridAuthor({
|
|
589
|
-
appId,
|
|
590
|
-
gridId: grid.gridId,
|
|
591
|
-
authorKind: mod.authorKind,
|
|
592
|
-
authorRef: mod.authorRef,
|
|
593
|
-
consentCapabilityHash: mod.authorCapabilityHash,
|
|
594
|
-
});
|
|
595
|
-
if (!this.lifecycle.isCurrent(scope)) return;
|
|
596
|
-
trustedAny = true;
|
|
597
|
-
}
|
|
598
|
-
if (trustedAny) {
|
|
599
|
-
mods = await client.marketplace.gridClientMods({ appId, gridId: grid.gridId });
|
|
600
|
-
if (!this.lifecycle.isCurrent(scope)) return;
|
|
601
|
-
}
|
|
602
|
-
for (const mod of mods) {
|
|
603
|
-
if (!this.lifecycle.isCurrent(scope)) return;
|
|
604
|
-
if (
|
|
605
|
-
(mod.callerTrustsAuthor || mod.callerConsented) &&
|
|
606
|
-
!this.lifecycle.has(mod.attachmentId)
|
|
607
|
-
) {
|
|
608
|
-
await this.runGridMod(mod, grid, scope);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
657
|
+
await halves.refresh();
|
|
611
658
|
} catch (error) {
|
|
612
|
-
this.session.network.log(`grid
|
|
613
|
-
} finally {
|
|
614
|
-
this.modsInFlight = false;
|
|
615
|
-
this.state = { ...this.state, clientModsRunning: this.lifecycle.runningCount };
|
|
616
|
-
this.emit();
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
private async runGridMod(
|
|
621
|
-
mod: {
|
|
622
|
-
attachmentId: string;
|
|
623
|
-
clientArtifactHash: string;
|
|
624
|
-
capabilityHash: string;
|
|
625
|
-
listingName?: string | null;
|
|
626
|
-
},
|
|
627
|
-
grid: GridSnapshot,
|
|
628
|
-
scope: ClientModScope,
|
|
629
|
-
): Promise<void> {
|
|
630
|
-
const handle = await runConsentedGridMod({
|
|
631
|
-
client: this.session.network.game,
|
|
632
|
-
appId: this.session.appId,
|
|
633
|
-
attachmentId: mod.attachmentId,
|
|
634
|
-
artifactCacheKey: mod.clientArtifactHash,
|
|
635
|
-
hudSource: `grid:${mod.attachmentId}`,
|
|
636
|
-
hudLabel: mod.listingName || 'Grid mod',
|
|
637
|
-
grid: toBrokerBounds(grid.bounds, grid.gridId),
|
|
638
|
-
workerUrl: glueWorkerAssetUrl,
|
|
639
|
-
serverCalls: this.serverCallsFor(grid),
|
|
640
|
-
reads: this.reads(),
|
|
641
|
-
writes: this.writes(),
|
|
642
|
-
hud: this.hud,
|
|
643
|
-
overlay: this.overlay,
|
|
644
|
-
input: this.pointerClicks,
|
|
645
|
-
});
|
|
646
|
-
if (handle) {
|
|
647
|
-
this.lifecycle.track(scope, descriptorOf(mod), handle);
|
|
659
|
+
this.session.network.log(`grid CLIENT halves unavailable: ${messageOf(error)}`);
|
|
648
660
|
}
|
|
649
661
|
}
|
|
650
662
|
|
|
651
|
-
private async confirmTrust(
|
|
652
|
-
|
|
663
|
+
private async confirmTrust(
|
|
664
|
+
summary: string,
|
|
665
|
+
context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
|
|
666
|
+
): Promise<boolean> {
|
|
667
|
+
if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary, context);
|
|
653
668
|
return window.confirm(summary);
|
|
654
669
|
}
|
|
655
670
|
|
|
@@ -658,29 +673,6 @@ export class StudioService {
|
|
|
658
673
|
}
|
|
659
674
|
}
|
|
660
675
|
|
|
661
|
-
function
|
|
662
|
-
|
|
663
|
-
clientArtifactHash: string;
|
|
664
|
-
capabilityHash: string;
|
|
665
|
-
}) {
|
|
666
|
-
return {
|
|
667
|
-
attachmentId: mod.attachmentId,
|
|
668
|
-
artifactHash: mod.clientArtifactHash,
|
|
669
|
-
capabilityHash: mod.capabilityHash,
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
function prettyCapabilities(json: string | null | undefined): string {
|
|
674
|
-
if (!json) return '(no capability summary)';
|
|
675
|
-
try {
|
|
676
|
-
const parsed = JSON.parse(json) as unknown;
|
|
677
|
-
if (parsed && typeof parsed === 'object') {
|
|
678
|
-
return Object.entries(parsed as Record<string, unknown>)
|
|
679
|
-
.map(([k, v]) => `• ${k}: ${Array.isArray(v) ? v.join(', ') : String(v)}`)
|
|
680
|
-
.join('\n');
|
|
681
|
-
}
|
|
682
|
-
return String(parsed);
|
|
683
|
-
} catch {
|
|
684
|
-
return json;
|
|
685
|
-
}
|
|
676
|
+
function stringCorner(corner: { x: bigint; y: bigint; z: bigint }) {
|
|
677
|
+
return { x: corner.x.toString(), y: corner.y.toString(), z: corner.z.toString() };
|
|
686
678
|
}
|