@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,6 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it
|
|
2
|
-
|
|
3
|
-
vi.mock('@crowdedkingdoms/crowdyjs', () => ({ PlayerCodeBroker: class {} }));
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
4
2
|
|
|
5
3
|
const {
|
|
6
4
|
DEFAULT_VOXEL_STATE,
|
|
@@ -56,7 +54,7 @@ describe('routeClientHostCall', () => {
|
|
|
56
54
|
expect(out).toEqual({ actors: [{ uuid: 'me', name: 'neo' }] });
|
|
57
55
|
});
|
|
58
56
|
|
|
59
|
-
it('clamps actors_list_radius to the grid
|
|
57
|
+
it('clamps actors_list_radius to the grid', async () => {
|
|
60
58
|
const r = reads();
|
|
61
59
|
await routeClientHostCall(
|
|
62
60
|
{ fn: 'actors_list_radius', args: { x: '0', y: '0', z: '0', radius: 50 } } as never,
|
|
@@ -68,6 +66,23 @@ describe('routeClientHostCall', () => {
|
|
|
68
66
|
expect(r.calls.every((c) => c.split(',').every((v) => Math.abs(Number(v)) <= 1))).toBe(true);
|
|
69
67
|
});
|
|
70
68
|
|
|
69
|
+
it('reads crowdy-client-sdk radii and clamps them as the platform does (3 across, 1 up)', async () => {
|
|
70
|
+
const r = reads();
|
|
71
|
+
const wide = { low: { x: -9n, y: -9n, z: -9n }, high: { x: 9n, y: 9n, z: 9n } };
|
|
72
|
+
await routeClientHostCall(
|
|
73
|
+
{
|
|
74
|
+
fn: 'actors_list_radius',
|
|
75
|
+
args: { x: '0', y: '0', z: '0', radiusXz: 9, radiusY: 4 },
|
|
76
|
+
} as never,
|
|
77
|
+
r.reads,
|
|
78
|
+
wide,
|
|
79
|
+
);
|
|
80
|
+
expect(r.calls.length).toBe(7 * 3 * 7);
|
|
81
|
+
const ys = new Set(r.calls.map((c) => Number(c.split(',')[1])));
|
|
82
|
+
expect([...ys].sort()).toEqual([-1, 0, 1]);
|
|
83
|
+
expect(r.calls.every((c) => Math.abs(Number(c.split(',')[0])) <= 3)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
71
86
|
it('serves chunk_get as base64 and voxels_list as sparse rows', async () => {
|
|
72
87
|
const r = reads();
|
|
73
88
|
const dense = (await routeClientHostCall(
|
|
@@ -192,6 +207,28 @@ describe('routeClientHostCall', () => {
|
|
|
192
207
|
});
|
|
193
208
|
});
|
|
194
209
|
|
|
210
|
+
it('parses the voxel_set arguments crowdy-client-sdk sends', () => {
|
|
211
|
+
expect(
|
|
212
|
+
parseVoxelSetArgs({
|
|
213
|
+
chunkX: 3,
|
|
214
|
+
chunkY: 0,
|
|
215
|
+
chunkZ: 3,
|
|
216
|
+
voxelX: 8,
|
|
217
|
+
voxelY: 1,
|
|
218
|
+
voxelZ: 4,
|
|
219
|
+
voxelType: 2,
|
|
220
|
+
stateBase64: null,
|
|
221
|
+
}),
|
|
222
|
+
).toEqual({
|
|
223
|
+
chunk: { x: 3, y: 0, z: 3 },
|
|
224
|
+
x: 8,
|
|
225
|
+
y: 1,
|
|
226
|
+
z: 4,
|
|
227
|
+
voxelType: 2,
|
|
228
|
+
state: DEFAULT_VOXEL_STATE,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
195
232
|
it('decodes an empty grid to no rows', () => {
|
|
196
233
|
expect(voxelsFromBase64(null)).toEqual([]);
|
|
197
234
|
expect(voxelsFromBase64(bytesToBase64(new Uint8Array(4096)))).toEqual([]);
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The host side of CLIENT mods: what a sandboxed player module
|
|
3
|
-
*
|
|
2
|
+
* The host side of CLIENT mods: what a sandboxed player module (the Studio's
|
|
3
|
+
* preview, or a grid's CLIENT half, `clientHalves.ts`) may ask the game for.
|
|
4
4
|
*
|
|
5
5
|
* The SDK's `PlayerCodeBroker` has already validated every call against the
|
|
6
6
|
* platform allowlist, clamped chunk coordinates to the mod's grid AABB, rate-
|
|
7
7
|
* limited it, and answered `grid_info` / `hud_set` / `overlay_draw` itself.
|
|
8
8
|
* What reaches `routeClientHostCall` is world_read, `voxel_set`, and
|
|
9
|
-
* `pointer_clicks
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the raw client.
|
|
9
|
+
* `pointer_clicks`, in the argument shapes `crowdy-client-sdk` sends. Reads
|
|
10
|
+
* return only what the running player can already lawfully see. Writes go
|
|
11
|
+
* through ChunkStore.setVoxel (optimistic + UDP) and markDirty (durable
|
|
12
|
+
* write-back), the same route Paint uses. Never hand a mod the raw client.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
15
|
-
PlayerCodeBroker,
|
|
16
|
-
type CrowdyClient,
|
|
17
|
-
type PlayerCodeGridBounds,
|
|
18
|
-
type PlayerCodeHostCall,
|
|
19
|
-
type PlayerCodePresentation,
|
|
20
|
-
} from '@crowdedkingdoms/crowdyjs';
|
|
21
|
-
import type { CrowdyStudioTextHud } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
22
|
-
import type { ModOverlayStore } from './modOverlay';
|
|
14
|
+
import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
|
|
23
15
|
|
|
24
16
|
export interface ClientModHostReads {
|
|
25
17
|
actorsInChunk(x: bigint, y: bigint, z: bigint): Array<Record<string, unknown>>;
|
|
@@ -54,12 +46,19 @@ export const OFFERED_HOST_CALLS = [
|
|
|
54
46
|
/** One zero byte, base64: the smallest non-empty voxel state the API accepts. */
|
|
55
47
|
export const DEFAULT_VOXEL_STATE = 'AA==';
|
|
56
48
|
|
|
57
|
-
|
|
49
|
+
/** `actors_list_radius` reaches this far, as the platform clamps it. */
|
|
50
|
+
const MAX_RADIUS_XZ = 3;
|
|
51
|
+
const MAX_RADIUS_Y = 1;
|
|
58
52
|
|
|
59
53
|
function toBigInt(value: unknown): bigint {
|
|
60
54
|
return BigInt(value as string | number | bigint);
|
|
61
55
|
}
|
|
62
56
|
|
|
57
|
+
function radius(value: unknown, max: number): bigint {
|
|
58
|
+
const requested = Number(value ?? 0);
|
|
59
|
+
return BigInt(Number.isFinite(requested) ? Math.max(0, Math.min(max, Math.floor(requested))) : 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
63
62
|
export class HostCallRefusedError extends Error {
|
|
64
63
|
constructor(readonly fn: string) {
|
|
65
64
|
super(`host call '${fn}' is not offered by this game`);
|
|
@@ -80,22 +79,20 @@ export async function routeClientHostCall(
|
|
|
80
79
|
return { actors: reads.actorsInChunk(toBigInt(args.x), toBigInt(args.y), toBigInt(args.z)) };
|
|
81
80
|
case 'actors_list_radius': {
|
|
82
81
|
const origin = { x: toBigInt(args.x), y: toBigInt(args.y), z: toBigInt(args.z) };
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
Number.isFinite(requested) ? Math.max(0, Math.min(MAX_RADIUS, Math.floor(requested))) : 0,
|
|
86
|
-
);
|
|
82
|
+
const rXz = radius(args.radiusXz ?? args.radius ?? args.r, MAX_RADIUS_XZ);
|
|
83
|
+
const rY = radius(args.radiusY, MAX_RADIUS_Y);
|
|
87
84
|
const low = grid
|
|
88
85
|
? {
|
|
89
|
-
x: maxBig(grid.low.x, origin.x -
|
|
90
|
-
y: maxBig(grid.low.y, origin.y -
|
|
91
|
-
z: maxBig(grid.low.z, origin.z -
|
|
86
|
+
x: maxBig(grid.low.x, origin.x - rXz),
|
|
87
|
+
y: maxBig(grid.low.y, origin.y - rY),
|
|
88
|
+
z: maxBig(grid.low.z, origin.z - rXz),
|
|
92
89
|
}
|
|
93
90
|
: origin;
|
|
94
91
|
const high = grid
|
|
95
92
|
? {
|
|
96
|
-
x: minBig(grid.high.x, origin.x +
|
|
97
|
-
y: minBig(grid.high.y, origin.y +
|
|
98
|
-
z: minBig(grid.high.z, origin.z +
|
|
93
|
+
x: minBig(grid.high.x, origin.x + rXz),
|
|
94
|
+
y: minBig(grid.high.y, origin.y + rY),
|
|
95
|
+
z: minBig(grid.high.z, origin.z + rXz),
|
|
99
96
|
}
|
|
100
97
|
: origin;
|
|
101
98
|
const actors: Array<Record<string, unknown>> = [];
|
|
@@ -166,7 +163,7 @@ function asCoord3(value: unknown): { x: unknown; y: unknown; z: unknown } | null
|
|
|
166
163
|
return null;
|
|
167
164
|
}
|
|
168
165
|
|
|
169
|
-
/** Accept the
|
|
166
|
+
/** Accept the SDKs' flattened host-call JSON (the broker clamps its chunk) and the tuple shape. */
|
|
170
167
|
export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
171
168
|
chunk: { x: number; y: number; z: number };
|
|
172
169
|
x: number;
|
|
@@ -185,9 +182,9 @@ export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
|
185
182
|
const voxelRaw =
|
|
186
183
|
asCoord3(args.voxel) ??
|
|
187
184
|
({
|
|
188
|
-
x: coordField(args, 'x', 'vx'),
|
|
189
|
-
y: coordField(args, 'y', 'vy'),
|
|
190
|
-
z: coordField(args, 'z', 'vz'),
|
|
185
|
+
x: coordField(args, 'voxelX', 'x', 'vx'),
|
|
186
|
+
y: coordField(args, 'voxelY', 'y', 'vy'),
|
|
187
|
+
z: coordField(args, 'voxelZ', 'z', 'vz'),
|
|
191
188
|
} as { x: unknown; y: unknown; z: unknown });
|
|
192
189
|
const cx = intInRange(chunkRaw.x, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
193
190
|
const cy = intInRange(chunkRaw.y, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
@@ -234,115 +231,6 @@ export function bytesToBase64(bytes: Uint8Array): string {
|
|
|
234
231
|
return btoa(binary);
|
|
235
232
|
}
|
|
236
233
|
|
|
237
|
-
interface CachedArtifact {
|
|
238
|
-
bytes: Uint8Array;
|
|
239
|
-
artifactHash?: string;
|
|
240
|
-
fuelPerDispatch?: string | bigint;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const artifactCache = new Map<string, CachedArtifact>();
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Fetch a consented grid-attached client mod's artifact and run it in a
|
|
247
|
-
* hash-verified broker with the same allowlist, grid clamp, rate caps and
|
|
248
|
-
* circuit breaker as the Studio's own Test/Run path. Its HUD output lands in
|
|
249
|
-
* the persistent text HUD. Returns null (fails closed) when the artifact
|
|
250
|
-
* cannot be fetched.
|
|
251
|
-
*/
|
|
252
|
-
export async function runConsentedGridMod(options: {
|
|
253
|
-
client: CrowdyClient;
|
|
254
|
-
appId: string;
|
|
255
|
-
attachmentId: string;
|
|
256
|
-
artifactCacheKey: string;
|
|
257
|
-
hudSource: string;
|
|
258
|
-
hudLabel: string;
|
|
259
|
-
/** The module's name, which addressed grid events target (default `hudLabel`). */
|
|
260
|
-
moduleName?: string;
|
|
261
|
-
grid: PlayerCodeGridBounds;
|
|
262
|
-
workerUrl: string;
|
|
263
|
-
reads: ClientModHostReads;
|
|
264
|
-
writes?: ClientModHostWrites;
|
|
265
|
-
hud: CrowdyStudioTextHud;
|
|
266
|
-
overlay?: ModOverlayStore;
|
|
267
|
-
input?: ClientModHostInput;
|
|
268
|
-
/**
|
|
269
|
-
* Answers the host calls this game does not route itself (DN-10: the rest
|
|
270
|
-
* of the client catalog, through CrowdyJS `createGridHostCalls`).
|
|
271
|
-
*/
|
|
272
|
-
serverCalls?: (call: PlayerCodeHostCall) => Promise<unknown>;
|
|
273
|
-
/**
|
|
274
|
-
* How often the worker self-drives `on_tick`. Omitted/0 means invoke-only
|
|
275
|
-
* and a HUD mod then never runs (measured 2026-09-07: broker started, HUD
|
|
276
|
-
* stayed empty). Studio Test/Deploy reads `[package.metadata.crowdy]
|
|
277
|
-
* tick_interval_ms` from the CLIENT Cargo.toml (default 1000, clamped
|
|
278
|
-
* 16–1000). Pass this only for grid-attached visitors until that field is
|
|
279
|
-
* stored on the compiled version.
|
|
280
|
-
*/
|
|
281
|
-
tickIntervalMs?: number;
|
|
282
|
-
}): Promise<{ stop: () => void } | null> {
|
|
283
|
-
let fetched: { bytes: ArrayBuffer; artifactHash?: string; fuelPerDispatch?: string | bigint };
|
|
284
|
-
const cached = artifactCache.get(options.artifactCacheKey);
|
|
285
|
-
if (cached) {
|
|
286
|
-
fetched = {
|
|
287
|
-
bytes: cached.bytes.slice().buffer,
|
|
288
|
-
artifactHash: cached.artifactHash,
|
|
289
|
-
fuelPerDispatch: cached.fuelPerDispatch,
|
|
290
|
-
};
|
|
291
|
-
} else {
|
|
292
|
-
try {
|
|
293
|
-
fetched = await options.client.marketplace.clientArtifactBytes({
|
|
294
|
-
appId: options.appId,
|
|
295
|
-
attachmentId: options.attachmentId,
|
|
296
|
-
});
|
|
297
|
-
} catch {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
artifactCache.set(options.artifactCacheKey, {
|
|
301
|
-
bytes: new Uint8Array(fetched.bytes).slice(),
|
|
302
|
-
artifactHash: fetched.artifactHash,
|
|
303
|
-
fuelPerDispatch: fetched.fuelPerDispatch,
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
const broker = new PlayerCodeBroker({
|
|
307
|
-
workerUrl: options.workerUrl,
|
|
308
|
-
grid: options.grid,
|
|
309
|
-
artifactHash: fetched.artifactHash,
|
|
310
|
-
fuelPerDispatch: fetched.fuelPerDispatch != null ? BigInt(fetched.fuelPerDispatch) : undefined,
|
|
311
|
-
tickIntervalMs: options.tickIntervalMs ?? 1000,
|
|
312
|
-
moduleName: options.moduleName ?? options.hudLabel,
|
|
313
|
-
onHostCall: (call) =>
|
|
314
|
-
routeWithFallback(
|
|
315
|
-
call,
|
|
316
|
-
() => routeClientHostCall(call, options.reads, options.grid, options.writes, options.input),
|
|
317
|
-
options.serverCalls,
|
|
318
|
-
),
|
|
319
|
-
onPresentation: (presentation: PlayerCodePresentation) => {
|
|
320
|
-
if (presentation.channel === 'hud') {
|
|
321
|
-
options.hud.set({
|
|
322
|
-
source: options.hudSource,
|
|
323
|
-
label: options.hudLabel,
|
|
324
|
-
payload: presentation.payload,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
if (presentation.channel === 'overlay') {
|
|
328
|
-
options.overlay?.apply(options.hudSource, presentation.payload, options.grid);
|
|
329
|
-
}
|
|
330
|
-
},
|
|
331
|
-
onCircuitOpen: () => {
|
|
332
|
-
options.hud.remove(options.hudSource);
|
|
333
|
-
options.overlay?.remove(options.hudSource);
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
await broker.start(fetched.bytes);
|
|
337
|
-
return {
|
|
338
|
-
stop: () => {
|
|
339
|
-
broker.stop();
|
|
340
|
-
options.hud.remove(options.hudSource);
|
|
341
|
-
options.overlay?.remove(options.hudSource);
|
|
342
|
-
},
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
234
|
/**
|
|
347
235
|
* The game's own router first (local reads, optimistic writes); anything it
|
|
348
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,102 +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
|
-
export class ClientModLifecycle {
|
|
25
|
-
private readonly running = new Map<string, RunningClientMod>();
|
|
26
|
-
private activeGridId: string | null = null;
|
|
27
|
-
private generation = 0;
|
|
28
|
-
private stopped = false;
|
|
29
|
-
|
|
30
|
-
/** Returns true when the grid actually changed (callers reset their caches). */
|
|
31
|
-
enterGrid(gridId: string | null): boolean {
|
|
32
|
-
if (this.stopped) return false;
|
|
33
|
-
if (gridId === this.activeGridId) return false;
|
|
34
|
-
this.stopAll();
|
|
35
|
-
this.activeGridId = gridId;
|
|
36
|
-
this.generation++;
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
get currentGridId(): string | null {
|
|
41
|
-
return this.activeGridId;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
has(attachmentId: string): boolean {
|
|
45
|
-
return this.running.has(attachmentId);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get runningCount(): number {
|
|
49
|
-
return this.running.size;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
scope(gridId: string): ClientModScope | null {
|
|
53
|
-
if (this.stopped || gridId !== this.activeGridId) return null;
|
|
54
|
-
return { gridId, generation: this.generation };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
isCurrent(scope: ClientModScope): boolean {
|
|
58
|
-
return (
|
|
59
|
-
!this.stopped && scope.gridId === this.activeGridId && scope.generation === this.generation
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
track(scope: ClientModScope, descriptor: ClientModDescriptor, handle: ClientModHandle): boolean {
|
|
64
|
-
if (!this.isCurrent(scope) || this.running.has(descriptor.attachmentId)) {
|
|
65
|
-
handle.stop();
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
this.running.set(descriptor.attachmentId, { ...descriptor, ...handle });
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Stop mods that disappeared or changed hash; returns false if scope is stale. */
|
|
73
|
-
reconcile(scope: ClientModScope, descriptors: readonly ClientModDescriptor[]): boolean {
|
|
74
|
-
if (!this.isCurrent(scope)) return false;
|
|
75
|
-
const current = new Map(descriptors.map((d) => [d.attachmentId, d]));
|
|
76
|
-
for (const [attachmentId, running] of this.running) {
|
|
77
|
-
const next = current.get(attachmentId);
|
|
78
|
-
if (
|
|
79
|
-
!next ||
|
|
80
|
-
next.artifactHash !== running.artifactHash ||
|
|
81
|
-
next.capabilityHash !== running.capabilityHash
|
|
82
|
-
) {
|
|
83
|
-
running.stop();
|
|
84
|
-
this.running.delete(attachmentId);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private stopAll(): void {
|
|
91
|
-
for (const running of this.running.values()) running.stop();
|
|
92
|
-
this.running.clear();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
shutdown(): void {
|
|
96
|
-
if (this.stopped) return;
|
|
97
|
-
this.stopped = true;
|
|
98
|
-
this.activeGridId = null;
|
|
99
|
-
this.generation++;
|
|
100
|
-
this.stopAll();
|
|
101
|
-
}
|
|
102
|
-
}
|