@crowdedkingdoms/construct 0.2.1-dev.1 → 0.2.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/README.md +10 -4
- package/package.json +3 -3
- package/src/index.ts +9 -2
- package/src/platform/studio/ClientModLifecycle.ts +5 -0
- package/src/platform/studio/StudioService.ts +180 -168
- package/src/platform/studio/clientHalves.test.ts +328 -0
- package/src/platform/studio/clientHalves.ts +291 -0
- package/src/platform/studio/clientModHost.test.ts +40 -1
- package/src/platform/studio/clientModHost.ts +33 -21
package/README.md
CHANGED
|
@@ -9,15 +9,21 @@ and the games built on it share.
|
|
|
9
9
|
| `engine/` | `GameLoop`, `SceneRouter`, `Input`, `Controls`, the `GameScene` contract |
|
|
10
10
|
| `platform/` | Hosted sign-in and app entry (`AuthService`, `NetworkManager`), `GameSession`, World Stores presence and the pose codec, chat, voice, webcam, envScope storage |
|
|
11
11
|
| `platform/exec/`, `platform/model/` | The game's world hub on ck-exec (`WorldHub`, one connection per page as `NetworkManager.worldHub`) and `ModelService`, which reads the pulse, programs and progression from it |
|
|
12
|
-
| `platform/studio/` | The Crowdy Studio dock (
|
|
12
|
+
| `platform/studio/` | The Crowdy Studio dock (a project runs as a ck-exec mod and its CLIENT half), grid claims and the world hub's claim registry (`GridService`), the CLIENT halves of the grid the player stands in (`GridClientHalves`, over CrowdyJS's `ExecClientHalves`) and the host calls they reach, the agent's player host |
|
|
13
13
|
| `grid/` | JS grid programs (`GridProgramRunner`): player JavaScript with the full CrowdyJS SDK, sandboxed, relayed with a grid-scoped token |
|
|
14
14
|
| `node/` | `security-headers` (site, `/dsh/`, `/grid-program.html`), `vite-plugins`, `construct-copy-dsh` |
|
|
15
15
|
| `sandbox/boot` | The grid program sandbox bootstrap |
|
|
16
16
|
|
|
17
17
|
It ships **TypeScript source** for Vite (it uses `?worker&url` and
|
|
18
18
|
`import.meta.env`). Peer dependencies: `@crowdedkingdoms/crowdyjs` 17.7+ (17.12
|
|
19
|
-
for `ModelService`, `GridService` and the Studio's SERVER target on ck-exec
|
|
20
|
-
and, for the Studio agent
|
|
19
|
+
for `ModelService`, `GridService` and the Studio's SERVER target on ck-exec,
|
|
20
|
+
17.14 for its CLIENT target and `GridClientHalves`) and, for the Studio agent
|
|
21
|
+
pane, `@crowdedkingdoms/crowdy-dsh` 0.4+. On a
|
|
22
|
+
prerelease tier the ranges name each `X.Y.Z` line they admit, because npm
|
|
23
|
+
matches a prerelease only against a comparator on the same `X.Y.Z`. A game can
|
|
24
|
+
take a new CrowdyJS line only once a construct release names it (until then
|
|
25
|
+
`npm install` refuses the pin with `ERESOLVE`); npm lets an unnamed crowdy-dsh
|
|
26
|
+
line through with a warning, but keep that pin on a named line too.
|
|
21
27
|
|
|
22
28
|
## Installing it
|
|
23
29
|
|
|
@@ -25,7 +31,7 @@ Releases are on npm under one dist-tag per tier, and a game pins the exact
|
|
|
25
31
|
version of its own tier, as it pins CrowdyJS:
|
|
26
32
|
|
|
27
33
|
```bash
|
|
28
|
-
npm install --save-exact @crowdedkingdoms/construct@dev #
|
|
34
|
+
npm install --save-exact @crowdedkingdoms/construct@dev # an X.Y.Z-dev.N build
|
|
29
35
|
```
|
|
30
36
|
|
|
31
37
|
`dev` is `X.Y.Z-dev.N`, `test` is `X.Y.Z-test.N`, and `prod` is `X.Y.Z` on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.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": ">=17.7.0-dev.0 <18 || >=17.8.0-dev.0 <18 || >=17.9.0-dev.0 <18 || >=17.12.0-dev.0 <18",
|
|
49
|
-
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1"
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=17.7.0-dev.0 <18 || >=17.8.0-dev.0 <18 || >=17.9.0-dev.0 <18 || >=17.12.0-dev.0 <18 || >=17.13.0-dev.0 <18 || >=17.14.0-dev.0 <18",
|
|
49
|
+
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@crowdedkingdoms/crowdy-dsh": {
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The platform layer every Crowded Kingdoms browser game needs (hosted
|
|
5
5
|
* sign-in, app entry, World Stores presence, voice / webcam / chat, the
|
|
6
|
-
* Crowdy Studio dock with
|
|
7
|
-
* loop around it. Deep imports (`@crowdedkingdoms/construct/platform/...`,
|
|
6
|
+
* Crowdy Studio dock with grid mods and their CLIENT halves, JS grid programs)
|
|
7
|
+
* and the engine loop around it. Deep imports (`@crowdedkingdoms/construct/platform/...`,
|
|
8
8
|
* `/engine/...`, `/grid/...`) reach every module; this barrel re-exports the
|
|
9
9
|
* pieces a game wires at boot.
|
|
10
10
|
*/
|
|
@@ -85,4 +85,11 @@ export {
|
|
|
85
85
|
type ClientModHostReads,
|
|
86
86
|
type ClientModHostWrites,
|
|
87
87
|
} from './platform/studio/clientModHost';
|
|
88
|
+
export {
|
|
89
|
+
GridClientHalves,
|
|
90
|
+
describeClientHalfPrompt,
|
|
91
|
+
isOwnClientHalfPrompt,
|
|
92
|
+
type ClientHalvesGrid,
|
|
93
|
+
type GridClientHalvesOptions,
|
|
94
|
+
} from './platform/studio/clientHalves';
|
|
88
95
|
export { GridProgramRunner, type GridProgramRunnerOptions } from './grid/GridProgramRunner';
|
|
@@ -21,6 +21,11 @@ export interface ClientModScope {
|
|
|
21
21
|
|
|
22
22
|
interface RunningClientMod extends ClientModDescriptor, ClientModHandle {}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated For the legacy grid-attached client mods (`runConsentedGridMod`),
|
|
26
|
+
* which CrowdyJS 18 removes; `GridClientHalves` keeps a ck-exec grid's CLIENT
|
|
27
|
+
* halves.
|
|
28
|
+
*/
|
|
24
29
|
export class ClientModLifecycle {
|
|
25
30
|
private readonly running = new Map<string, RunningClientMod>();
|
|
26
31
|
private activeGridId: string | null = null;
|
|
@@ -1,31 +1,41 @@
|
|
|
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
|
-
* builds, deploys to the grid and switches on, which players in the
|
|
6
|
-
* as `mod:<name
|
|
3
|
+
* CLIENT Rust mods for the grid they stand on. A project runs as a ck-exec mod
|
|
4
|
+
* (`serverEngine: 'ck-exec'`): its SERVER target is a `ckx-sdk` crate the
|
|
5
|
+
* platform builds, deploys to the grid and switches on, which players in the
|
|
6
|
+
* grid call as `mod:<name>`; its CLIENT target is a `crowdy-client-sdk` crate
|
|
7
|
+
* the platform builds as that mod's CLIENT half, which the grid serves to the
|
|
8
|
+
* players who stand in it.
|
|
7
9
|
*
|
|
8
10
|
* The SDK's embed kit owns the chrome (dock, fullscreen fallback, Context
|
|
9
11
|
* drawer, focus trap, HUD sink). This service supplies what only the game
|
|
10
12
|
* knows:
|
|
11
13
|
* - 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
|
|
14
|
+
* - the same-origin glue worker that runs CLIENT halves,
|
|
15
|
+
* - the allowlisted host-call router (what a CLIENT half may read),
|
|
14
16
|
* - input suppression and layout hooks,
|
|
15
|
-
* - the
|
|
16
|
-
*
|
|
17
|
+
* - the grid's CLIENT halves wherever you stand (`GridClientHalves`): the
|
|
18
|
+
* ones you consented to, or whose author you trust, run for you, you are
|
|
19
|
+
* asked about the rest, and they stop when you leave the grid, and
|
|
17
20
|
* - the Agentic Crowdy Studio host (Ask / Build / Play) when the platform
|
|
18
21
|
* policy and `use_studio_agent` are armed.
|
|
19
22
|
*
|
|
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.
|
|
23
|
+
* CLIENT halves need `crossOriginIsolated` (COOP + COEP headers on the host).
|
|
24
|
+
* The service checks that at construction and hides the CLIENT target when it
|
|
25
|
+
* is missing, saying so in the HUD, rather than letting a worker fail silently.
|
|
23
26
|
*/
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
createGridHostCalls,
|
|
29
|
+
type ExecClientHalfPrompt,
|
|
30
|
+
type ExecGridClientMod,
|
|
31
|
+
type PlayerCodeHostCall,
|
|
32
|
+
} from '@crowdedkingdoms/crowdyjs';
|
|
25
33
|
import {
|
|
26
34
|
CrowdyStudioEmbed,
|
|
27
35
|
CrowdyStudioTextHud,
|
|
36
|
+
type CrowdyStudioController,
|
|
28
37
|
type CrowdyStudioEmbedContext,
|
|
38
|
+
type CrowdyStudioPlayerCompute,
|
|
29
39
|
} from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
30
40
|
import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
|
|
31
41
|
|
|
@@ -36,7 +46,7 @@ import { messageOf } from '../network/NetworkManager';
|
|
|
36
46
|
import { isTextEntry } from '../../engine/Input';
|
|
37
47
|
import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
|
|
38
48
|
import { AgentLocomotion } from './agentLocomotion';
|
|
39
|
-
import {
|
|
49
|
+
import { describeClientHalfPrompt, GridClientHalves, studioPreviewModName } from './clientHalves';
|
|
40
50
|
import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
|
|
41
51
|
import { HumanInputMonitor } from './HumanInputMonitor';
|
|
42
52
|
import {
|
|
@@ -44,14 +54,13 @@ import {
|
|
|
44
54
|
DEFAULT_VOXEL_STATE,
|
|
45
55
|
routeClientHostCall,
|
|
46
56
|
routeWithFallback,
|
|
47
|
-
runConsentedGridMod,
|
|
48
57
|
type ClientModHostReads,
|
|
49
58
|
type ClientModHostWrites,
|
|
50
59
|
} from './clientModHost';
|
|
51
60
|
import { PointerClickBuffer } from './pointerClicks';
|
|
52
61
|
import { ModOverlayStore } from './modOverlay';
|
|
53
62
|
import { GridService, type GridSnapshot } from './GridService';
|
|
54
|
-
import { hasAnyStudioPermission, toBrokerBounds } from './permissions';
|
|
63
|
+
import { hasAnyStudioPermission, toBrokerBounds, type GridBounds } from './permissions';
|
|
55
64
|
import { Emitter } from '../util/Emitter';
|
|
56
65
|
|
|
57
66
|
export interface StudioState {
|
|
@@ -59,6 +68,7 @@ export interface StudioState {
|
|
|
59
68
|
grid: GridSnapshot | null;
|
|
60
69
|
/** COOP/COEP present and the build flag on. */
|
|
61
70
|
clientModsAvailable: boolean;
|
|
71
|
+
/** The grid's CLIENT halves running for this player. */
|
|
62
72
|
clientModsRunning: number;
|
|
63
73
|
/** Why CLIENT mods are unavailable, when they are. */
|
|
64
74
|
clientModsReason: string | null;
|
|
@@ -72,8 +82,17 @@ export interface StudioHooks {
|
|
|
72
82
|
suppressGameplayInput(): () => void;
|
|
73
83
|
onLayoutChange(rightInsetPx: number): void;
|
|
74
84
|
notify(text: string, tone?: 'info' | 'warn' | 'error'): void;
|
|
75
|
-
/**
|
|
76
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Ask the player whether to run a grid's CLIENT halves; `summary` is the
|
|
87
|
+
* question as plain text. `signal` aborts when they leave the grid. Defaults
|
|
88
|
+
* to `confirm`.
|
|
89
|
+
*/
|
|
90
|
+
confirmTrust?(
|
|
91
|
+
summary: string,
|
|
92
|
+
context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
|
|
93
|
+
): Promise<boolean>;
|
|
94
|
+
/** Ask once per author (the default) or once per CLIENT half. */
|
|
95
|
+
clientHalfConsent?: 'author' | 'mod';
|
|
77
96
|
/** Screenshot hook for the agent pane. */
|
|
78
97
|
captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
|
|
79
98
|
describeView?(): string | undefined;
|
|
@@ -103,6 +122,29 @@ function createConstructTextHud(): CrowdyStudioTextHud {
|
|
|
103
122
|
return hud;
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
/**
|
|
126
|
+
* CrowdyJS 17's embed types a legacy player compute service, which it never
|
|
127
|
+
* calls while both targets run on ck-exec; this one refuses, so nothing can
|
|
128
|
+
* reach the switched-off engine.
|
|
129
|
+
*/
|
|
130
|
+
const NO_PLAYER_COMPUTE: CrowdyStudioPlayerCompute = {
|
|
131
|
+
deploy: refuseLegacyEngine,
|
|
132
|
+
versions: refuseLegacyEngine,
|
|
133
|
+
setEnabled: refuseLegacyEngine,
|
|
134
|
+
setRequires: refuseLegacyEngine,
|
|
135
|
+
artifactBytes: refuseLegacyEngine,
|
|
136
|
+
usage: refuseLegacyEngine,
|
|
137
|
+
runs: refuseLegacyEngine,
|
|
138
|
+
logs: refuseLegacyEngine,
|
|
139
|
+
invoke: refuseLegacyEngine,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function refuseLegacyEngine(): Promise<never> {
|
|
143
|
+
return Promise.reject(
|
|
144
|
+
new Error('Crowdy Studio runs mods on ck-exec here; legacy player compute is not used'),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
106
148
|
const GRID_REFRESH_MS = 8_000;
|
|
107
149
|
const MODS_REFRESH_MS = 10_000;
|
|
108
150
|
/**
|
|
@@ -121,14 +163,12 @@ export class StudioService {
|
|
|
121
163
|
/** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
|
|
122
164
|
readonly overlay = new ModOverlayStore();
|
|
123
165
|
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
166
|
private readonly agentHost: ConstructPlayerHostAdapter;
|
|
128
167
|
private readonly humanInput = new HumanInputMonitor();
|
|
129
168
|
private readonly pointerClicks = new PointerClickBuffer();
|
|
130
169
|
/** JS grid programs the agent (or the player) runs on the current grid. */
|
|
131
170
|
readonly programs: GridProgramRunner;
|
|
171
|
+
private halves: GridClientHalves | null = null;
|
|
132
172
|
private gridEnteredAt = 0;
|
|
133
173
|
private embed: CrowdyStudioEmbed | null = null;
|
|
134
174
|
private hooks: StudioHooks | null = null;
|
|
@@ -137,8 +177,11 @@ export class StudioService {
|
|
|
137
177
|
private lastGridChunkKey: string | null = null;
|
|
138
178
|
private lastGridReadAt = 0;
|
|
139
179
|
private lastModsReadAt = 0;
|
|
140
|
-
private modsInFlight = false;
|
|
141
180
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
181
|
+
/** The grid the open Studio edits, and the mod its project runs as. */
|
|
182
|
+
private studioGridId: string | null = null;
|
|
183
|
+
private studioMod: string | null = null;
|
|
184
|
+
private unwatchStudio: (() => void) | null = null;
|
|
142
185
|
|
|
143
186
|
constructor(private readonly session: GameSession) {
|
|
144
187
|
this.grids = new GridService(session.network);
|
|
@@ -189,11 +232,8 @@ export class StudioService {
|
|
|
189
232
|
get crowdyStudio() {
|
|
190
233
|
return network().game.crowdyStudio;
|
|
191
234
|
},
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return network().game.playerCompute;
|
|
195
|
-
},
|
|
196
|
-
// The SERVER target's mod builds, deploys and switch (`serverEngine` below).
|
|
235
|
+
playerCompute: NO_PLAYER_COMPUTE,
|
|
236
|
+
// Both targets' builds, deploys, switch and preview (`serverEngine` below).
|
|
197
237
|
get exec() {
|
|
198
238
|
return network().game.exec;
|
|
199
239
|
},
|
|
@@ -213,8 +253,8 @@ export class StudioService {
|
|
|
213
253
|
},
|
|
214
254
|
appId: () => this.session.appId,
|
|
215
255
|
gameName: GAME_NAME,
|
|
216
|
-
//
|
|
217
|
-
//
|
|
256
|
+
// The SERVER target runs as the grid's mod and the CLIENT target as that mod's CLIENT
|
|
257
|
+
// half, which the grid serves to the players standing in it (GridClientHalves).
|
|
218
258
|
serverEngine: 'ck-exec',
|
|
219
259
|
closeKeyCode: 'KeyM',
|
|
220
260
|
dsh: {
|
|
@@ -230,7 +270,8 @@ export class StudioService {
|
|
|
230
270
|
studioOrigin: STUDIO_ORIGIN ?? undefined,
|
|
231
271
|
openOnMount: true,
|
|
232
272
|
},
|
|
233
|
-
onAgentMounted: (
|
|
273
|
+
onAgentMounted: (handle) => {
|
|
274
|
+
this.watchStudio(handle.controller);
|
|
234
275
|
this.state = { ...this.state, agentReady: true, agentReason: null };
|
|
235
276
|
this.emit();
|
|
236
277
|
},
|
|
@@ -239,6 +280,7 @@ export class StudioService {
|
|
|
239
280
|
this.emit();
|
|
240
281
|
},
|
|
241
282
|
onAgentUnmounted: () => {
|
|
283
|
+
this.watchStudio(null);
|
|
242
284
|
this.state = { ...this.state, agentReady: false, agentReason: null };
|
|
243
285
|
this.emit();
|
|
244
286
|
},
|
|
@@ -250,7 +292,9 @@ export class StudioService {
|
|
|
250
292
|
this.overlay.remove(source);
|
|
251
293
|
this.hud.remove(source);
|
|
252
294
|
}
|
|
295
|
+
this.studioGridId = null;
|
|
253
296
|
this.setOpen(false);
|
|
297
|
+
void this.refreshClientHalves();
|
|
254
298
|
},
|
|
255
299
|
});
|
|
256
300
|
if (!this.timer) this.timer = setInterval(() => void this.poll(), 2000);
|
|
@@ -321,7 +365,9 @@ export class StudioService {
|
|
|
321
365
|
dispose(): void {
|
|
322
366
|
if (this.timer) clearInterval(this.timer);
|
|
323
367
|
this.timer = null;
|
|
324
|
-
this.
|
|
368
|
+
this.halves?.stop();
|
|
369
|
+
this.halves = null;
|
|
370
|
+
this.watchStudio(null);
|
|
325
371
|
this.programs.stopAll();
|
|
326
372
|
this.embed?.destroy();
|
|
327
373
|
this.embed = null;
|
|
@@ -337,6 +383,7 @@ export class StudioService {
|
|
|
337
383
|
private openOn(grid: GridSnapshot): void {
|
|
338
384
|
if (!this.embed) throw new Error('Studio not attached');
|
|
339
385
|
this.embed.toggle(this.contextFor(grid));
|
|
386
|
+
this.studioGridId = this.embed.open ? grid.gridId : null;
|
|
340
387
|
this.setOpen(true);
|
|
341
388
|
}
|
|
342
389
|
|
|
@@ -345,7 +392,7 @@ export class StudioService {
|
|
|
345
392
|
const clientOk = this.state.clientModsAvailable;
|
|
346
393
|
const reads = this.reads();
|
|
347
394
|
const writes = this.writes();
|
|
348
|
-
const serverCalls = this.serverCallsFor(grid);
|
|
395
|
+
const serverCalls = this.serverCallsFor(grid.gridId, grid.bounds);
|
|
349
396
|
const overlaySource = `studio:${grid.gridId}`;
|
|
350
397
|
return {
|
|
351
398
|
gridId: grid.gridId,
|
|
@@ -394,16 +441,87 @@ export class StudioService {
|
|
|
394
441
|
}
|
|
395
442
|
|
|
396
443
|
/**
|
|
397
|
-
* The rest of the CLIENT host catalog for a mod on
|
|
398
|
-
*
|
|
399
|
-
*
|
|
444
|
+
* The rest of the CLIENT host catalog for a mod on a grid (DN-10): channels,
|
|
445
|
+
* sessions and user state through CrowdyJS, confined to the grid. The local
|
|
446
|
+
* router still answers world reads and voxel writes.
|
|
400
447
|
*/
|
|
401
|
-
private serverCallsFor(
|
|
448
|
+
private serverCallsFor(
|
|
449
|
+
gridId: string,
|
|
450
|
+
bounds: GridBounds,
|
|
451
|
+
): (call: PlayerCodeHostCall) => Promise<unknown> {
|
|
402
452
|
const game = this.session.network.game;
|
|
403
|
-
const scope = game.grid(this.session.appId,
|
|
453
|
+
const scope = game.grid(this.session.appId, gridId, toBrokerBounds(bounds));
|
|
404
454
|
return createGridHostCalls({ scope, client: game });
|
|
405
455
|
}
|
|
406
456
|
|
|
457
|
+
/**
|
|
458
|
+
* The grid's CLIENT halves, made on first use: the app is entered by then,
|
|
459
|
+
* and the whole service is rebuilt for another app.
|
|
460
|
+
*/
|
|
461
|
+
private clientHalves(): GridClientHalves {
|
|
462
|
+
this.halves ??= new GridClientHalves({
|
|
463
|
+
exec: () => this.session.network.game.exec,
|
|
464
|
+
appId: this.session.appId,
|
|
465
|
+
workerUrl: glueWorkerAssetUrl,
|
|
466
|
+
selfUserId: () => this.session.network.user?.userId ?? null,
|
|
467
|
+
reads: this.reads(),
|
|
468
|
+
writes: this.writes(),
|
|
469
|
+
input: this.pointerClicks,
|
|
470
|
+
serverCalls: (grid) =>
|
|
471
|
+
this.serverCallsFor(grid.gridId, {
|
|
472
|
+
low: stringCorner(grid.bounds.low),
|
|
473
|
+
high: stringCorner(grid.bounds.high),
|
|
474
|
+
}),
|
|
475
|
+
hud: this.hud,
|
|
476
|
+
overlay: this.overlay,
|
|
477
|
+
ask: this.hooks?.clientHalfConsent ?? 'author',
|
|
478
|
+
confirm: (prompt, signal) =>
|
|
479
|
+
this.confirmTrust(describeClientHalfPrompt(prompt, this.authorName(prompt)), {
|
|
480
|
+
prompt,
|
|
481
|
+
signal,
|
|
482
|
+
}),
|
|
483
|
+
filter: (mod) => !this.previewedByStudio(mod),
|
|
484
|
+
onChange: (running) => {
|
|
485
|
+
this.state = { ...this.state, clientModsRunning: running.length };
|
|
486
|
+
this.emit();
|
|
487
|
+
},
|
|
488
|
+
log: (line) => this.session.network.log(line),
|
|
489
|
+
});
|
|
490
|
+
return this.halves;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The open Studio previews its project's CLIENT half itself; the grid's copy waits. */
|
|
494
|
+
private previewedByStudio(mod: ExecGridClientMod): boolean {
|
|
495
|
+
return (
|
|
496
|
+
this.isOpen &&
|
|
497
|
+
this.studioMod !== null &&
|
|
498
|
+
String(mod.gridId) === this.studioGridId &&
|
|
499
|
+
mod.name === this.studioMod
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private watchStudio(controller: CrowdyStudioController | null): void {
|
|
504
|
+
this.unwatchStudio?.();
|
|
505
|
+
this.unwatchStudio = null;
|
|
506
|
+
const follow = (mod: string | null) => {
|
|
507
|
+
if (mod === this.studioMod) return;
|
|
508
|
+
this.studioMod = mod;
|
|
509
|
+
void this.refreshClientHalves();
|
|
510
|
+
};
|
|
511
|
+
if (!controller) {
|
|
512
|
+
follow(null);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
this.unwatchStudio = controller.subscribe((state) =>
|
|
516
|
+
follow(state.project ? studioPreviewModName(state.project) : null),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private authorName(prompt: ExecClientHalfPrompt): string | null {
|
|
521
|
+
const grid = this.currentGrid;
|
|
522
|
+
return grid && grid.gridId === String(prompt.gridId) ? (grid.ownerName ?? null) : null;
|
|
523
|
+
}
|
|
524
|
+
|
|
407
525
|
private observationFrame() {
|
|
408
526
|
const pose = this.session.joined ? this.session.world.self.state : null;
|
|
409
527
|
const nearby = this.session.players().map((player) => ({
|
|
@@ -495,15 +613,19 @@ export class StudioService {
|
|
|
495
613
|
}
|
|
496
614
|
|
|
497
615
|
private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
|
|
616
|
+
const entered = (grid?.gridId ?? null) !== (this.currentGrid?.gridId ?? null);
|
|
498
617
|
this.currentGrid = grid;
|
|
499
618
|
this.lastGridChunkKey = chunkKey(chunk);
|
|
500
619
|
this.lastGridReadAt = Date.now();
|
|
501
|
-
if (
|
|
502
|
-
this.declinedAuthors.clear();
|
|
503
|
-
this.approvedAuthors.clear();
|
|
620
|
+
if (entered) {
|
|
504
621
|
this.lastModsReadAt = 0;
|
|
505
622
|
this.gridEnteredAt = Date.now();
|
|
506
623
|
this.overlay.clear();
|
|
624
|
+
if (this.state.clientModsAvailable) {
|
|
625
|
+
this.clientHalves().enterGrid(
|
|
626
|
+
grid ? { gridId: grid.gridId, bounds: toBrokerBounds(grid.bounds) } : null,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
507
629
|
}
|
|
508
630
|
this.state = { ...this.state, grid };
|
|
509
631
|
this.emit();
|
|
@@ -522,7 +644,7 @@ export class StudioService {
|
|
|
522
644
|
return Number.isFinite(px) ? px : 0;
|
|
523
645
|
}
|
|
524
646
|
|
|
525
|
-
/** Periodic: which grid are we on, and are there
|
|
647
|
+
/** Periodic: which grid are we on, and are there CLIENT halves to run for us? */
|
|
526
648
|
private async poll(): Promise<void> {
|
|
527
649
|
if (!this.session.joined || !this.session.network.hasGame) return;
|
|
528
650
|
const selfChunk = this.session.world.self.chunk;
|
|
@@ -538,118 +660,31 @@ export class StudioService {
|
|
|
538
660
|
this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
|
|
539
661
|
}
|
|
540
662
|
}
|
|
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
|
-
}
|
|
663
|
+
if (now - this.lastModsReadAt >= MODS_REFRESH_MS) await this.refreshClientHalves();
|
|
549
664
|
}
|
|
550
665
|
|
|
551
666
|
/**
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
667
|
+
* Reconcile the grid's CLIENT halves: start the consented ones, ask about
|
|
668
|
+
* the rest, stop what changed or went. Waits out the presence race after
|
|
669
|
+
* entering a grid.
|
|
555
670
|
*/
|
|
556
|
-
private async
|
|
557
|
-
const
|
|
558
|
-
if (this.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
this.lastModsReadAt = now;
|
|
562
|
-
this.modsInFlight = true;
|
|
671
|
+
private async refreshClientHalves(): Promise<void> {
|
|
672
|
+
const halves = this.halves;
|
|
673
|
+
if (!halves || !this.currentGrid || !this.state.clientModsAvailable) return;
|
|
674
|
+
if (Date.now() - this.gridEnteredAt < GRID_SETTLE_MS) return;
|
|
675
|
+
this.lastModsReadAt = Date.now();
|
|
563
676
|
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
|
-
}
|
|
677
|
+
await halves.refresh();
|
|
611
678
|
} 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();
|
|
679
|
+
this.session.network.log(`grid CLIENT halves unavailable: ${messageOf(error)}`);
|
|
617
680
|
}
|
|
618
681
|
}
|
|
619
682
|
|
|
620
|
-
private async
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
private async confirmTrust(summary: string): Promise<boolean> {
|
|
652
|
-
if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary);
|
|
683
|
+
private async confirmTrust(
|
|
684
|
+
summary: string,
|
|
685
|
+
context: { prompt: ExecClientHalfPrompt; signal: AbortSignal },
|
|
686
|
+
): Promise<boolean> {
|
|
687
|
+
if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary, context);
|
|
653
688
|
return window.confirm(summary);
|
|
654
689
|
}
|
|
655
690
|
|
|
@@ -658,29 +693,6 @@ export class StudioService {
|
|
|
658
693
|
}
|
|
659
694
|
}
|
|
660
695
|
|
|
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
|
-
}
|
|
696
|
+
function stringCorner(corner: { x: bigint; y: bigint; z: bigint }) {
|
|
697
|
+
return { x: corner.x.toString(), y: corner.y.toString(), z: corner.z.toString() };
|
|
686
698
|
}
|