@vgai/editor 0.4.0 → 0.4.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.
Files changed (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -0,0 +1,97 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import {
5
+ type AssetImportPlan,
6
+ assetImportPlanSchema,
7
+ importRecipePath,
8
+ } from '../src/asset-workflow/import-contract';
9
+
10
+ export interface CatalogRecipeSource {
11
+ stagedPath: string;
12
+ path: string;
13
+ url: string;
14
+ role: 'primary' | 'dependency' | 'material' | 'texture';
15
+ }
16
+
17
+ export async function writeCatalogImportRecipe(input: {
18
+ stagingDirectory: string;
19
+ source: string;
20
+ familyId: string;
21
+ variantId: string;
22
+ deliveryId: string;
23
+ displayName: string;
24
+ destination: string;
25
+ recipeRelativePath?: string;
26
+ sources: readonly CatalogRecipeSource[];
27
+ generatedFiles?: readonly string[];
28
+ contentKind?: AssetImportPlan['contentKind'];
29
+ targetSkeleton?: string;
30
+ runtimeReady: boolean;
31
+ backend: AssetImportPlan['backend'];
32
+ attribution: AssetImportPlan['attribution'];
33
+ settings?: Partial<AssetImportPlan['settings']>;
34
+ }): Promise<AssetImportPlan> {
35
+ const sourceFiles = await Promise.all(
36
+ input.sources.map(async (file) => {
37
+ const [bytes, info] = await Promise.all([readFile(file.stagedPath), stat(file.stagedPath)]);
38
+ return {
39
+ path: file.path,
40
+ url: file.url,
41
+ hash: createHash('sha256').update(bytes).digest('hex'),
42
+ size: info.size,
43
+ role: file.role,
44
+ };
45
+ }),
46
+ );
47
+ const sourceHash = createHash('sha256')
48
+ .update(
49
+ JSON.stringify(
50
+ sourceFiles
51
+ .map(({ path, hash, size }) => ({ path, hash, size }))
52
+ .sort((left, right) => left.path.localeCompare(right.path)),
53
+ ),
54
+ )
55
+ .digest('hex');
56
+ const plan = assetImportPlanSchema.parse({
57
+ version: 1,
58
+ id: randomUUID(),
59
+ identity: {
60
+ familyId: input.familyId,
61
+ variantId: input.variantId,
62
+ deliveryId: input.deliveryId,
63
+ source: input.source,
64
+ displayName: input.displayName,
65
+ },
66
+ sourceFiles,
67
+ generatedFiles: [...(input.generatedFiles ?? [])],
68
+ destination: input.destination,
69
+ contentKind: input.contentKind ?? 'model',
70
+ ...(input.targetSkeleton ? { targetSkeleton: input.targetSkeleton } : {}),
71
+ settings: {
72
+ scale: 1,
73
+ sourceUnits: 'auto',
74
+ upAxis: 'auto',
75
+ forwardAxis: 'auto',
76
+ normals: 'preserve',
77
+ tangents: 'preserve',
78
+ materials: 'import',
79
+ textures: 'copy',
80
+ animations: [],
81
+ meshOptimization: 'safe',
82
+ retainSource: true,
83
+ ...input.settings,
84
+ },
85
+ conflicts: [],
86
+ attribution: input.attribution,
87
+ sourceHash,
88
+ runtimeReady: input.runtimeReady,
89
+ backend: input.backend,
90
+ createdAt: new Date().toISOString(),
91
+ });
92
+ const recipeFile = input.recipeRelativePath ?? basename(importRecipePath(input.destination));
93
+ const recipePath = join(input.stagingDirectory, recipeFile);
94
+ await mkdir(dirname(recipePath), { recursive: true });
95
+ await writeFile(recipePath, `${JSON.stringify(plan, null, 2)}\n`);
96
+ return plan;
97
+ }
@@ -0,0 +1,141 @@
1
+ import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
3
+ import { type AssetImportPlan, assetImportPlanSchema } from '../src/asset-workflow/import-contract';
4
+ import { type CatalogRecipeSource, writeCatalogImportRecipe } from './catalog-import-recipe';
5
+ import {
6
+ findLocalAsset,
7
+ findLocalAssetFamily,
8
+ getLocalAssetLibraryRoot,
9
+ resolveCatalogFile,
10
+ resolveLocalAssetThumbnail,
11
+ } from './local-asset-catalog';
12
+ import {
13
+ convertStagedModelToGlb,
14
+ SERVER_CONVERTIBLE_MODEL_FORMATS,
15
+ } from './model-import-conversion';
16
+ import { replaceStagedProjectDirectory } from './project-output-writer';
17
+ import { isPathInside } from './server-utils';
18
+
19
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: reacquisition, conversion, recipe regeneration, source-retention, and atomic replacement are one transaction boundary.
20
+ export async function reimportLocalCatalogAsset(
21
+ publicRoot: string,
22
+ assetPath: string,
23
+ rawPlan: AssetImportPlan,
24
+ ): Promise<{ path: string; provenanceOperationId?: string }> {
25
+ const plan = assetImportPlanSchema.parse(rawPlan);
26
+ const normalizedAssetPath = assetPath.replace(/^\/+/, '');
27
+ if (normalizedAssetPath !== plan.destination || plan.identity.source !== 'local') {
28
+ throw new Error('Reimport identity does not match the selected local project asset.');
29
+ }
30
+ const family = await findLocalAssetFamily(plan.identity.familyId);
31
+ const variant = await findLocalAsset(plan.identity.variantId);
32
+ if (!family || !variant || variant.familyId !== family.id) {
33
+ throw new Error('The recorded catalog family/variant is no longer available.');
34
+ }
35
+ const sourceFile = variant.files[0];
36
+ if (!sourceFile) throw new Error('The recorded catalog variant has no source file.');
37
+ const destination = resolve(publicRoot, dirname(plan.destination));
38
+ if (!isPathInside(publicRoot, destination))
39
+ throw new Error('Reimport destination escapes public/.');
40
+ const staging = await mkdtemp(join(dirname(destination), `.${basename(destination)}.reimport-`));
41
+ try {
42
+ const libraryRoot = getLocalAssetLibraryRoot();
43
+ const primarySource = resolveCatalogFile(libraryRoot, sourceFile.path);
44
+ const primaryRelative = basename(primarySource);
45
+ const stagedPrimary = join(staging, primaryRelative);
46
+ await copyFile(primarySource, stagedPrimary);
47
+ const recipeSources: CatalogRecipeSource[] = [
48
+ {
49
+ stagedPath: stagedPrimary,
50
+ path: primaryRelative,
51
+ url: plan.sourceFiles[0]?.url ?? `vgai-local:${variant.id}:0`,
52
+ role: 'primary',
53
+ },
54
+ ];
55
+ for (const dependency of sourceFile.dependencies ?? []) {
56
+ const dependencySource = resolveCatalogFile(libraryRoot, dependency);
57
+ const sourceRelative = relative(dirname(primarySource), dependencySource);
58
+ const dependencyRelative = sourceRelative.startsWith('..')
59
+ ? basename(dependencySource)
60
+ : sourceRelative;
61
+ const destinationPath = join(staging, dependencyRelative);
62
+ await mkdir(dirname(destinationPath), { recursive: true });
63
+ await copyFile(dependencySource, destinationPath);
64
+ recipeSources.push({
65
+ stagedPath: destinationPath,
66
+ path: dependencyRelative.replaceAll('\\', '/'),
67
+ url: `catalog:${dependency}`,
68
+ role: dependencyRole(dependency),
69
+ });
70
+ }
71
+ const format = sourceFile.format.toLowerCase();
72
+ const resultFile = basename(plan.destination);
73
+ let backend: AssetImportPlan['backend'] = 'native';
74
+ if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
75
+ const output = await convertStagedModelToGlb(stagedPrimary, format, plan.settings);
76
+ await writeFile(join(staging, resultFile), output);
77
+ backend = 'threejs';
78
+ } else if (resultFile !== primaryRelative) {
79
+ await copyFile(stagedPrimary, join(staging, resultFile));
80
+ }
81
+ try {
82
+ const thumbnail = resolveLocalAssetThumbnail(libraryRoot, variant);
83
+ await copyFile(thumbnail, join(staging, '.vgai-thumbnail.webp'));
84
+ } catch {
85
+ // Thumbnails are a presentation cache and never invalidate a source reimport.
86
+ }
87
+ await writeCatalogImportRecipe({
88
+ stagingDirectory: staging,
89
+ source: 'local',
90
+ familyId: family.id,
91
+ variantId: variant.id,
92
+ deliveryId: plan.identity.deliveryId,
93
+ displayName: plan.identity.displayName ?? family.name,
94
+ destination: plan.destination,
95
+ sources: recipeSources,
96
+ contentKind: plan.contentKind,
97
+ ...(plan.targetSkeleton ? { targetSkeleton: plan.targetSkeleton } : {}),
98
+ generatedFiles: backend === 'threejs' ? [plan.destination] : [],
99
+ runtimeReady: ['glb', 'gltf'].includes(format) || backend === 'threejs',
100
+ backend,
101
+ attribution: plan.attribution,
102
+ settings: plan.settings,
103
+ });
104
+ if (backend === 'threejs' && !plan.settings.retainSource) {
105
+ await Promise.all(recipeSources.map((source) => rm(source.stagedPath, { force: true })));
106
+ }
107
+ const result = await replaceStagedProjectDirectory({
108
+ projectRoot: dirname(publicRoot),
109
+ stagedDirectory: staging,
110
+ destination,
111
+ provenance: {
112
+ operationName: 'catalog.asset.reimport',
113
+ operationSource: 'vgai-catalog',
114
+ input: {
115
+ familyId: family.id,
116
+ variantId: variant.id,
117
+ sourceHash: plan.sourceHash,
118
+ settings: plan.settings,
119
+ },
120
+ },
121
+ });
122
+ return {
123
+ path: plan.destination,
124
+ ...(result.provenanceOperationId
125
+ ? { provenanceOperationId: result.provenanceOperationId }
126
+ : {}),
127
+ };
128
+ } catch (error) {
129
+ await rm(staging, { recursive: true, force: true });
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ function dependencyRole(path: string): 'dependency' | 'material' | 'texture' {
135
+ const extension = extname(path).toLowerCase();
136
+ if (extension === '.mtl') return 'material';
137
+ if (['.png', '.jpg', '.jpeg', '.webp', '.ktx', '.ktx2', '.dds', '.tga'].includes(extension)) {
138
+ return 'texture';
139
+ }
140
+ return 'dependency';
141
+ }
@@ -1,7 +1,7 @@
1
1
  export const DEFAULT_CLOUD_ASSET_BASE_URL = 'https://vgai-asset-library.aaron-0ed.workers.dev';
2
2
 
3
3
  export interface CloudAssetObject {
4
- key: string;
4
+ key?: string;
5
5
  sha256: string;
6
6
  sizeBytes: number;
7
7
  contentType: string;
@@ -17,6 +17,8 @@ export interface CloudAssetFile {
17
17
 
18
18
  export interface CloudAssetRecord {
19
19
  id: string;
20
+ familyId?: string;
21
+ label?: string;
20
22
  status: 'validated';
21
23
  source: string;
22
24
  name: string;
@@ -25,17 +27,24 @@ export interface CloudAssetRecord {
25
27
  author: string;
26
28
  sourceUrl: string;
27
29
  attribution: string;
30
+ categories?: string[];
31
+ tags?: string[];
28
32
  files: CloudAssetFile[];
29
33
  }
30
34
 
31
35
  interface CloudAssetManifest {
32
- version: 1;
36
+ version: 1 | 2;
33
37
  generatedAt: string;
34
38
  assets: CloudAssetRecord[];
35
39
  }
36
40
 
37
41
  let cachedManifest: { value: CloudAssetManifest; url: string; loadedAt: number } | undefined;
38
42
  const CACHE_MS = 5 * 60 * 1000;
43
+ const GRAPHQL_ID_BATCH = 500;
44
+ const graphqlAssetCache = new Map<
45
+ string,
46
+ { baseUrl: string; value: CloudAssetRecord | null; loadedAt: number }
47
+ >();
39
48
 
40
49
  export function getCloudAssetBaseUrl(): string {
41
50
  return (process.env['VGAI_CLOUD_ASSET_URL'] ?? DEFAULT_CLOUD_ASSET_BASE_URL).replace(/\/+$/, '');
@@ -54,7 +63,7 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
54
63
  const response = await fetch(`${baseUrl}/v1/manifest`, { signal: AbortSignal.timeout(5_000) });
55
64
  if (!response.ok) return null;
56
65
  const value = (await response.json()) as CloudAssetManifest;
57
- if (value.version !== 1 || !Array.isArray(value.assets)) return null;
66
+ if ((value.version !== 1 && value.version !== 2) || !Array.isArray(value.assets)) return null;
58
67
  cachedManifest = { value, url: baseUrl, loadedAt: Date.now() };
59
68
  return value;
60
69
  } catch {
@@ -63,8 +72,65 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
63
72
  }
64
73
 
65
74
  export async function findCloudAsset(id: string): Promise<CloudAssetRecord | null> {
66
- const manifest = await loadCloudAssetManifest();
67
- return manifest?.assets.find((asset) => asset.id === id) ?? null;
75
+ return (await findCloudAssets([id]))[0] ?? null;
76
+ }
77
+
78
+ async function queryCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[] | null> {
79
+ if (ids.length === 0) return [];
80
+ const response = await fetch(`${getCloudAssetBaseUrl()}/graphql`, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({
84
+ query: `query CloudAssets($ids: [ID!]!) {
85
+ assetsByIds(ids: $ids) {
86
+ id familyId label status source name type license author sourceUrl attribution categories tags
87
+ files: deliveries {
88
+ label format
89
+ main { sha256 sizeBytes contentType relativePath }
90
+ dependencies { sha256 sizeBytes contentType relativePath }
91
+ }
92
+ }
93
+ }`,
94
+ variables: { ids },
95
+ }),
96
+ signal: AbortSignal.timeout(5_000),
97
+ });
98
+ if (!response.ok) return null;
99
+ const body = (await response.json()) as {
100
+ data?: { assetsByIds?: CloudAssetRecord[] };
101
+ errors?: unknown[];
102
+ };
103
+ return body.errors || !Array.isArray(body.data?.assetsByIds) ? null : body.data.assetsByIds;
104
+ }
105
+
106
+ /** Resolve exact hosted variants through GraphQL; REST manifest is rollout fallback only. */
107
+ export async function findCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[]> {
108
+ const uniqueIds = [...new Set(ids)];
109
+ const baseUrl = getCloudAssetBaseUrl();
110
+ const now = Date.now();
111
+ const unresolved = uniqueIds.filter((id) => {
112
+ const cached = graphqlAssetCache.get(id);
113
+ return !cached || cached.baseUrl !== baseUrl || now - cached.loadedAt >= CACHE_MS;
114
+ });
115
+ try {
116
+ for (let offset = 0; offset < unresolved.length; offset += GRAPHQL_ID_BATCH) {
117
+ const batch = unresolved.slice(offset, offset + GRAPHQL_ID_BATCH);
118
+ const found = await queryCloudAssets(batch);
119
+ if (!found) throw new Error('GraphQL catalog unavailable.');
120
+ const byId = new Map(found.map((asset) => [asset.id, asset]));
121
+ for (const id of batch) {
122
+ graphqlAssetCache.set(id, { baseUrl, value: byId.get(id) ?? null, loadedAt: now });
123
+ }
124
+ }
125
+ return uniqueIds.flatMap((id) => {
126
+ const asset = graphqlAssetCache.get(id)?.value;
127
+ return asset ? [asset] : [];
128
+ });
129
+ } catch {
130
+ const manifest = await loadCloudAssetManifest();
131
+ const requested = new Set(uniqueIds);
132
+ return manifest?.assets.filter((asset) => requested.has(asset.id)) ?? [];
133
+ }
68
134
  }
69
135
 
70
136
  export function cloudAssetObjectUrl(
package/server/dev.ts CHANGED
@@ -209,7 +209,7 @@ async function main(): Promise<void> {
209
209
  engineRoot,
210
210
  projectPath,
211
211
  watchEngineSource: true,
212
- loadProjectModule: (moduleId) => vite.ssrLoadModule(moduleId),
212
+ loadProjectModule: (moduleId: string) => vite.ssrLoadModule(moduleId),
213
213
  onProjectOpened(newProjectPath: string) {
214
214
  // Dynamically allow Vite to serve /@fs/ paths from the new project directory
215
215
  const allowed = vite.config.server.fs.allow;
@@ -19,6 +19,13 @@ import express from 'express';
19
19
  import { GAME_MANIFEST_VERSION } from '../../engine/src/manifest/schema';
20
20
  import { applyAssetRename } from '../../engine/src/scene/asset-paths';
21
21
  import type { SceneFile } from '../../engine/src/scene/scene-types';
22
+ import {
23
+ deriveProjectAttributionReport,
24
+ emptyProjectProvenanceDocument,
25
+ formatProjectAttributionMarkdown,
26
+ PROJECT_PROVENANCE_PATH,
27
+ ProjectProvenanceDocumentSchema,
28
+ } from '../../vgai-sdk/src/project/provenance';
22
29
  import {
23
30
  assertEditorCompatibility,
24
31
  assertProjectCompatibility,
@@ -39,10 +46,10 @@ import {
39
46
  } from './editor-sse';
40
47
  import { HarnessChatService } from './harness-chat-service';
41
48
  import {
42
- discoverProjectOperations,
43
- executeProjectOperation,
49
+ discoverProjectTools,
50
+ executeProjectTool,
44
51
  type ProjectModuleLoader,
45
- } from './project-operations';
52
+ } from './project-tools';
46
53
  import { classifyValidatableFile, validateProjectFile } from './project-validation';
47
54
  import { readProjectView } from './project-view';
48
55
  import type { CommandResult } from './server-utils';
@@ -70,6 +77,7 @@ interface AssetEntry {
70
77
  name: string;
71
78
  type: 'file' | 'directory';
72
79
  size: number;
80
+ mtime: number;
73
81
  }
74
82
 
75
83
  interface RecentProject {
@@ -142,9 +150,26 @@ export interface EditorServerOptions {
142
150
  /** Dev checkout only: mark this long-running process stale when server-loaded source changes. */
143
151
  watchEngineSource?: boolean | undefined;
144
152
  /** Node-side project-module loader. Vite's ssrLoadModule supplies this in
145
- * dev and packaged editors so discoverable project operations can remain
153
+ * dev and packaged editors so discoverable project tools can remain
146
154
  * ordinary TypeScript using the project's own dependency graph. */
147
155
  loadProjectModule?: ProjectModuleLoader | undefined;
156
+ /** Install a newly scaffolded project's dependencies before reporting it ready. */
157
+ installProjectDependencies?: ((projectPath: string) => Promise<void>) | undefined;
158
+ }
159
+
160
+ async function installProjectDependencies(projectPath: string): Promise<void> {
161
+ await new Promise<void>((resolveInstall, rejectInstall) => {
162
+ const child = spawn(
163
+ 'npm',
164
+ ['install', '--no-audit', '--no-fund', '--loglevel=error'],
165
+ { cwd: projectPath, stdio: 'ignore', shell: true },
166
+ );
167
+ child.once('error', rejectInstall);
168
+ child.once('close', (code) => {
169
+ if (code === 0) resolveInstall();
170
+ else rejectInstall(new Error(`npm install exited with code ${code ?? 'unknown'}`));
171
+ });
172
+ });
148
173
  }
149
174
 
150
175
  function readRunningEngineVersion(engineRoot: string): string | null {
@@ -654,9 +679,8 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
654
679
  // packaged/prod (this module is shared by both), and on drvfs
655
680
  // (`/mnt/c`, no inotify) via the same `VGAI_WATCH_POLL` env var.
656
681
  //
657
- // Tool/data/story lists use add/unlink. Operation modules also use change:
658
- // they are Node-loaded rather than browser-HMR-loaded, so metadata and
659
- // implementation edits require a fresh catalog request.
682
+ // Tool/data/story lists use add/unlink. Registered tool modules also use
683
+ // change because their metadata and implementation are Node-loaded.
660
684
  const srcDir = join(projectRoot, 'src');
661
685
  if (projectRoot !== engineRoot && existsSync(srcDir)) {
662
686
  // #131: same polling decision as the publicRoot watcher above (env
@@ -674,20 +698,18 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
674
698
  tools: 'tool-files-changed',
675
699
  data: 'data-files-changed',
676
700
  stories: 'story-files-changed',
677
- operations: 'operation-files-changed',
678
701
  }[kind]
679
702
  : undefined;
680
703
  if (event) broadcastDebounced(event, { path: rel });
681
704
  };
682
705
  srcWatcher.on('add', onListChange);
683
706
  srcWatcher.on('unlink', onListChange);
684
- // Tool edits ride Vite HMR and data edits have their own live-value
685
- // path. Node-loaded project operations instead need a fresh catalog
686
- // read when metadata or implementation changes in place.
707
+ // Registered callable tool edits need a fresh Node-side catalog read;
708
+ // contribution modules still ride ordinary Vite HMR in the browser.
687
709
  srcWatcher.on('change', (filePath) => {
688
710
  const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
689
- if (classifyProjectSrcPath(rel) === 'operations') {
690
- broadcastDebounced('operation-files-changed', { path: rel });
711
+ if (classifyProjectSrcPath(rel) === 'tools') {
712
+ broadcastDebounced('tool-files-changed', { path: rel });
691
713
  }
692
714
  });
693
715
  srcWatcher.on('add', (filePath) => scheduleFileValidation(resolve(filePath)));
@@ -852,6 +874,55 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
852
874
  createAssetLibraryRouter(() => publicRoot),
853
875
  );
854
876
 
877
+ // Derived credits view. The sole durable source is central project provenance.
878
+ router.get('/__editor/project-attribution', async (req: Request, res: Response) => {
879
+ if (projectRoot === engineRoot) {
880
+ res.status(400).json({ error: 'No project is open.' });
881
+ return;
882
+ }
883
+ try {
884
+ const raw = await readFile(join(projectRoot, PROJECT_PROVENANCE_PATH), 'utf8').catch(
885
+ (error: NodeJS.ErrnoException) => {
886
+ if (error.code === 'ENOENT') return null;
887
+ throw error;
888
+ },
889
+ );
890
+ const provenance = raw
891
+ ? ProjectProvenanceDocumentSchema.parse(JSON.parse(raw))
892
+ : emptyProjectProvenanceDocument();
893
+ const activeOperations = Object.fromEntries(
894
+ (
895
+ await Promise.all(
896
+ Object.entries(provenance.operations).map(async ([operationId, operation]) => {
897
+ const present = await Promise.all(
898
+ operation.outputs.map(async (output) => {
899
+ const absolute = resolve(projectRoot, output.path);
900
+ if (!isPathInside(projectRoot, absolute)) return false;
901
+ return stat(absolute).then(
902
+ (info) => info.isFile(),
903
+ () => false,
904
+ );
905
+ }),
906
+ );
907
+ return present.some(Boolean) ? ([operationId, operation] as const) : null;
908
+ }),
909
+ )
910
+ ).filter((entry): entry is readonly [string, (typeof provenance.operations)[string]] =>
911
+ Boolean(entry),
912
+ ),
913
+ );
914
+ const report = deriveProjectAttributionReport({ version: 1, operations: activeOperations });
915
+ const format = typeof req.query['format'] === 'string' ? req.query['format'] : 'json';
916
+ if (format === 'markdown' || req.accepts(['json', 'text/markdown']) === 'text/markdown') {
917
+ res.type('text/markdown').send(formatProjectAttributionMarkdown(report));
918
+ return;
919
+ }
920
+ res.json(report);
921
+ } catch (error) {
922
+ res.status(500).json({ error: `Project attribution failed: ${error}` });
923
+ }
924
+ });
925
+
855
926
  // ---- Project file serving middleware ----
856
927
  // Serves the v2 manifest from root and static files from project's public/
857
928
  router.use((req: Request, res: Response, next) => {
@@ -1253,11 +1324,11 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
1253
1324
  for (const d of dirents) {
1254
1325
  if (d.name.startsWith('.')) continue;
1255
1326
  const entryPath = join(absDir, d.name);
1327
+ const s = await stat(entryPath);
1256
1328
  if (d.isDirectory()) {
1257
- entries.push({ name: d.name, type: 'directory', size: 0 });
1329
+ entries.push({ name: d.name, type: 'directory', size: 0, mtime: s.mtimeMs });
1258
1330
  } else if (d.isFile()) {
1259
- const s = await stat(entryPath);
1260
- entries.push({ name: d.name, type: 'file', size: s.size });
1331
+ entries.push({ name: d.name, type: 'file', size: s.size, mtime: s.mtimeMs });
1261
1332
  }
1262
1333
  }
1263
1334
 
@@ -1513,6 +1584,8 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
1513
1584
  ...(exampleId !== undefined ? { exampleId } : {}),
1514
1585
  });
1515
1586
 
1587
+ await (options.installProjectDependencies ?? installProjectDependencies)(result.targetDir);
1588
+
1516
1589
  // Ensure .vgai/ directory exists
1517
1590
  await mkdir(join(projectDir, '.vgai'), { recursive: true });
1518
1591
 
@@ -2075,50 +2148,27 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
2075
2148
  }
2076
2149
  });
2077
2150
 
2078
- // ---- Project tools (W4, docs/DATA-TOOLS-DESIGN.md §3) ----
2079
- //
2080
- // Discovery for the dock's project-tool tabs: every
2081
- // `src/tools/**/*.tool.tsx` of the open project (folder scan, same D3
2082
- // physics as `/__editor/data-files` above — no registry, no cached
2083
- // manifest; §3.5 rule 2). Paths only: unlike data files, tool MODULES are
2084
- // loaded client-side through Vite's `/@fs/` dynamic-import path (the
2085
- // `script-loader.ts` pattern), so the code arrives transpiled with HMR —
2086
- // never as raw text through this route.
2087
- router.get('/__editor/tool-files', async (_req: Request, res: Response) => {
2088
- if (projectRoot === engineRoot) {
2089
- res.json({ files: [] });
2090
- return;
2091
- }
2092
- const toolsDir = join(projectRoot, 'src', 'tools');
2093
- const found = await findFiles(toolsDir, '.tool.tsx');
2094
- res.json({
2095
- files: found.sort().map((abs) => ({
2096
- path: relative(projectRoot, abs).split('\\').join('/'),
2097
- })),
2098
- });
2099
- });
2100
-
2101
- // ---- Project operations (docs/PROJECT-OPERATIONS-DESIGN.md) ----
2151
+ // ---- Registered project tools (docs/PROJECT-TOOLS-DESIGN.md) ----
2102
2152
  //
2103
- // Unlike project tools, operation modules are loaded on the NODE side.
2104
- // Merely listing a Node-native asset/map build script must never import it
2153
+ // Callable modules are loaded on the NODE side. Merely listing a native
2154
+ // asset/map build script must never import it
2105
2155
  // into the editor browser or make it reachable from a standalone game
2106
2156
  // bundle. Vite's ssrLoadModule is injected by the owning server and gives
2107
2157
  // us normal TS transform + project dependency resolution without a second
2108
2158
  // script runtime.
2109
- router.get('/__editor/project-operations', async (_req: Request, res: Response) => {
2159
+ router.get('/__editor/project-tools', async (_req: Request, res: Response) => {
2110
2160
  if (projectRoot === engineRoot) {
2111
- res.json({ operations: [], loadErrors: [] });
2161
+ res.json({ tools: [], loadErrors: [] });
2112
2162
  return;
2113
2163
  }
2114
- res.json(await discoverProjectOperations(projectRoot, options.loadProjectModule));
2164
+ res.json(await discoverProjectTools(projectRoot, options.loadProjectModule));
2115
2165
  });
2116
2166
 
2117
- router.post('/__editor/project-operations/run', async (req: Request, res: Response) => {
2167
+ router.post('/__editor/project-tools/run', async (req: Request, res: Response) => {
2118
2168
  if (projectRoot === engineRoot) {
2119
2169
  res.status(400).json({
2120
2170
  ok: false,
2121
- error: { code: 'NO_PROJECT_OPEN', message: 'Open a project before running an operation.' },
2171
+ error: { code: 'NO_PROJECT_OPEN', message: 'Open a project before running a tool.' },
2122
2172
  });
2123
2173
  return;
2124
2174
  }
@@ -2126,13 +2176,13 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
2126
2176
  if (typeof body.name !== 'string' || body.name.trim() === '') {
2127
2177
  res.status(400).json({
2128
2178
  ok: false,
2129
- error: { code: 'INVALID_OPERATION_NAME', message: 'Expected a non-empty operation name.' },
2179
+ error: { code: 'INVALID_TOOL_NAME', message: 'Expected a non-empty tool name.' },
2130
2180
  });
2131
2181
  return;
2132
2182
  }
2133
2183
  const controller = new AbortController();
2134
2184
  req.once('aborted', () => controller.abort());
2135
- const result = await executeProjectOperation({
2185
+ const result = await executeProjectTool({
2136
2186
  projectRoot,
2137
2187
  loadModule: options.loadProjectModule,
2138
2188
  name: body.name,
@@ -2251,6 +2301,7 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
2251
2301
  // ---- Play-mode console log persistence ----
2252
2302
  const MAX_LOG_FILES = 20;
2253
2303
  let activeLogFile: string | null = null;
2304
+ let logSessionSequence = 0;
2254
2305
 
2255
2306
  router.post('/__editor/log-session', async (req: Request, res: Response) => {
2256
2307
  const body = req.body as { action: 'start' | 'end' };
@@ -2273,8 +2324,12 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
2273
2324
  }
2274
2325
 
2275
2326
  const now = new Date();
2276
- const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
2277
- const filename = `play-${ts}.jsonl`;
2327
+ // Milliseconds plus a process-local sequence make restart artifacts
2328
+ // distinct even when two sessions begin inside the same millisecond.
2329
+ // Lexicographic order remains chronological for pruning/tail tooling.
2330
+ const ts = now.toISOString().replace(/[:.]/g, '-');
2331
+ const sequence = String(++logSessionSequence).padStart(4, '0');
2332
+ const filename = `play-${ts}-${sequence}.jsonl`;
2278
2333
  activeLogFile = join(logsDir, filename);
2279
2334
  res.json({ ok: true, file: filename });
2280
2335
  } else {
@@ -119,6 +119,11 @@ export function updateClientControlHealth(clientId: string, health: ClientContro
119
119
  clientControlHealth.set(clientId, health);
120
120
  }
121
121
 
122
+ /** Clear project-specific health snapshots while keeping live SSE sockets. */
123
+ export function clearClientControlHealth(): void {
124
+ clientControlHealth.clear();
125
+ }
126
+
122
127
  /** Number of currently-connected clients (exposed for tests). */
123
128
  export function clientCount(): number {
124
129
  return sseClients.size;