@forgeax/engine-import 0.1.2
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/LICENSE +202 -0
- package/README.md +226 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts +2 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/import-contract-migration.test.d.ts +2 -0
- package/dist/__tests__/import-contract-migration.test.d.ts.map +1 -0
- package/dist/__tests__/import-dependencies.test.d.ts +2 -0
- package/dist/__tests__/import-dependencies.test.d.ts.map +1 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts +2 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts.map +1 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/import.unit.test.d.ts +2 -0
- package/dist/__tests__/import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts +2 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts +2 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts.map +1 -0
- package/dist/import-product.d.ts +36 -0
- package/dist/import-product.d.ts.map +1 -0
- package/dist/import-runner.d.ts +133 -0
- package/dist/import-runner.d.ts.map +1 -0
- package/dist/importer-registry.d.ts +40 -0
- package/dist/importer-registry.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1607 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mesh-bin.d.ts +19 -0
- package/dist/mesh-bin.d.ts.map +1 -0
- package/dist/scriptable-pack-output-producers.d.ts +6 -0
- package/dist/scriptable-pack-output-producers.d.ts.map +1 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts +22 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts.map +1 -0
- package/dist/scriptable-pack.d.ts +153 -0
- package/dist/scriptable-pack.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/decode-image-owner.test-d.ts +53 -0
- package/src/__tests__/import-contract-migration.test.ts +190 -0
- package/src/__tests__/import-dependencies.test.ts +65 -0
- package/src/__tests__/import-finalizer-digest-retry.test.ts +113 -0
- package/src/__tests__/import-local-artifacts.test.ts +100 -0
- package/src/__tests__/import.unit.test.ts +429 -0
- package/src/__tests__/material-import-product.unit.test.ts +56 -0
- package/src/__tests__/mesh-bin-contract.test.ts +95 -0
- package/src/__tests__/mesh-bin-morph-v3.test.ts +44 -0
- package/src/__tests__/mesh-bin.test.ts +102 -0
- package/src/__tests__/scriptable-pack-output-producers.unit.test.ts +190 -0
- package/src/__tests__/scriptable-pack-production-producers.unit.test.ts +429 -0
- package/src/__tests__/scriptable-pack-staged-snapshot.unit.test.ts +258 -0
- package/src/__tests__/scriptable-pack.integration.test.ts +435 -0
- package/src/__tests__/source-overrides-import-context.unit.test.ts +92 -0
- package/src/import-product.ts +195 -0
- package/src/import-runner.ts +720 -0
- package/src/importer-registry.ts +80 -0
- package/src/index.ts +103 -0
- package/src/mesh-bin.ts +229 -0
- package/src/scriptable-pack-output-producers.ts +466 -0
- package/src/scriptable-pack-staged-snapshot.ts +161 -0
- package/src/scriptable-pack.ts +615 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import type { Component } from '@forgeax/engine-ecs';
|
|
2
|
+
import { isScriptablePackAssetKind, type ScriptablePackAssetKind } from '@forgeax/engine-pack';
|
|
3
|
+
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
4
|
+
import {
|
|
5
|
+
type AssetReader,
|
|
6
|
+
projectScriptablePackMeta,
|
|
7
|
+
type ScriptablePackDefinition,
|
|
8
|
+
type ScriptablePackError,
|
|
9
|
+
type ScriptablePackReadError,
|
|
10
|
+
} from '@forgeax/engine-pack/source';
|
|
11
|
+
import type {
|
|
12
|
+
AnimationGraph,
|
|
13
|
+
Asset,
|
|
14
|
+
AssetGuid as AssetGuidType,
|
|
15
|
+
AssetPublicationEnvelope,
|
|
16
|
+
AssetRef,
|
|
17
|
+
FontAsset,
|
|
18
|
+
ImportedArtifactBody,
|
|
19
|
+
ImportedAsset,
|
|
20
|
+
ImportProduct,
|
|
21
|
+
MaterialAsset,
|
|
22
|
+
MeshAsset,
|
|
23
|
+
Result,
|
|
24
|
+
TilesetAsset,
|
|
25
|
+
} from '@forgeax/engine-types';
|
|
26
|
+
import { AssetError, err, ImportError, ok } from '@forgeax/engine-types';
|
|
27
|
+
|
|
28
|
+
function commonPathPrefix(paths: readonly string[]): string {
|
|
29
|
+
const first = paths[0];
|
|
30
|
+
if (first === undefined) return '';
|
|
31
|
+
const firstNormalized = first.replaceAll('\\', '/');
|
|
32
|
+
const firstDirectory = firstNormalized.slice(0, firstNormalized.lastIndexOf('/'));
|
|
33
|
+
const parts = firstDirectory.split('/');
|
|
34
|
+
let length = parts.length;
|
|
35
|
+
for (const path of paths.slice(1)) {
|
|
36
|
+
const normalized = path.replaceAll('\\', '/');
|
|
37
|
+
const candidate = normalized.slice(0, normalized.lastIndexOf('/')).split('/');
|
|
38
|
+
length = Math.min(length, candidate.length);
|
|
39
|
+
for (let index = 0; index < length; index += 1) {
|
|
40
|
+
if (parts[index] !== candidate[index]) {
|
|
41
|
+
length = index;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return parts.slice(0, length).join('/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Remove host-specific absolute roots from the source closure fingerprint. */
|
|
50
|
+
function stableSourceClosure(
|
|
51
|
+
closure: readonly ScriptablePackSourceClosureEntry[],
|
|
52
|
+
): readonly ScriptablePackSourceClosureEntry[] {
|
|
53
|
+
const root = commonPathPrefix(closure.map((entry) => entry.path));
|
|
54
|
+
return closure
|
|
55
|
+
.map((entry) => ({
|
|
56
|
+
path: entry.path.replaceAll('\\', '/').slice(root.length).replace(/^\/+/, ''),
|
|
57
|
+
digest: entry.digest,
|
|
58
|
+
}))
|
|
59
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ScriptablePackAssetSnapshot {
|
|
63
|
+
readonly asset: Asset;
|
|
64
|
+
readonly generation: number;
|
|
65
|
+
readonly digest: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ScriptablePackStagedOutput {
|
|
69
|
+
readonly guid: AssetGuidType;
|
|
70
|
+
/** Producer-owned stable output identity from the ScriptablePack definition. */
|
|
71
|
+
readonly sourceKey?: string;
|
|
72
|
+
readonly asset: Asset;
|
|
73
|
+
readonly digest?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ScriptablePackAssetSnapshotSource {
|
|
77
|
+
readByGuid(
|
|
78
|
+
guid: AssetGuidType,
|
|
79
|
+
): Promise<Result<ScriptablePackAssetSnapshot, ScriptablePackReadError>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AssetOutputInput {
|
|
83
|
+
readonly guid: string;
|
|
84
|
+
readonly sourceKey: string;
|
|
85
|
+
readonly asset: Asset;
|
|
86
|
+
/** World-local schema projection for scene ref externalization. */
|
|
87
|
+
readonly components?: ReadonlyMap<string, Component>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type MaterialPackPayload = Omit<MaterialAsset, 'parent' | 'values'> & {
|
|
91
|
+
readonly parent?: number;
|
|
92
|
+
readonly values?: Readonly<Record<string, unknown>>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type ScenePackPayload = { readonly kind: 'scene'; readonly [key: string]: unknown };
|
|
96
|
+
|
|
97
|
+
type FontPackPayload = Omit<FontAsset, 'atlas' | 'sampler'> & {
|
|
98
|
+
readonly atlasGuid: string;
|
|
99
|
+
readonly samplerGuid: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type TilesetPackPayload = Omit<TilesetAsset, 'atlases'> & { readonly atlases: readonly number[] };
|
|
103
|
+
|
|
104
|
+
type AnimationGraphPackPayload = Omit<AnimationGraph, 'nodes'> & {
|
|
105
|
+
readonly nodes: readonly (
|
|
106
|
+
| Exclude<AnimationGraph['nodes'][number], { readonly type: 'clip' }>
|
|
107
|
+
| (Omit<Extract<AnimationGraph['nodes'][number], { readonly type: 'clip' }>, 'clip'> & {
|
|
108
|
+
readonly clip: number;
|
|
109
|
+
})
|
|
110
|
+
)[];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Serialized Pack projection; durable runtime Assets never carry ref indices. */
|
|
114
|
+
export type AssetOutputPayloadByKind = {
|
|
115
|
+
readonly mesh: MeshAsset;
|
|
116
|
+
readonly material: MaterialPackPayload;
|
|
117
|
+
readonly scene: ScenePackPayload;
|
|
118
|
+
readonly texture: Extract<Asset, { readonly kind: 'texture' }>;
|
|
119
|
+
readonly equirect: Extract<Asset, { readonly kind: 'equirect' }>;
|
|
120
|
+
readonly sampler: Extract<Asset, { readonly kind: 'sampler' }>;
|
|
121
|
+
readonly font: FontPackPayload;
|
|
122
|
+
readonly 'render-pipeline': Extract<Asset, { readonly kind: 'render-pipeline' }>;
|
|
123
|
+
readonly tileset: TilesetPackPayload;
|
|
124
|
+
readonly video: Extract<Asset, { readonly kind: 'video' }>;
|
|
125
|
+
readonly skeleton: Extract<Asset, { readonly kind: 'skeleton' }>;
|
|
126
|
+
readonly skin: Extract<Asset, { readonly kind: 'skin' }>;
|
|
127
|
+
readonly 'animation-clip': Extract<Asset, { readonly kind: 'animation-clip' }>;
|
|
128
|
+
readonly 'animation-graph': AnimationGraphPackPayload;
|
|
129
|
+
readonly audio: Extract<Asset, { readonly kind: 'audio' }>;
|
|
130
|
+
readonly 'particle-effect': Extract<Asset, { readonly kind: 'particle-effect' }>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type AssetOutputPayload = AssetOutputPayloadByKind[ScriptablePackAssetKind];
|
|
134
|
+
|
|
135
|
+
export interface AssetOutputProduct {
|
|
136
|
+
readonly payload: AssetOutputPayload;
|
|
137
|
+
readonly refs: readonly AssetRef[];
|
|
138
|
+
readonly artifacts: Readonly<Record<string, ImportedArtifactBody>>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface AssetOutputProducer {
|
|
142
|
+
readonly kind: string;
|
|
143
|
+
readonly version: string;
|
|
144
|
+
produce(
|
|
145
|
+
input: AssetOutputInput,
|
|
146
|
+
): Result<AssetOutputProduct, ImportError> | Promise<Result<AssetOutputProduct, ImportError>>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class AssetOutputProducerRegistry {
|
|
150
|
+
private readonly producers = new Map<string, AssetOutputProducer>();
|
|
151
|
+
|
|
152
|
+
register(producer: AssetOutputProducer): void {
|
|
153
|
+
if (producer.kind.trim().length === 0 || producer.version.trim().length === 0) {
|
|
154
|
+
throw new TypeError('ScriptablePack output producer kind and version must be non-empty');
|
|
155
|
+
}
|
|
156
|
+
if (typeof producer.produce !== 'function') {
|
|
157
|
+
throw new TypeError(`ScriptablePack output producer ${producer.kind} must expose produce`);
|
|
158
|
+
}
|
|
159
|
+
this.producers.set(producer.kind, producer);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get(kind: string): AssetOutputProducer | undefined {
|
|
163
|
+
return this.producers.get(kind);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
versions(): Readonly<Record<string, string>> {
|
|
167
|
+
return Object.fromEntries(
|
|
168
|
+
[...this.producers.entries()]
|
|
169
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
170
|
+
.map(([kind, producer]) => [kind, producer.version]),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type ScriptablePackExternalUsage = 'reference' | 'content' | 'both';
|
|
176
|
+
|
|
177
|
+
export interface ScriptablePackExternalEvidence {
|
|
178
|
+
readonly guid: string;
|
|
179
|
+
readonly usage: ScriptablePackExternalUsage;
|
|
180
|
+
readonly generation?: number;
|
|
181
|
+
readonly digest?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ScriptablePackSourceClosureEntry {
|
|
185
|
+
readonly path: string;
|
|
186
|
+
readonly digest: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ScriptablePackBuildProduct {
|
|
190
|
+
readonly product: ImportProduct<unknown>;
|
|
191
|
+
/** Raw private outputs for the current staged build generation, never a runtime publication path. */
|
|
192
|
+
readonly stagedOutputs: readonly ScriptablePackStagedOutput[];
|
|
193
|
+
readonly externalEvidence: readonly ScriptablePackExternalEvidence[];
|
|
194
|
+
readonly inputFingerprint: string;
|
|
195
|
+
/** Engine publication fields projected after ordinary producers run. */
|
|
196
|
+
readonly publication?: AssetPublicationEnvelope;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Domain-owned structured failure transported without flattening its recovery fields. */
|
|
200
|
+
export interface ScriptablePackDomainError {
|
|
201
|
+
readonly code: string;
|
|
202
|
+
readonly expected: string;
|
|
203
|
+
readonly hint: string;
|
|
204
|
+
readonly detail?: unknown;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type ScriptablePackBuildBridgeResult = Result<
|
|
208
|
+
ScriptablePackBuildProduct,
|
|
209
|
+
ScriptablePackError | AssetError | ImportError | ScriptablePackDomainError
|
|
210
|
+
>;
|
|
211
|
+
|
|
212
|
+
interface ObservedRead {
|
|
213
|
+
readonly guid: string;
|
|
214
|
+
readonly asset: Asset;
|
|
215
|
+
readonly generation: number;
|
|
216
|
+
readonly digest: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function privateClone<T>(value: T): T {
|
|
220
|
+
return structuredClone(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
224
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isAsset(value: unknown): value is Asset {
|
|
228
|
+
return record(value) && typeof value.kind === 'string' && isScriptablePackAssetKind(value.kind);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isStructuredDomainError(value: unknown): value is ScriptablePackDomainError {
|
|
232
|
+
return (
|
|
233
|
+
value !== null &&
|
|
234
|
+
typeof value === 'object' &&
|
|
235
|
+
typeof (value as { readonly code?: unknown }).code === 'string' &&
|
|
236
|
+
typeof (value as { readonly expected?: unknown }).expected === 'string' &&
|
|
237
|
+
typeof (value as { readonly hint?: unknown }).hint === 'string'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function remapBuildFailureSource(
|
|
242
|
+
value: unknown,
|
|
243
|
+
sourcePath: string,
|
|
244
|
+
): ScriptablePackError | undefined {
|
|
245
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
246
|
+
const error = value as { readonly code?: unknown; readonly detail?: unknown };
|
|
247
|
+
if (error.code !== 'pack-source-load-failed') return undefined;
|
|
248
|
+
if (error.detail === null || typeof error.detail !== 'object') return undefined;
|
|
249
|
+
const detail = error.detail as { readonly phase?: unknown } & Record<string, unknown>;
|
|
250
|
+
if (detail.phase !== 'build') return undefined;
|
|
251
|
+
return {
|
|
252
|
+
...(value as ScriptablePackError),
|
|
253
|
+
detail: { ...detail, sourcePath },
|
|
254
|
+
} as ScriptablePackError;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function observedAssetReader(source?: ScriptablePackAssetSnapshotSource): {
|
|
258
|
+
readonly reader: AssetReader;
|
|
259
|
+
readonly reads: ReadonlyMap<string, ObservedRead>;
|
|
260
|
+
} {
|
|
261
|
+
const reads = new Map<string, ObservedRead>();
|
|
262
|
+
const reader: AssetReader = {
|
|
263
|
+
async readByGuid<TAsset extends Asset = Asset>(guid: AssetGuidType) {
|
|
264
|
+
const key = AssetGuid.format(guid);
|
|
265
|
+
const cached = reads.get(key);
|
|
266
|
+
if (cached !== undefined) return ok(privateClone(cached.asset) as TAsset);
|
|
267
|
+
if (source === undefined) {
|
|
268
|
+
return err(
|
|
269
|
+
new AssetError({
|
|
270
|
+
code: 'asset-not-found',
|
|
271
|
+
expected: `a host Asset snapshot source for ScriptablePack content read ${key}`,
|
|
272
|
+
hint: 'configure pluginPack({ scriptablePack: { assetSource, ... } }) or remove the content read',
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const result = await source.readByGuid(guid);
|
|
277
|
+
if (!result.ok) return result;
|
|
278
|
+
const fixed = privateClone(result.value.asset);
|
|
279
|
+
reads.set(key, {
|
|
280
|
+
guid: key,
|
|
281
|
+
asset: fixed,
|
|
282
|
+
generation: result.value.generation,
|
|
283
|
+
digest: result.value.digest,
|
|
284
|
+
});
|
|
285
|
+
return ok(privateClone(fixed) as TAsset);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
return { reader, reads };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stable(value: unknown): string {
|
|
292
|
+
if (value instanceof Uint8Array) return JSON.stringify(Array.from(value));
|
|
293
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
294
|
+
if (value !== null && typeof value === 'object') {
|
|
295
|
+
const record = value as Record<string, unknown>;
|
|
296
|
+
return `{${Object.keys(record)
|
|
297
|
+
.sort()
|
|
298
|
+
.map((key) => `${JSON.stringify(key)}:${stable(record[key])}`)
|
|
299
|
+
.join(',')}}`;
|
|
300
|
+
}
|
|
301
|
+
return JSON.stringify(value) ?? 'null';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function fingerprint(value: unknown): Promise<string> {
|
|
305
|
+
const subtle = globalThis.crypto?.subtle;
|
|
306
|
+
if (subtle === undefined)
|
|
307
|
+
throw new Error('Web Crypto API is required for ScriptablePack fingerprints');
|
|
308
|
+
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(stable(value)));
|
|
309
|
+
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(
|
|
310
|
+
'',
|
|
311
|
+
);
|
|
312
|
+
return `sha256:${hex}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function outputError(
|
|
316
|
+
definition: ScriptablePackDefinition,
|
|
317
|
+
output: Readonly<Record<string, unknown>>,
|
|
318
|
+
): ScriptablePackError | undefined {
|
|
319
|
+
const declaredKeys = Object.keys(definition.assets);
|
|
320
|
+
const outputKeys = Object.keys(output);
|
|
321
|
+
const outputKeySet = new Set(outputKeys);
|
|
322
|
+
const declaredKeySet = new Set(declaredKeys);
|
|
323
|
+
const missingKeys = declaredKeys.filter((key) => !outputKeySet.has(key));
|
|
324
|
+
const unexpectedSourceKeys = outputKeys.filter((key) => !declaredKeySet.has(key));
|
|
325
|
+
const kindMismatches = declaredKeys.flatMap((sourceKey) => {
|
|
326
|
+
const descriptor = definition.assets[sourceKey];
|
|
327
|
+
const value = output[sourceKey];
|
|
328
|
+
if (descriptor === undefined || value === undefined) return [];
|
|
329
|
+
const actualKind =
|
|
330
|
+
value !== null && typeof value === 'object' && 'kind' in value
|
|
331
|
+
? String((value as { readonly kind: unknown }).kind)
|
|
332
|
+
: typeof value;
|
|
333
|
+
return actualKind === descriptor.kind
|
|
334
|
+
? []
|
|
335
|
+
: [{ sourceKey, expected: descriptor.kind, actual: actualKind }];
|
|
336
|
+
});
|
|
337
|
+
if (
|
|
338
|
+
missingKeys.length === 0 &&
|
|
339
|
+
unexpectedSourceKeys.length === 0 &&
|
|
340
|
+
kindMismatches.length === 0
|
|
341
|
+
) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
code: 'pack-source-output-invalid',
|
|
346
|
+
expected: 'build output keys and kinds to exactly match definition.assets',
|
|
347
|
+
hint: 'inspect sourceKey and GUID topology, then rebuild or cold-cook the ScriptablePack',
|
|
348
|
+
detail: {
|
|
349
|
+
missingGuids: missingKeys.map((key) =>
|
|
350
|
+
AssetGuid.format(definition.assets[key]?.guid as AssetGuidType),
|
|
351
|
+
),
|
|
352
|
+
unexpectedSourceKeys,
|
|
353
|
+
kindMismatches,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function externalClosureError(
|
|
359
|
+
declared: ReadonlySet<string>,
|
|
360
|
+
referenced: ReadonlySet<string>,
|
|
361
|
+
read: ReadonlySet<string>,
|
|
362
|
+
): ScriptablePackError | undefined {
|
|
363
|
+
const used = new Set([...referenced, ...read]);
|
|
364
|
+
const undeclaredReferencedGuids = [...referenced].filter((guid) => !declared.has(guid));
|
|
365
|
+
const undeclaredReadGuids = [...read].filter((guid) => !declared.has(guid));
|
|
366
|
+
const unusedDeclaredGuids = [...declared].filter((guid) => !used.has(guid));
|
|
367
|
+
if (
|
|
368
|
+
undeclaredReferencedGuids.length === 0 &&
|
|
369
|
+
undeclaredReadGuids.length === 0 &&
|
|
370
|
+
unusedDeclaredGuids.length === 0
|
|
371
|
+
) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
code: 'pack-source-external-closure-mismatch',
|
|
376
|
+
expected: 'externalAssets GUIDs to equal output external refs union AssetReader reads',
|
|
377
|
+
hint: 'inspect refs and AssetReader reads, repair GUID declarations, then rebuild or cold-cook',
|
|
378
|
+
detail: {
|
|
379
|
+
undeclaredReferencedGuids: undeclaredReferencedGuids.sort(),
|
|
380
|
+
undeclaredReadGuids: undeclaredReadGuids.sort(),
|
|
381
|
+
unusedDeclaredGuids: unusedDeclaredGuids.sort(),
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function productContractError(
|
|
387
|
+
sourceKey: string,
|
|
388
|
+
kind: string,
|
|
389
|
+
product: AssetOutputProduct,
|
|
390
|
+
): ScriptablePackError | undefined {
|
|
391
|
+
const mismatches: { sourceKey: string; expected: string; actual: string }[] = [];
|
|
392
|
+
if (product.payload.kind !== kind) {
|
|
393
|
+
mismatches.push({ sourceKey, expected: kind, actual: product.payload.kind });
|
|
394
|
+
}
|
|
395
|
+
for (const reference of product.refs) {
|
|
396
|
+
if (!AssetGuid.parse(reference.guid).ok) {
|
|
397
|
+
mismatches.push({
|
|
398
|
+
sourceKey,
|
|
399
|
+
expected: 'every producer ref to contain a valid Asset GUID',
|
|
400
|
+
actual: reference.guid,
|
|
401
|
+
});
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const [artifactKey, artifact] of Object.entries(product.artifacts)) {
|
|
406
|
+
if (
|
|
407
|
+
artifactKey.length === 0 ||
|
|
408
|
+
artifactKey.startsWith('/') ||
|
|
409
|
+
artifactKey.includes('..') ||
|
|
410
|
+
artifactKey.includes('\\') ||
|
|
411
|
+
artifact.mediaType.trim().length === 0 ||
|
|
412
|
+
!(artifact.bytes instanceof Uint8Array)
|
|
413
|
+
) {
|
|
414
|
+
mismatches.push({
|
|
415
|
+
sourceKey,
|
|
416
|
+
expected: 'asset-local artifacts with safe keys, mediaType, and Uint8Array bytes',
|
|
417
|
+
actual: artifactKey,
|
|
418
|
+
});
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (mismatches.length === 0) return undefined;
|
|
423
|
+
return {
|
|
424
|
+
code: 'pack-source-output-invalid',
|
|
425
|
+
expected: 'producer output refs and asset-local artifacts to satisfy the Pack v2 contract',
|
|
426
|
+
hint: 'inspect sourceKey and artifact provenance, repair refs or bytes, then rebuild or cold-cook',
|
|
427
|
+
detail: { missingGuids: [], unexpectedSourceKeys: [], kindMismatches: mismatches },
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface BuildScriptablePackOptions {
|
|
432
|
+
readonly definition: ScriptablePackDefinition;
|
|
433
|
+
readonly sourcePath: string;
|
|
434
|
+
readonly assetSource?: ScriptablePackAssetSnapshotSource;
|
|
435
|
+
readonly outputs: AssetOutputProducerRegistry;
|
|
436
|
+
readonly sourceClosure: readonly ScriptablePackSourceClosureEntry[];
|
|
437
|
+
readonly authoringContractVersion: string;
|
|
438
|
+
/** Optional owner-provided component schema projection for scene output. */
|
|
439
|
+
readonly components?: ReadonlyMap<string, Component>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Build one external ScriptablePack through the producer-owned Pack bridge.
|
|
444
|
+
* Success preserves payloads, GUID refs, and local artifacts; failure retains
|
|
445
|
+
* `code`, `expected`, `hint`, and `detail` for recovery by the caller.
|
|
446
|
+
*/
|
|
447
|
+
export async function buildScriptablePack(
|
|
448
|
+
options: BuildScriptablePackOptions,
|
|
449
|
+
): Promise<ScriptablePackBuildBridgeResult> {
|
|
450
|
+
const observed = observedAssetReader(options.assetSource);
|
|
451
|
+
let built: Awaited<ReturnType<ScriptablePackDefinition['build']>>;
|
|
452
|
+
try {
|
|
453
|
+
const build = options.definition.build;
|
|
454
|
+
built = await build(observed.reader);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return err(
|
|
457
|
+
new ImportError({
|
|
458
|
+
code: 'import-internal-error',
|
|
459
|
+
expected: 'ScriptablePack build to return a structured Result without throwing',
|
|
460
|
+
hint: 'fix the build implementation and return a structured failure for expected authoring errors',
|
|
461
|
+
detail: { reason: error instanceof Error ? error.message : String(error) },
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (!built.ok) {
|
|
466
|
+
const remappedBuildFailure = remapBuildFailureSource(built.error, options.sourcePath);
|
|
467
|
+
if (remappedBuildFailure !== undefined) return err(remappedBuildFailure);
|
|
468
|
+
if (
|
|
469
|
+
built.error instanceof ImportError ||
|
|
470
|
+
built.error instanceof AssetError ||
|
|
471
|
+
isStructuredDomainError(built.error)
|
|
472
|
+
) {
|
|
473
|
+
return err(built.error);
|
|
474
|
+
}
|
|
475
|
+
return err(
|
|
476
|
+
new ImportError({
|
|
477
|
+
code: 'import-internal-error',
|
|
478
|
+
expected: 'ScriptablePack build failure to use a structured domain error',
|
|
479
|
+
hint: 'return an error with code, expected, hint, and optional detail fields',
|
|
480
|
+
detail: { reason: String(built.error) },
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (!record(built.value)) {
|
|
485
|
+
return err(
|
|
486
|
+
new ImportError({
|
|
487
|
+
code: 'import-internal-error',
|
|
488
|
+
expected: 'ScriptablePack build output to be an object keyed by declared sourceKey',
|
|
489
|
+
hint: 'return one concrete Asset payload for each declared sourceKey',
|
|
490
|
+
detail: { reason: 'build output is not an object' },
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const output = built.value;
|
|
495
|
+
const invalidOutput = outputError(options.definition, output);
|
|
496
|
+
if (invalidOutput !== undefined) return err(invalidOutput);
|
|
497
|
+
|
|
498
|
+
const assets: ImportedAsset<unknown>[] = [];
|
|
499
|
+
for (const sourceKey of Object.keys(options.definition.assets).sort()) {
|
|
500
|
+
const descriptor = options.definition.assets[sourceKey];
|
|
501
|
+
const asset = output[sourceKey];
|
|
502
|
+
if (descriptor === undefined) continue;
|
|
503
|
+
if (!isAsset(asset)) {
|
|
504
|
+
return err(
|
|
505
|
+
new ImportError({
|
|
506
|
+
code: 'import-internal-error',
|
|
507
|
+
expected: `a concrete Asset payload for sourceKey ${sourceKey}`,
|
|
508
|
+
hint: 'return a durable Asset with a kind from SCRIPTABLE_PACK_ASSET_KINDS',
|
|
509
|
+
detail: { reason: 'build output payload is missing or has an unknown kind' },
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
const producer = options.outputs.get(descriptor.kind);
|
|
514
|
+
if (producer === undefined) {
|
|
515
|
+
return err({
|
|
516
|
+
code: 'pack-source-output-invalid',
|
|
517
|
+
expected: `a domain output producer registered for kind ${descriptor.kind}`,
|
|
518
|
+
hint: 'attach the owning producer capability, inspect registration, then rebuild or cold-cook',
|
|
519
|
+
detail: {
|
|
520
|
+
missingGuids: [AssetGuid.format(descriptor.guid)],
|
|
521
|
+
unexpectedSourceKeys: [],
|
|
522
|
+
kindMismatches: [],
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const product = await producer.produce({
|
|
527
|
+
guid: AssetGuid.format(descriptor.guid),
|
|
528
|
+
sourceKey,
|
|
529
|
+
asset,
|
|
530
|
+
...(options.components === undefined ? {} : { components: options.components }),
|
|
531
|
+
});
|
|
532
|
+
if (!product.ok) return err(product.error);
|
|
533
|
+
const productError = productContractError(sourceKey, descriptor.kind, product.value);
|
|
534
|
+
if (productError !== undefined) return err(productError);
|
|
535
|
+
assets.push({
|
|
536
|
+
guid: AssetGuid.format(descriptor.guid),
|
|
537
|
+
kind: descriptor.kind,
|
|
538
|
+
...(descriptor.name === undefined ? {} : { name: descriptor.name }),
|
|
539
|
+
payload: product.value.payload,
|
|
540
|
+
refs: product.value.refs,
|
|
541
|
+
artifacts: product.value.artifacts,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const local = new Set(assets.map((asset) => asset.guid.toLowerCase()));
|
|
546
|
+
const referenced = new Set(
|
|
547
|
+
assets
|
|
548
|
+
.flatMap((asset) => asset.refs.map((reference) => reference.guid.toLowerCase()))
|
|
549
|
+
.filter((guid) => !local.has(guid)),
|
|
550
|
+
);
|
|
551
|
+
const read = new Set([...observed.reads.keys()].map((guid) => guid.toLowerCase()));
|
|
552
|
+
const declared = new Set(
|
|
553
|
+
Object.values(options.definition.externalAssets).map((guid) =>
|
|
554
|
+
AssetGuid.format(guid).toLowerCase(),
|
|
555
|
+
),
|
|
556
|
+
);
|
|
557
|
+
const closureError = externalClosureError(declared, referenced, read);
|
|
558
|
+
if (closureError !== undefined) return err(closureError);
|
|
559
|
+
|
|
560
|
+
const externalEvidence = [...declared].sort().map((guid): ScriptablePackExternalEvidence => {
|
|
561
|
+
const observedRead = observed.reads.get(guid);
|
|
562
|
+
const isReference = referenced.has(guid);
|
|
563
|
+
return {
|
|
564
|
+
guid,
|
|
565
|
+
usage: observedRead === undefined ? 'reference' : isReference ? 'both' : 'content',
|
|
566
|
+
...(observedRead === undefined
|
|
567
|
+
? {}
|
|
568
|
+
: { generation: observedRead.generation, digest: observedRead.digest }),
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
// The dependency generation is runtime provenance, not a content identity.
|
|
572
|
+
// It may advance when a fresh AssetRegistry is created even though the
|
|
573
|
+
// referenced bytes are unchanged. Keep it in the receipt for diagnostics,
|
|
574
|
+
// but exclude it from the source fingerprint so an identical source closure
|
|
575
|
+
// and dependency digest produce the same publication fence across Edit and
|
|
576
|
+
// fresh Play worlds.
|
|
577
|
+
const fingerprintEvidence = externalEvidence.map(({ guid, usage, digest }) => ({
|
|
578
|
+
guid,
|
|
579
|
+
usage,
|
|
580
|
+
...(digest === undefined ? {} : { digest }),
|
|
581
|
+
}));
|
|
582
|
+
const product: ImportProduct<unknown> = {
|
|
583
|
+
assets,
|
|
584
|
+
sourceDependencies: options.sourceClosure.map((entry) => entry.path),
|
|
585
|
+
};
|
|
586
|
+
const inputFingerprint = await fingerprint({
|
|
587
|
+
meta: projectScriptablePackMeta(options.definition, options.sourcePath),
|
|
588
|
+
sourceClosure: stableSourceClosure(options.sourceClosure),
|
|
589
|
+
externalEvidence: fingerprintEvidence,
|
|
590
|
+
authoringContractVersion: options.authoringContractVersion,
|
|
591
|
+
producerVersions: options.outputs.versions(),
|
|
592
|
+
});
|
|
593
|
+
return ok({
|
|
594
|
+
product,
|
|
595
|
+
stagedOutputs: await Promise.all(
|
|
596
|
+
Object.keys(options.definition.assets)
|
|
597
|
+
.sort()
|
|
598
|
+
.map(async (sourceKey) => {
|
|
599
|
+
const descriptor = options.definition.assets[sourceKey];
|
|
600
|
+
const asset = output[sourceKey];
|
|
601
|
+
if (descriptor === undefined || !isAsset(asset)) {
|
|
602
|
+
throw new Error(`validated ScriptablePack output ${sourceKey} is not an Asset`);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
guid: descriptor.guid,
|
|
606
|
+
sourceKey,
|
|
607
|
+
asset: privateClone(asset),
|
|
608
|
+
digest: await fingerprint(asset),
|
|
609
|
+
};
|
|
610
|
+
}),
|
|
611
|
+
),
|
|
612
|
+
externalEvidence,
|
|
613
|
+
inputFingerprint,
|
|
614
|
+
});
|
|
615
|
+
}
|