@crowdedkingdoms/construct 0.2.3-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 +6 -8
- package/package.json +3 -3
- package/src/grid/GridProgramRunner.ts +3 -9
- package/src/index.ts +0 -1
- 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 +14 -34
- package/src/platform/studio/clientHalves.test.ts +21 -1
- package/src/platform/studio/clientHalves.ts +23 -2
- package/src/platform/studio/clientModHost.test.ts +1 -3
- package/src/platform/studio/clientModHost.ts +1 -125
- package/src/platform/studio/ClientModLifecycle.test.ts +0 -56
- package/src/platform/studio/ClientModLifecycle.ts +0 -107
package/README.md
CHANGED
|
@@ -15,10 +15,9 @@ and the games built on it share.
|
|
|
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
|
-
`import.meta.env`). Peer dependencies: `@crowdedkingdoms/crowdyjs`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
pane, `@crowdedkingdoms/crowdy-dsh` 0.4+. On a
|
|
18
|
+
`import.meta.env`). Peer dependencies: `@crowdedkingdoms/crowdyjs` 18 (0.3.0
|
|
19
|
+
and later; the 0.2 line peers on 17) and, for the Studio agent pane,
|
|
20
|
+
`@crowdedkingdoms/crowdy-dsh` 0.4+. On a
|
|
22
21
|
prerelease tier the ranges name each `X.Y.Z` line they admit, because npm
|
|
23
22
|
matches a prerelease only against a comparator on the same `X.Y.Z`. A game can
|
|
24
23
|
take a new CrowdyJS line only once a construct release names it (until then
|
|
@@ -78,7 +77,6 @@ plugins: constructHeaderPlugins({
|
|
|
78
77
|
|
|
79
78
|
`@crowdedkingdoms/construct/platform/onboarding/steps` provisions an app
|
|
80
79
|
(org, app, access tier, redirect URIs, grid claim policy, your ck-exec code
|
|
81
|
-
(`deployExec`: `execBuild` + `execDeploy`),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`commonFiles`.
|
|
80
|
+
(`deployExec`: `execBuild` + `execDeploy`), Studio Common Files, agent policy).
|
|
81
|
+
Pass your own `exec` (manifest, crates, and a client holding your session on
|
|
82
|
+
the app's datacenter) and `commonFiles`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-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": ">=
|
|
49
|
-
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1"
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=18.0.0-dev.0 <19",
|
|
49
|
+
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@crowdedkingdoms/crowdy-dsh": {
|
|
@@ -56,10 +56,7 @@ export class GridProgramRunner implements CrowdyStudioDshGridHost {
|
|
|
56
56
|
const scope = this.options.client().grid(this.options.appId(), gridId);
|
|
57
57
|
const token = await scope.mintToken(60).catch(() => null);
|
|
58
58
|
if (!token) return null;
|
|
59
|
-
const
|
|
60
|
-
scope.channels.list().catch(() => []),
|
|
61
|
-
scope.sessions.list({ status: 'active' }).catch(() => []),
|
|
62
|
-
]);
|
|
59
|
+
const channels = await scope.channels.list().catch(() => []);
|
|
63
60
|
return {
|
|
64
61
|
appId: this.options.appId(),
|
|
65
62
|
gridId,
|
|
@@ -67,11 +64,8 @@ export class GridProgramRunner implements CrowdyStudioDshGridHost {
|
|
|
67
64
|
high: token.highChunk,
|
|
68
65
|
owned: this.options.owns?.(gridId) ?? false,
|
|
69
66
|
channels: channels.map((c) => ({ groupId: String(c.groupId), name: c.name })),
|
|
70
|
-
sessions
|
|
71
|
-
|
|
72
|
-
name: s.name ?? null,
|
|
73
|
-
status: s.status,
|
|
74
|
-
})),
|
|
67
|
+
// Grid sessions were the legacy game model's; the bridge's field stays until its protocol moves.
|
|
68
|
+
sessions: [],
|
|
75
69
|
};
|
|
76
70
|
}
|
|
77
71
|
|
package/src/index.ts
CHANGED
|
@@ -75,8 +75,6 @@ export interface OnboardingExec {
|
|
|
75
75
|
export interface RunOnboardingOptions {
|
|
76
76
|
/** Origins to register as the app's redirect URIs (hosted sign-in + CORS). */
|
|
77
77
|
redirectOrigins?: string[];
|
|
78
|
-
/** A Game Model's Game Kit blueprints, for a game that keeps one. */
|
|
79
|
-
blueprints?: unknown[];
|
|
80
78
|
/** The game's ck-exec code, built and deployed as a step (the starter's world hub). */
|
|
81
79
|
exec?: OnboardingExec;
|
|
82
80
|
/** Studio Common Files to publish (starter templates); none by default. */
|
|
@@ -131,11 +129,6 @@ export function deployExec(
|
|
|
131
129
|
input: DeployExecInput,
|
|
132
130
|
log?: Logger,
|
|
133
131
|
): Promise<{ buildId: string; version: number; restarted: string[] }>;
|
|
134
|
-
export function deployModel(
|
|
135
|
-
game: CrowdyClient,
|
|
136
|
-
input: { appId: string; blueprints: unknown[] },
|
|
137
|
-
log?: Logger,
|
|
138
|
-
): Promise<unknown>;
|
|
139
132
|
export function publishStarterFiles(
|
|
140
133
|
game: CrowdyClient,
|
|
141
134
|
input: { appId: string; commonFiles?: OnboardingCommonFile[] },
|
|
@@ -198,8 +198,8 @@ export async function ensureConstructorTier(identity, { appId, userId }, log = n
|
|
|
198
198
|
// 2026-09-07 the replay path of grantAppAccess answered a DateTime
|
|
199
199
|
// serialization error on dev, and "already on this tier" is the honest
|
|
200
200
|
// idempotent answer anyway.
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
201
|
+
const held = await accessOf(identity, appId, userId);
|
|
202
|
+
if (held && String(held.tierId) === String(tier.tierId) && held.status !== 'revoked') {
|
|
203
203
|
log(`User ${userId} already holds "${CONSTRUCTOR_TIER_NAME}"`);
|
|
204
204
|
return { tier, created, updated, granted: false };
|
|
205
205
|
}
|
|
@@ -208,6 +208,27 @@ export async function ensureConstructorTier(identity, { appId, userId }, log = n
|
|
|
208
208
|
return { tier, created, updated, granted: true };
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** `userId`'s access to the app: the caller's own grant, or theirs among the app's grants. */
|
|
212
|
+
async function accessOf(identity, appId, userId) {
|
|
213
|
+
let me = null;
|
|
214
|
+
try {
|
|
215
|
+
me = await identity.users.me();
|
|
216
|
+
} catch {
|
|
217
|
+
// Unknown caller: look the user up among the app's grants.
|
|
218
|
+
}
|
|
219
|
+
if (me && String(me.userId) === String(userId)) {
|
|
220
|
+
return identity.appAccess.myAccess(appId).catch(() => null);
|
|
221
|
+
}
|
|
222
|
+
const page = 100;
|
|
223
|
+
for (let offset = 0; ; offset += page) {
|
|
224
|
+
const rows = await identity.appAccess
|
|
225
|
+
.usersByApp(appId, { limit: page, offset })
|
|
226
|
+
.catch(() => []);
|
|
227
|
+
const row = rows.find((r) => String(r.userId) === String(userId));
|
|
228
|
+
if (row || rows.length < page) return row ?? null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
211
232
|
/** 4. Players may claim an unowned chunk for themselves (the Claim pad). */
|
|
212
233
|
export async function ensureSelfClaimPolicy(client, { appId }, log = noop) {
|
|
213
234
|
const current = await client.marketplace.gridClaimPolicy({ appId }).catch(() => null);
|
|
@@ -286,54 +307,6 @@ export async function deployExec(
|
|
|
286
307
|
return { buildId: build.buildId, version, restarted: restart };
|
|
287
308
|
}
|
|
288
309
|
|
|
289
|
-
/**
|
|
290
|
-
* Deploy a Game Model (kit blueprints), for a game that keeps one. The Construct
|
|
291
|
-
* starter keeps its state in its ck-exec world hub instead (`deployExec`).
|
|
292
|
-
*
|
|
293
|
-
* `gameModelSeed` upserts DEFINITIONS (types, properties, functions) by name,
|
|
294
|
-
* but seed CONTAINERS are instances and are created every time they are sent.
|
|
295
|
-
* Re-running a seed that lists containers therefore duplicates them — measured
|
|
296
|
-
* on 2026-09-07: two runs, two `WorldState`s. So before deploying, drop every
|
|
297
|
-
* seed container whose type already has an instance with the same display
|
|
298
|
-
* name. Definitions still refresh; existing data is left alone.
|
|
299
|
-
*/
|
|
300
|
-
export async function deployModel(game, { appId, blueprints }, log = noop) {
|
|
301
|
-
if (!Array.isArray(blueprints)) {
|
|
302
|
-
throw new Error("deployModel needs the game's blueprints (e.g. constructBlueprints())");
|
|
303
|
-
}
|
|
304
|
-
for (const blueprint of blueprints) {
|
|
305
|
-
if (!blueprint.containers?.length) continue;
|
|
306
|
-
const keep = [];
|
|
307
|
-
const seenTypes = new Map();
|
|
308
|
-
for (const container of blueprint.containers) {
|
|
309
|
-
let existing = seenTypes.get(container.typeName);
|
|
310
|
-
if (!existing) {
|
|
311
|
-
existing = await game.gameModel
|
|
312
|
-
.containers({ appId, typeName: container.typeName })
|
|
313
|
-
.catch(() => []);
|
|
314
|
-
seenTypes.set(container.typeName, existing);
|
|
315
|
-
}
|
|
316
|
-
if (existing.some((row) => row.displayName === container.displayName)) {
|
|
317
|
-
log(
|
|
318
|
-
`Container "${container.displayName}" (${container.typeName}) already exists; not re-seeding`,
|
|
319
|
-
);
|
|
320
|
-
} else {
|
|
321
|
-
keep.push(container);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
blueprint.containers = keep;
|
|
325
|
-
}
|
|
326
|
-
const result = await game.kit(appId).deploy(blueprints);
|
|
327
|
-
const seed = result.seed ?? {};
|
|
328
|
-
log(
|
|
329
|
-
`Model deployed: ${seed.containerTypesCreated ?? 0} new types, ` +
|
|
330
|
-
`${seed.functionsCreated ?? 0} new functions, ${seed.containersCreated ?? 0} new containers, ` +
|
|
331
|
-
`${result.automations?.length ?? 0} automations upserted`,
|
|
332
|
-
);
|
|
333
|
-
for (const warning of result.warnings ?? seed.warnings ?? []) log(` seed warning: ${warning}`);
|
|
334
|
-
return result;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
310
|
const PUBLISH_COMMON = `mutation PublishCommon($input: PublishCrowdyStudioCommonFileInput!) {
|
|
338
311
|
crowdyStudioCommonPublish(input: $input) { commonFileId slug versionId versionNo }
|
|
339
312
|
}`;
|
|
@@ -516,8 +489,8 @@ function messageOf(error) {
|
|
|
516
489
|
/**
|
|
517
490
|
* The whole sequence. `enterApp(appId)` must return a client holding an
|
|
518
491
|
* app-scoped token for `appId` (browser: NetworkManager.enterApp; Node: mint
|
|
519
|
-
* and build a client). `exec` (the game's ck-exec code) and
|
|
520
|
-
*
|
|
492
|
+
* and build a client). `exec` (the game's ck-exec code) is built and deployed
|
|
493
|
+
* when given. Returns a report the UI renders.
|
|
521
494
|
*/
|
|
522
495
|
export async function runOnboarding(options) {
|
|
523
496
|
const {
|
|
@@ -529,7 +502,6 @@ export async function runOnboarding(options) {
|
|
|
529
502
|
appSlug,
|
|
530
503
|
datacenter,
|
|
531
504
|
redirectOrigins = [],
|
|
532
|
-
blueprints,
|
|
533
505
|
exec,
|
|
534
506
|
commonFiles = [],
|
|
535
507
|
log = noop,
|
|
@@ -577,9 +549,6 @@ export async function runOnboarding(options) {
|
|
|
577
549
|
}
|
|
578
550
|
const game = await step('enter', 'App token', () => enterApp(appId));
|
|
579
551
|
await step('claims', 'Grid claim policy', () => ensureSelfClaimPolicy(identity, { appId }, log));
|
|
580
|
-
if (blueprints) {
|
|
581
|
-
await step('model', 'Game model', () => deployModel(game, { appId, blueprints }, log));
|
|
582
|
-
}
|
|
583
552
|
if (exec) {
|
|
584
553
|
await step('exec', 'ck-exec code', async () => {
|
|
585
554
|
const client = await exec.client(appId);
|
|
@@ -35,6 +35,9 @@ function identityStub(overrides: Any = {}): Any {
|
|
|
35
35
|
return { appId: '77', name: input.name, slug: input.slug };
|
|
36
36
|
}),
|
|
37
37
|
},
|
|
38
|
+
users: {
|
|
39
|
+
me: vi.fn(async () => ({ userId: overrides.callerId ?? '42' })),
|
|
40
|
+
},
|
|
38
41
|
appAccess: {
|
|
39
42
|
tiers: vi.fn(async () => overrides.tiers ?? []),
|
|
40
43
|
createTier: vi.fn(async (input: Any) => {
|
|
@@ -46,6 +49,7 @@ function identityStub(overrides: Any = {}): Any {
|
|
|
46
49
|
return { tierId: id, name: 'Constructor', permissionKeys: input.permissionKeys };
|
|
47
50
|
}),
|
|
48
51
|
myAccess: vi.fn(async () => overrides.myAccess ?? null),
|
|
52
|
+
usersByApp: vi.fn(async () => overrides.usersByApp ?? []),
|
|
49
53
|
grant: vi.fn(async (input: Any) => {
|
|
50
54
|
created.push(['grant', input]);
|
|
51
55
|
return { tierId: input.tierId };
|
|
@@ -160,6 +164,43 @@ describe('onboarding steps', () => {
|
|
|
160
164
|
expect(already.appAccess.updateTier).not.toHaveBeenCalled();
|
|
161
165
|
});
|
|
162
166
|
|
|
167
|
+
it("grants another user by that user's access, not the caller's", async () => {
|
|
168
|
+
const tiers = [
|
|
169
|
+
{
|
|
170
|
+
tierId: 'd',
|
|
171
|
+
name: 'Default',
|
|
172
|
+
isDefault: true,
|
|
173
|
+
status: 'active',
|
|
174
|
+
permissionKeys: ['access', ...steps.VISITOR_RUN_KEYS],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
tierId: 't1',
|
|
178
|
+
name: 'Constructor',
|
|
179
|
+
status: 'active',
|
|
180
|
+
permissionKeys: [...steps.CONSTRUCTOR_TIER_KEYS],
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
// The caller (an org admin) holds Constructor; user 42 holds the default tier.
|
|
184
|
+
const admin = identityStub({
|
|
185
|
+
callerId: '1',
|
|
186
|
+
tiers,
|
|
187
|
+
myAccess: { tierId: 't1', status: 'active' },
|
|
188
|
+
usersByApp: [{ userId: '42', tierId: 'd', status: 'active' }],
|
|
189
|
+
});
|
|
190
|
+
const a = await steps.ensureConstructorTier(admin as never, { appId: '77', userId: '42' });
|
|
191
|
+
expect(a.granted).toBe(true);
|
|
192
|
+
expect(admin.appAccess.grant).toHaveBeenCalledWith({ appId: '77', userId: '42', tierId: 't1' });
|
|
193
|
+
|
|
194
|
+
const holds = identityStub({
|
|
195
|
+
callerId: '1',
|
|
196
|
+
tiers,
|
|
197
|
+
usersByApp: [{ userId: '42', tierId: 't1', status: 'active' }],
|
|
198
|
+
});
|
|
199
|
+
const b = await steps.ensureConstructorTier(holds as never, { appId: '77', userId: '42' });
|
|
200
|
+
expect(b.granted).toBe(false);
|
|
201
|
+
expect(holds.appAccess.grant).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
163
204
|
it('only sets the claim policy when it differs', async () => {
|
|
164
205
|
const game = {
|
|
165
206
|
marketplace: {
|
|
@@ -179,37 +220,6 @@ describe('onboarding steps', () => {
|
|
|
179
220
|
});
|
|
180
221
|
});
|
|
181
222
|
|
|
182
|
-
it('does not re-seed containers that already exist', async () => {
|
|
183
|
-
const deploy = vi.fn(async () => ({
|
|
184
|
-
seed: { containersCreated: 0 },
|
|
185
|
-
automations: [],
|
|
186
|
-
warnings: [],
|
|
187
|
-
}));
|
|
188
|
-
const game = {
|
|
189
|
-
kit: () => ({ deploy }),
|
|
190
|
-
gameModel: {
|
|
191
|
-
containers: vi.fn(async ({ typeName }: Any) =>
|
|
192
|
-
typeName === 'WorldState' ? [{ displayName: 'The Construct' }] : [],
|
|
193
|
-
),
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
await steps.deployModel(game as never, {
|
|
197
|
-
appId: '77',
|
|
198
|
-
blueprints: [
|
|
199
|
-
{
|
|
200
|
-
name: 'construct-world',
|
|
201
|
-
containers: [
|
|
202
|
-
{ typeName: 'WorldState', displayName: 'The Construct' },
|
|
203
|
-
{ typeName: 'Program', displayName: 'Paint' },
|
|
204
|
-
],
|
|
205
|
-
},
|
|
206
|
-
],
|
|
207
|
-
});
|
|
208
|
-
const blueprints = (deploy.mock.calls as Any[][])[0]![0] as Any[];
|
|
209
|
-
const world = blueprints.find((b) => b.name === 'construct-world')!;
|
|
210
|
-
expect(world.containers.map((c: Any) => c.typeName)).toEqual(['Program']);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
223
|
const execManifest = {
|
|
214
224
|
root: 'construct',
|
|
215
225
|
types: {
|
|
@@ -1,9 +1,8 @@
|
|
|
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 runs as a ck-exec mod
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* grid call as `mod:<name>`; its CLIENT target is a `crowdy-client-sdk` crate
|
|
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
|
|
7
6
|
* the platform builds as that mod's CLIENT half, which the grid serves to the
|
|
8
7
|
* players who stand in it.
|
|
9
8
|
*
|
|
@@ -35,7 +34,6 @@ import {
|
|
|
35
34
|
CrowdyStudioTextHud,
|
|
36
35
|
type CrowdyStudioController,
|
|
37
36
|
type CrowdyStudioEmbedContext,
|
|
38
|
-
type CrowdyStudioPlayerCompute,
|
|
39
37
|
} from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
40
38
|
import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
|
|
41
39
|
|
|
@@ -122,29 +120,6 @@ function createConstructTextHud(): CrowdyStudioTextHud {
|
|
|
122
120
|
return hud;
|
|
123
121
|
}
|
|
124
122
|
|
|
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
|
-
|
|
148
123
|
const GRID_REFRESH_MS = 8_000;
|
|
149
124
|
const MODS_REFRESH_MS = 10_000;
|
|
150
125
|
/**
|
|
@@ -232,8 +207,9 @@ export class StudioService {
|
|
|
232
207
|
get crowdyStudio() {
|
|
233
208
|
return network().game.crowdyStudio;
|
|
234
209
|
},
|
|
235
|
-
|
|
236
|
-
//
|
|
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).
|
|
237
213
|
get exec() {
|
|
238
214
|
return network().game.exec;
|
|
239
215
|
},
|
|
@@ -253,9 +229,6 @@ export class StudioService {
|
|
|
253
229
|
},
|
|
254
230
|
appId: () => this.session.appId,
|
|
255
231
|
gameName: GAME_NAME,
|
|
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).
|
|
258
|
-
serverEngine: 'ck-exec',
|
|
259
232
|
closeKeyCode: 'KeyM',
|
|
260
233
|
dsh: {
|
|
261
234
|
graphql: network().game.graphql,
|
|
@@ -451,7 +424,14 @@ export class StudioService {
|
|
|
451
424
|
): (call: PlayerCodeHostCall) => Promise<unknown> {
|
|
452
425
|
const game = this.session.network.game;
|
|
453
426
|
const scope = game.grid(this.session.appId, gridId, toBrokerBounds(bounds));
|
|
454
|
-
return createGridHostCalls({
|
|
427
|
+
return createGridHostCalls({
|
|
428
|
+
scope,
|
|
429
|
+
client: game,
|
|
430
|
+
local: {
|
|
431
|
+
gridPermissionKeys: () =>
|
|
432
|
+
this.currentGrid?.gridId === gridId ? this.currentGrid.permissions.effectiveKeys : [],
|
|
433
|
+
},
|
|
434
|
+
});
|
|
455
435
|
}
|
|
456
436
|
|
|
457
437
|
/**
|
|
@@ -64,6 +64,7 @@ function harness(
|
|
|
64
64
|
self?: string;
|
|
65
65
|
confirm?: (prompt: unknown, signal: AbortSignal) => Promise<boolean>;
|
|
66
66
|
filter?: (mod: { name: string }) => boolean;
|
|
67
|
+
log?: (line: string) => void;
|
|
67
68
|
} = {},
|
|
68
69
|
) {
|
|
69
70
|
const listings = options.listings ?? [[half({ callerConsented: true })]];
|
|
@@ -95,6 +96,7 @@ function harness(
|
|
|
95
96
|
options: {
|
|
96
97
|
onHostCall(call: unknown): Promise<unknown>;
|
|
97
98
|
onPresentation?(presentation: unknown): void;
|
|
99
|
+
onLog?(line: unknown): void;
|
|
98
100
|
consentedHostCalls?: readonly string[];
|
|
99
101
|
engine?: string;
|
|
100
102
|
};
|
|
@@ -117,11 +119,16 @@ function harness(
|
|
|
117
119
|
overlay: overlay as never,
|
|
118
120
|
...(options.confirm ? { confirm: options.confirm as never } : {}),
|
|
119
121
|
...(options.filter ? { filter: options.filter as never } : {}),
|
|
122
|
+
...(options.log ? { log: options.log } : {}),
|
|
120
123
|
onChange: (running) => changes.push(running.length),
|
|
121
124
|
brokerFactory: (brokerOptions) => {
|
|
122
125
|
const broker = { options: brokerOptions as never, stop: vi.fn() };
|
|
123
126
|
brokers.push(broker);
|
|
124
|
-
return {
|
|
127
|
+
return {
|
|
128
|
+
start: async () => {},
|
|
129
|
+
stop: broker.stop,
|
|
130
|
+
invoke: async (payload: Uint8Array) => new Uint8Array([...payload].reverse()),
|
|
131
|
+
};
|
|
125
132
|
},
|
|
126
133
|
});
|
|
127
134
|
return { halves, exec, brokers, hud, overlay, actorsInChunk, serverCall, serverCalls, changes };
|
|
@@ -159,6 +166,19 @@ describe('GridClientHalves', () => {
|
|
|
159
166
|
expect(h.serverCalls).toHaveBeenCalledWith(GRID);
|
|
160
167
|
});
|
|
161
168
|
|
|
169
|
+
it("forwards a CLIENT half's log lines and invokes its handle_invoke", async () => {
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
const h = harness({ log: (line) => lines.push(line) });
|
|
172
|
+
h.halves.enterGrid(GRID);
|
|
173
|
+
await h.halves.refresh();
|
|
174
|
+
h.brokers[0]!.options.onLog?.({ level: 'info', message: 'ready', moduleName: 'greeter' });
|
|
175
|
+
expect(lines).toContain('CLIENT half of mod greeter info: ready');
|
|
176
|
+
await expect(h.halves.invoke('m1', new Uint8Array([1, 2, 3]))).resolves.toEqual(
|
|
177
|
+
new Uint8Array([3, 2, 1]),
|
|
178
|
+
);
|
|
179
|
+
await expect(h.halves.invoke('nope', new Uint8Array())).rejects.toThrow();
|
|
180
|
+
});
|
|
181
|
+
|
|
162
182
|
it('asks about another author, and a yes trusts them at the hash the grid listed', async () => {
|
|
163
183
|
const asked: unknown[] = [];
|
|
164
184
|
const h = harness({
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* into the text HUD and the holodeck overlay. CrowdyJS's `ExecClientHalves`
|
|
5
5
|
* lists what the grid serves, asks, fetches by digest and sandboxes each half
|
|
6
6
|
* in a `PlayerCodeBroker` (`engine: 'ck-exec'`); this adds what only the game
|
|
7
|
-
* knows.
|
|
8
|
-
* the legacy grid-attached client mods.
|
|
7
|
+
* knows.
|
|
9
8
|
*
|
|
10
9
|
* The player's own CLIENT halves run without a question (on your own grid
|
|
11
10
|
* every half is yours), unless one was installed from a marketplace listing:
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
type ExecGridClientMod,
|
|
23
22
|
type PlayerCodeGridBounds,
|
|
24
23
|
type PlayerCodeHostCall,
|
|
24
|
+
type PlayerCodeLogLine,
|
|
25
25
|
type PlayerCodePresentation,
|
|
26
26
|
} from '@crowdedkingdoms/crowdyjs';
|
|
27
27
|
import {
|
|
@@ -80,6 +80,11 @@ export interface GridClientHalvesOptions {
|
|
|
80
80
|
filter?: (mod: ExecGridClientMod) => boolean;
|
|
81
81
|
/** After a CLIENT half starts or stops. */
|
|
82
82
|
onChange?: (running: readonly ExecGridClientMod[]) => void;
|
|
83
|
+
/**
|
|
84
|
+
* A CLIENT half's `crowdy::log` lines (rate- and length-capped by the broker). The text is
|
|
85
|
+
* the mod author's: render it as text. Without it they go to `log`.
|
|
86
|
+
*/
|
|
87
|
+
onLog?: (line: PlayerCodeLogLine, mod: ExecGridClientMod) => void;
|
|
83
88
|
log?: (line: string) => void;
|
|
84
89
|
brokerFactory?: ExecClientHalvesOptions['brokerFactory'];
|
|
85
90
|
now?: () => number;
|
|
@@ -114,6 +119,10 @@ export class GridClientHalves {
|
|
|
114
119
|
},
|
|
115
120
|
onStopped: (mod, reason) => this.stopped(mod, reason),
|
|
116
121
|
onError: (error) => this.options.log?.(this.describeError(error)),
|
|
122
|
+
onLog: (line, mod) => {
|
|
123
|
+
if (this.options.onLog) this.options.onLog(line, mod);
|
|
124
|
+
else this.options.log?.(`CLIENT half of mod ${mod.name} ${line.level}: ${line.message}`);
|
|
125
|
+
},
|
|
117
126
|
...(options.brokerFactory ? { brokerFactory: options.brokerFactory } : {}),
|
|
118
127
|
...(options.now ? { now: options.now } : {}),
|
|
119
128
|
});
|
|
@@ -141,6 +150,18 @@ export class GridClientHalves {
|
|
|
141
150
|
return this.runner.refresh();
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Calls the `handle_invoke` export of the running CLIENT half of mod `modId`. The reply is
|
|
155
|
+
* the mod author's bytes: treat them as untrusted input.
|
|
156
|
+
*/
|
|
157
|
+
invoke(
|
|
158
|
+
modId: string,
|
|
159
|
+
payload: Uint8Array,
|
|
160
|
+
options?: { timeoutMs?: number },
|
|
161
|
+
): Promise<Uint8Array> {
|
|
162
|
+
return this.runner.invoke(modId, payload, options);
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
stop(): void {
|
|
145
166
|
this.asking?.abort();
|
|
146
167
|
this.asking = null;
|
|
@@ -10,19 +10,8 @@
|
|
|
10
10
|
* return only what the running player can already lawfully see. Writes go
|
|
11
11
|
* through ChunkStore.setVoxel (optimistic + UDP) and markDirty (durable
|
|
12
12
|
* write-back), the same route Paint uses. Never hand a mod the raw client.
|
|
13
|
-
*
|
|
14
|
-
* `runConsentedGridMod` starts a legacy grid-attached client mod, until
|
|
15
|
-
* CrowdyJS 18 removes them.
|
|
16
13
|
*/
|
|
17
|
-
import {
|
|
18
|
-
PlayerCodeBroker,
|
|
19
|
-
type CrowdyClient,
|
|
20
|
-
type PlayerCodeGridBounds,
|
|
21
|
-
type PlayerCodeHostCall,
|
|
22
|
-
type PlayerCodePresentation,
|
|
23
|
-
} from '@crowdedkingdoms/crowdyjs';
|
|
24
|
-
import type { CrowdyStudioTextHud } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
25
|
-
import type { ModOverlayStore } from './modOverlay';
|
|
14
|
+
import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
|
|
26
15
|
|
|
27
16
|
export interface ClientModHostReads {
|
|
28
17
|
actorsInChunk(x: bigint, y: bigint, z: bigint): Array<Record<string, unknown>>;
|
|
@@ -242,119 +231,6 @@ export function bytesToBase64(bytes: Uint8Array): string {
|
|
|
242
231
|
return btoa(binary);
|
|
243
232
|
}
|
|
244
233
|
|
|
245
|
-
interface CachedArtifact {
|
|
246
|
-
bytes: Uint8Array;
|
|
247
|
-
artifactHash?: string;
|
|
248
|
-
fuelPerDispatch?: string | bigint;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const artifactCache = new Map<string, CachedArtifact>();
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Fetch a consented grid-attached client mod's artifact and run it in a
|
|
255
|
-
* hash-verified broker with the same allowlist, grid clamp, rate caps and
|
|
256
|
-
* circuit breaker as the Studio's own Test/Run path. Its HUD output lands in
|
|
257
|
-
* the persistent text HUD. Returns null (fails closed) when the artifact
|
|
258
|
-
* cannot be fetched.
|
|
259
|
-
*
|
|
260
|
-
* @deprecated A legacy grid-attached client mod (`marketplace.gridClientMods`),
|
|
261
|
-
* which CrowdyJS 18 removes. A ck-exec mod's CLIENT half runs through
|
|
262
|
-
* `GridClientHalves` (`clientHalves.ts`).
|
|
263
|
-
*/
|
|
264
|
-
export async function runConsentedGridMod(options: {
|
|
265
|
-
client: CrowdyClient;
|
|
266
|
-
appId: string;
|
|
267
|
-
attachmentId: string;
|
|
268
|
-
artifactCacheKey: string;
|
|
269
|
-
hudSource: string;
|
|
270
|
-
hudLabel: string;
|
|
271
|
-
/** The module's name, which addressed grid events target (default `hudLabel`). */
|
|
272
|
-
moduleName?: string;
|
|
273
|
-
grid: PlayerCodeGridBounds;
|
|
274
|
-
workerUrl: string;
|
|
275
|
-
reads: ClientModHostReads;
|
|
276
|
-
writes?: ClientModHostWrites;
|
|
277
|
-
hud: CrowdyStudioTextHud;
|
|
278
|
-
overlay?: ModOverlayStore;
|
|
279
|
-
input?: ClientModHostInput;
|
|
280
|
-
/**
|
|
281
|
-
* Answers the host calls this game does not route itself (DN-10: the rest
|
|
282
|
-
* of the client catalog, through CrowdyJS `createGridHostCalls`).
|
|
283
|
-
*/
|
|
284
|
-
serverCalls?: (call: PlayerCodeHostCall) => Promise<unknown>;
|
|
285
|
-
/**
|
|
286
|
-
* How often the worker self-drives `on_tick`. Omitted/0 means invoke-only
|
|
287
|
-
* and a HUD mod then never runs (measured 2026-09-07: broker started, HUD
|
|
288
|
-
* stayed empty). Studio Test/Deploy reads `[package.metadata.crowdy]
|
|
289
|
-
* tick_interval_ms` from the CLIENT Cargo.toml (default 1000, clamped
|
|
290
|
-
* 16–1000). Pass this only for grid-attached visitors until that field is
|
|
291
|
-
* stored on the compiled version.
|
|
292
|
-
*/
|
|
293
|
-
tickIntervalMs?: number;
|
|
294
|
-
}): Promise<{ stop: () => void } | null> {
|
|
295
|
-
let fetched: { bytes: ArrayBuffer; artifactHash?: string; fuelPerDispatch?: string | bigint };
|
|
296
|
-
const cached = artifactCache.get(options.artifactCacheKey);
|
|
297
|
-
if (cached) {
|
|
298
|
-
fetched = {
|
|
299
|
-
bytes: cached.bytes.slice().buffer,
|
|
300
|
-
artifactHash: cached.artifactHash,
|
|
301
|
-
fuelPerDispatch: cached.fuelPerDispatch,
|
|
302
|
-
};
|
|
303
|
-
} else {
|
|
304
|
-
try {
|
|
305
|
-
fetched = await options.client.marketplace.clientArtifactBytes({
|
|
306
|
-
appId: options.appId,
|
|
307
|
-
attachmentId: options.attachmentId,
|
|
308
|
-
});
|
|
309
|
-
} catch {
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
artifactCache.set(options.artifactCacheKey, {
|
|
313
|
-
bytes: new Uint8Array(fetched.bytes).slice(),
|
|
314
|
-
artifactHash: fetched.artifactHash,
|
|
315
|
-
fuelPerDispatch: fetched.fuelPerDispatch,
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
const broker = new PlayerCodeBroker({
|
|
319
|
-
workerUrl: options.workerUrl,
|
|
320
|
-
grid: options.grid,
|
|
321
|
-
artifactHash: fetched.artifactHash,
|
|
322
|
-
fuelPerDispatch: fetched.fuelPerDispatch != null ? BigInt(fetched.fuelPerDispatch) : undefined,
|
|
323
|
-
tickIntervalMs: options.tickIntervalMs ?? 1000,
|
|
324
|
-
moduleName: options.moduleName ?? options.hudLabel,
|
|
325
|
-
onHostCall: (call) =>
|
|
326
|
-
routeWithFallback(
|
|
327
|
-
call,
|
|
328
|
-
() => routeClientHostCall(call, options.reads, options.grid, options.writes, options.input),
|
|
329
|
-
options.serverCalls,
|
|
330
|
-
),
|
|
331
|
-
onPresentation: (presentation: PlayerCodePresentation) => {
|
|
332
|
-
if (presentation.channel === 'hud') {
|
|
333
|
-
options.hud.set({
|
|
334
|
-
source: options.hudSource,
|
|
335
|
-
label: options.hudLabel,
|
|
336
|
-
payload: presentation.payload,
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
if (presentation.channel === 'overlay') {
|
|
340
|
-
options.overlay?.apply(options.hudSource, presentation.payload, options.grid);
|
|
341
|
-
}
|
|
342
|
-
},
|
|
343
|
-
onCircuitOpen: () => {
|
|
344
|
-
options.hud.remove(options.hudSource);
|
|
345
|
-
options.overlay?.remove(options.hudSource);
|
|
346
|
-
},
|
|
347
|
-
});
|
|
348
|
-
await broker.start(fetched.bytes);
|
|
349
|
-
return {
|
|
350
|
-
stop: () => {
|
|
351
|
-
broker.stop();
|
|
352
|
-
options.hud.remove(options.hudSource);
|
|
353
|
-
options.overlay?.remove(options.hudSource);
|
|
354
|
-
},
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
234
|
/**
|
|
359
235
|
* The game's own router first (local reads, optimistic writes); anything it
|
|
360
236
|
* does not offer goes to `serverCalls` when the game supplied it.
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { ClientModLifecycle } from './ClientModLifecycle';
|
|
4
|
-
|
|
5
|
-
const d = (id: string, artifactHash = 'a', capabilityHash = 'c') => ({
|
|
6
|
-
attachmentId: id,
|
|
7
|
-
artifactHash,
|
|
8
|
-
capabilityHash,
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('ClientModLifecycle', () => {
|
|
12
|
-
it('stops every worker when the grid changes', () => {
|
|
13
|
-
const life = new ClientModLifecycle();
|
|
14
|
-
expect(life.enterGrid('g1')).toBe(true);
|
|
15
|
-
const scope = life.scope('g1')!;
|
|
16
|
-
const stop = vi.fn();
|
|
17
|
-
expect(life.track(scope, d('m1'), { stop })).toBe(true);
|
|
18
|
-
expect(life.enterGrid('g1')).toBe(false);
|
|
19
|
-
expect(life.enterGrid('g2')).toBe(true);
|
|
20
|
-
expect(stop).toHaveBeenCalledTimes(1);
|
|
21
|
-
expect(life.runningCount).toBe(0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('rejects a worker that finished starting after the grid changed', () => {
|
|
25
|
-
const life = new ClientModLifecycle();
|
|
26
|
-
life.enterGrid('g1');
|
|
27
|
-
const stale = life.scope('g1')!;
|
|
28
|
-
life.enterGrid('g2');
|
|
29
|
-
const stop = vi.fn();
|
|
30
|
-
expect(life.track(stale, d('m1'), { stop })).toBe(false);
|
|
31
|
-
expect(stop).toHaveBeenCalled();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('reconciles by immutable hashes', () => {
|
|
35
|
-
const life = new ClientModLifecycle();
|
|
36
|
-
life.enterGrid('g1');
|
|
37
|
-
const scope = life.scope('g1')!;
|
|
38
|
-
const stopA = vi.fn();
|
|
39
|
-
const stopB = vi.fn();
|
|
40
|
-
life.track(scope, d('a'), { stop: stopA });
|
|
41
|
-
life.track(scope, d('b'), { stop: stopB });
|
|
42
|
-
expect(life.reconcile(scope, [d('a'), d('b', 'a2')])).toBe(true);
|
|
43
|
-
expect(stopA).not.toHaveBeenCalled();
|
|
44
|
-
expect(stopB).toHaveBeenCalled();
|
|
45
|
-
expect(life.has('a')).toBe(true);
|
|
46
|
-
expect(life.has('b')).toBe(false);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('is inert after shutdown', () => {
|
|
50
|
-
const life = new ClientModLifecycle();
|
|
51
|
-
life.enterGrid('g1');
|
|
52
|
-
life.shutdown();
|
|
53
|
-
expect(life.enterGrid('g2')).toBe(false);
|
|
54
|
-
expect(life.scope('g2')).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Owns the worker handles of grid-attached CLIENT mods independently of any
|
|
3
|
-
* scene. Every metadata refresh reconciles immutable hashes; changing grids
|
|
4
|
-
* synchronously tears everything down; a worker that finishes starting after
|
|
5
|
-
* a grid change (or shutdown) is stopped instead of attached stale.
|
|
6
|
-
*/
|
|
7
|
-
export interface ClientModDescriptor {
|
|
8
|
-
attachmentId: string;
|
|
9
|
-
artifactHash: string;
|
|
10
|
-
capabilityHash: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ClientModHandle {
|
|
14
|
-
stop: () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ClientModScope {
|
|
18
|
-
gridId: string;
|
|
19
|
-
generation: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface RunningClientMod extends ClientModDescriptor, ClientModHandle {}
|
|
23
|
-
|
|
24
|
-
/**
|
|
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
|
-
*/
|
|
29
|
-
export class ClientModLifecycle {
|
|
30
|
-
private readonly running = new Map<string, RunningClientMod>();
|
|
31
|
-
private activeGridId: string | null = null;
|
|
32
|
-
private generation = 0;
|
|
33
|
-
private stopped = false;
|
|
34
|
-
|
|
35
|
-
/** Returns true when the grid actually changed (callers reset their caches). */
|
|
36
|
-
enterGrid(gridId: string | null): boolean {
|
|
37
|
-
if (this.stopped) return false;
|
|
38
|
-
if (gridId === this.activeGridId) return false;
|
|
39
|
-
this.stopAll();
|
|
40
|
-
this.activeGridId = gridId;
|
|
41
|
-
this.generation++;
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
get currentGridId(): string | null {
|
|
46
|
-
return this.activeGridId;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
has(attachmentId: string): boolean {
|
|
50
|
-
return this.running.has(attachmentId);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
get runningCount(): number {
|
|
54
|
-
return this.running.size;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
scope(gridId: string): ClientModScope | null {
|
|
58
|
-
if (this.stopped || gridId !== this.activeGridId) return null;
|
|
59
|
-
return { gridId, generation: this.generation };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
isCurrent(scope: ClientModScope): boolean {
|
|
63
|
-
return (
|
|
64
|
-
!this.stopped && scope.gridId === this.activeGridId && scope.generation === this.generation
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
track(scope: ClientModScope, descriptor: ClientModDescriptor, handle: ClientModHandle): boolean {
|
|
69
|
-
if (!this.isCurrent(scope) || this.running.has(descriptor.attachmentId)) {
|
|
70
|
-
handle.stop();
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
this.running.set(descriptor.attachmentId, { ...descriptor, ...handle });
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Stop mods that disappeared or changed hash; returns false if scope is stale. */
|
|
78
|
-
reconcile(scope: ClientModScope, descriptors: readonly ClientModDescriptor[]): boolean {
|
|
79
|
-
if (!this.isCurrent(scope)) return false;
|
|
80
|
-
const current = new Map(descriptors.map((d) => [d.attachmentId, d]));
|
|
81
|
-
for (const [attachmentId, running] of this.running) {
|
|
82
|
-
const next = current.get(attachmentId);
|
|
83
|
-
if (
|
|
84
|
-
!next ||
|
|
85
|
-
next.artifactHash !== running.artifactHash ||
|
|
86
|
-
next.capabilityHash !== running.capabilityHash
|
|
87
|
-
) {
|
|
88
|
-
running.stop();
|
|
89
|
-
this.running.delete(attachmentId);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
private stopAll(): void {
|
|
96
|
-
for (const running of this.running.values()) running.stop();
|
|
97
|
-
this.running.clear();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
shutdown(): void {
|
|
101
|
-
if (this.stopped) return;
|
|
102
|
-
this.stopped = true;
|
|
103
|
-
this.activeGridId = null;
|
|
104
|
-
this.generation++;
|
|
105
|
-
this.stopAll();
|
|
106
|
-
}
|
|
107
|
-
}
|