@gripforgeai/mcp 0.1.10 → 0.1.12
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 +21 -0
- package/README.md +82 -2
- package/dist/ability-tools.js +21 -0
- package/dist/architecture-tools.js +83 -0
- package/dist/asset-production-tools.js +101 -0
- package/dist/creature-rig-tools.js +41 -0
- package/dist/game-concept-tools.js +56 -0
- package/dist/game-creation-tools.js +45 -0
- package/dist/gamekit-deliver-local.js +5 -7
- package/dist/gamekit-tools.js +133 -24
- package/dist/hero-ability-tools.js +48 -0
- package/dist/joystick-kit/godot.js +146 -0
- package/dist/joystick-kit/index.js +32 -0
- package/dist/joystick-kit/threejs.js +93 -0
- package/dist/joystick-kit/unity.js +182 -0
- package/dist/joystick-kit/unreal.js +231 -0
- package/dist/joystick-tools.js +26 -0
- package/dist/look.js +6 -0
- package/dist/map-unreal-export-local.js +285 -0
- package/dist/map-unreal-import-local.js +421 -0
- package/dist/map-unreal-local.js +75 -0
- package/dist/motion-tools.js +50 -0
- package/dist/projectile-tools.js +28 -0
- package/dist/scene-tools.js +79 -13
- package/dist/server-tools.js +3 -3
- package/dist/server.js +139 -13
- package/dist/shield-vfx-tools.js +29 -0
- package/dist/studio-tools-tools.js +106 -0
- package/dist/unreal-export-package.js +212 -0
- package/dist/vfx-project-tools.js +2 -1
- package/dist/weapon-motion-tools.js +34 -0
- package/package.json +6 -1
- package/runtime/unreal-scene-export.py +341 -0
- package/runtime/unreal-scene-import.py +622 -0
- package/runtime/unreal-scene-upload.mjs +7 -0
- package/runtime/unreal_scene_math.py +96 -0
- package/runtime/unreal_spawn_review.py +135 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readFile, writeFile, mkdir, copyFile, realpath, open, unlink } from 'node:fs/promises';
|
|
5
|
+
import { spawn, execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { unrealLaunchCommand } from './map-unreal-import-local.js';
|
|
8
|
+
import { atomicJson, readJson, sha256, buildUnrealStudioPackage, exportFile } from './unreal-export-package.js';
|
|
9
|
+
const absolute = z.string().min(1).max(4096).refine(isAbsolute, 'Absolute local path required.').refine(s => !/[\r\n\0"]/.test(s), 'Invalid path.');
|
|
10
|
+
const level = z.string().regex(/^\/Game(?:\/[\w-]+)+$/);
|
|
11
|
+
const qualities = { texture_size: z.union([z.literal(512), z.literal(1024), z.literal(2048)]).default(1024), terrain_texture_size: z.union([z.literal(1024), z.literal(2048), z.literal(4096)]).default(4096), terrain_resolution: z.union([z.literal(129), z.literal(257), z.literal(513), z.literal(1025)]).default(513) };
|
|
12
|
+
const requestSchema = z.object({ schema: z.literal('gripforge.unreal-export-request.v1'), project_file: absolute, source_level: level, source_sha256: z.string().regex(/^[a-f0-9]{64}$/), ...qualities }).strict();
|
|
13
|
+
function alive(pid) { if (typeof pid !== 'number' || pid <= 0)
|
|
14
|
+
return false; try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code === 'EPERM')
|
|
20
|
+
return true;
|
|
21
|
+
if (e.code !== 'ESRCH')
|
|
22
|
+
throw e;
|
|
23
|
+
return false;
|
|
24
|
+
} }
|
|
25
|
+
async function optionalJson(file) { try {
|
|
26
|
+
return await readJson(file);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e.code !== 'ENOENT')
|
|
30
|
+
throw e;
|
|
31
|
+
return null;
|
|
32
|
+
} }
|
|
33
|
+
async function withLaunchLock(dir, stage, task) {
|
|
34
|
+
const file = join(dir, stage + '-launch.lock');
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(file, JSON.stringify({ pid: process.pid }), { flag: 'wx' });
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== 'EEXIST')
|
|
40
|
+
throw e;
|
|
41
|
+
if (alive((await readJson(file)).pid))
|
|
42
|
+
throw Error('Another launch request is in progress.');
|
|
43
|
+
await unlink(file);
|
|
44
|
+
return withLaunchLock(dir, stage, task);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return await task();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await unlink(file);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function prepareUnrealExport(input) {
|
|
54
|
+
absolute.parse(input.job_directory);
|
|
55
|
+
absolute.parse(input.project_file);
|
|
56
|
+
level.parse(input.source_level);
|
|
57
|
+
const project = await realpath(input.project_file);
|
|
58
|
+
if (!project.endsWith('.uproject'))
|
|
59
|
+
throw Error('Expected an installed .uproject.');
|
|
60
|
+
const source = join(dirname(project), 'Content', input.source_level.slice(6) + '.umap');
|
|
61
|
+
const request = requestSchema.parse({ schema: 'gripforge.unreal-export-request.v1', project_file: project, source_level: input.source_level, source_sha256: sha256(await readFile(source)), texture_size: input.texture_size, terrain_texture_size: input.terrain_texture_size, terrain_resolution: input.terrain_resolution });
|
|
62
|
+
await mkdir(input.job_directory, { recursive: true });
|
|
63
|
+
const dir = await realpath(input.job_directory);
|
|
64
|
+
const previous = await optionalJson(join(dir, 'request.json'));
|
|
65
|
+
if (previous && JSON.stringify(requestSchema.parse(previous)) !== JSON.stringify(request))
|
|
66
|
+
throw Error('Job inputs changed. Use a new job directory.');
|
|
67
|
+
if (!previous)
|
|
68
|
+
await writeFile(join(dir, 'request.json'), JSON.stringify(request), { flag: 'wx' });
|
|
69
|
+
const worker = fileURLToPath(new URL('../runtime/unreal-scene-export.py', import.meta.url)), target = join(dir, 'unreal-scene-export.py');
|
|
70
|
+
try {
|
|
71
|
+
await copyFile(worker, target, 1);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e.code !== 'EEXIST')
|
|
75
|
+
throw e;
|
|
76
|
+
if (sha256(await readFile(worker)) !== sha256(await readFile(target)))
|
|
77
|
+
throw Error('Worker version changed. Prepare a new job directory.');
|
|
78
|
+
}
|
|
79
|
+
return { job_directory: dir, request, lifecycle: 'work' };
|
|
80
|
+
}
|
|
81
|
+
export async function unrealExportStatus(directory) {
|
|
82
|
+
absolute.parse(directory);
|
|
83
|
+
const dir = await realpath(directory);
|
|
84
|
+
requestSchema.parse(await readJson(join(dir, 'request.json')));
|
|
85
|
+
const result = { job_directory: dir, lifecycle: 'work' };
|
|
86
|
+
for (const stage of ['export', 'upload']) {
|
|
87
|
+
const file = stage === 'export' ? 'status.json' : 'upload-status.json', launch = await optionalJson(join(dir, stage + '-launch.json'));
|
|
88
|
+
let state = await optionalJson(join(dir, file));
|
|
89
|
+
if (launch && (!state || launch.started_at > (state.updated_at ?? 0)))
|
|
90
|
+
state = { ...launch, status: 'running', stage: 'launching' };
|
|
91
|
+
if (!state) {
|
|
92
|
+
result[stage] = { status: 'pending' };
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const running = alive(launch?.pid ?? state.pid);
|
|
96
|
+
if (state.status === 'running' && !running)
|
|
97
|
+
state = { ...state, status: 'interrupted', error: 'Worker exited. Read its log and resume the saved job; completed steps remain available.' };
|
|
98
|
+
result[stage] = { ...state, worker_running: running };
|
|
99
|
+
}
|
|
100
|
+
if (result.upload.studio_url)
|
|
101
|
+
result.studio_url = result.upload.studio_url;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
export async function startUnrealExport(directory, editor) {
|
|
105
|
+
absolute.parse(directory);
|
|
106
|
+
absolute.parse(editor);
|
|
107
|
+
const dir = await realpath(directory), executable = await realpath(editor);
|
|
108
|
+
if (!/^UnrealEditor(?:-Cmd)?(?:\.exe)?$/.test(basename(executable)))
|
|
109
|
+
throw Error('Expected UnrealEditor or UnrealEditor-Cmd executable.');
|
|
110
|
+
return withLaunchLock(dir, 'export', async () => {
|
|
111
|
+
const state = await unrealExportStatus(dir);
|
|
112
|
+
if (state.export.worker_running || state.export.status === 'succeeded')
|
|
113
|
+
return state;
|
|
114
|
+
const request = requestSchema.parse(await readJson(join(dir, 'request.json')));
|
|
115
|
+
const source = join(dirname(request.project_file), 'Content', request.source_level.slice(6) + '.umap');
|
|
116
|
+
if (sha256(await readFile(source)) !== request.source_sha256)
|
|
117
|
+
throw Error('Source level changed. Prepare a new export.');
|
|
118
|
+
const worker = await exportFile(dir, 'unreal-scene-export.py');
|
|
119
|
+
const current = fileURLToPath(new URL('../runtime/unreal-scene-export.py', import.meta.url));
|
|
120
|
+
if (sha256(await readFile(worker)) !== sha256(await readFile(current)))
|
|
121
|
+
throw Error('Worker changed. Prepare a new job.');
|
|
122
|
+
await atomicJson(join(dir, 'control.json'), { cancel: false });
|
|
123
|
+
const arm = process.platform === 'darwin' && (await promisify(execFile)('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'])).stdout.trim() === '1';
|
|
124
|
+
const launch = unrealLaunchCommand(executable, [request.project_file, '-run=pythonscript', '-script=' + worker, '-GFExportJob=' + dir, '-AllowCommandletRendering', '-EnablePlugins=PythonScriptPlugin,EditorScriptingUtilities,GLTFExporter', '-unattended', '-nosplash', '-nosound', '-abslog=' + join(dir, 'export.log')], arm);
|
|
125
|
+
const log = await open(join(dir, 'export-launch.log'), 'a');
|
|
126
|
+
try {
|
|
127
|
+
const child = spawn(launch.command, launch.args, { cwd: dirname(executable), detached: true, shell: false, stdio: ['ignore', log.fd, log.fd] });
|
|
128
|
+
await new Promise((ok, bad) => { child.once('spawn', ok); child.once('error', bad); });
|
|
129
|
+
child.unref();
|
|
130
|
+
await atomicJson(join(dir, 'export-launch.json'), { pid: child.pid, started_at: Date.now() / 1000 });
|
|
131
|
+
return { job_directory: dir, status: 'running', stage: 'launching', pid: child.pid, next: 'Poll gripforge_map_unreal_export_status_local. No source level/assets will be saved.' };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await log.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function apiOrigin(value) {
|
|
139
|
+
const url = new URL(value);
|
|
140
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
|
|
141
|
+
throw Error('Use an HTTPS API origin, or HTTP loopback for local development.');
|
|
142
|
+
return url.origin;
|
|
143
|
+
}
|
|
144
|
+
async function api(url, key, workspace, path, method = 'GET', body, headers = {}) {
|
|
145
|
+
const response = await fetch(url + path, { method, redirect: 'error', headers: { 'x-api-key': key, 'x-workspace-id': workspace, 'x-gripforge-client': 'mcp', ...headers }, ...(body === undefined ? {} : { body: typeof body === 'string' ? body : new Uint8Array(body) }), signal: AbortSignal.timeout(120000) });
|
|
146
|
+
const data = await response.json();
|
|
147
|
+
if (!response.ok)
|
|
148
|
+
throw Error('GripForge API ' + response.status + ': ' + JSON.stringify(data).slice(0, 1500));
|
|
149
|
+
return data;
|
|
150
|
+
}
|
|
151
|
+
/** Runs in a detached Node worker. The credential is inherited in memory, never persisted in its checkpoints. */
|
|
152
|
+
export async function uploadUnrealExport(directory, origin, workspace, key) {
|
|
153
|
+
const dir = await realpath(directory), url = apiOrigin(origin), scope = sha256(url + ':' + workspace), stateFile = join(dir, 'upload-status.json');
|
|
154
|
+
let state = await optionalJson(stateFile) ?? {};
|
|
155
|
+
if (state.scope && state.scope !== scope)
|
|
156
|
+
throw Error('Upload job belongs to another workspace or API origin. Use a separate copy of the export.');
|
|
157
|
+
state = { ...state, scope, workspace_id: workspace, api_origin: url, status: 'running', pid: process.pid, lifecycle: 'work', updated_at: Date.now() / 1000, uploaded: state.uploaded ?? {} };
|
|
158
|
+
const progress = async (stage) => { state.stage = stage; state.updated_at = Date.now() / 1000; await atomicJson(stateFile, state); };
|
|
159
|
+
const checkCancel = async () => {
|
|
160
|
+
if ((await optionalJson(join(dir, 'upload-control.json')))?.cancel) {
|
|
161
|
+
if (state.job_id)
|
|
162
|
+
await api(url, key, workspace, '/api/v1/generation-jobs/' + encodeURIComponent(state.job_id), 'POST', JSON.stringify({ action: 'cancel' }), { 'content-type': 'application/json' });
|
|
163
|
+
throw Error('Upload cancelled; completed assets retained.');
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
await progress('assembling');
|
|
168
|
+
await checkCancel();
|
|
169
|
+
const portable = await buildUnrealStudioPackage(dir), hash = portable.manifest.sceneHash;
|
|
170
|
+
if (state.scene_hash && state.scene_hash !== hash)
|
|
171
|
+
throw Error('Portable scene changed during upload. Use a new job.');
|
|
172
|
+
state.scene_hash = hash;
|
|
173
|
+
state.total = portable.manifest.assets.length;
|
|
174
|
+
for (const file of portable.manifest.assets) {
|
|
175
|
+
await checkCancel();
|
|
176
|
+
if (state.uploaded[file.sha256])
|
|
177
|
+
continue;
|
|
178
|
+
state.file = file.name;
|
|
179
|
+
state.completed = Object.keys(state.uploaded).length;
|
|
180
|
+
await progress('uploading_assets');
|
|
181
|
+
const bytes = await readFile(await exportFile(dir, file.file));
|
|
182
|
+
if (sha256(bytes) !== file.sha256)
|
|
183
|
+
throw Error('Export changed: ' + file.file);
|
|
184
|
+
const response = await api(url, key, workspace, '/api/v1/maps/import/unreal/assets', 'POST', bytes, { 'content-type': file.mime, 'x-asset-filename': basename(file.file), 'x-content-sha256': file.sha256 });
|
|
185
|
+
if (response.sha256 !== file.sha256 || !response.asset?.assetId || !response.asset?.revisionId)
|
|
186
|
+
throw Error('Unexpected asset upload response.');
|
|
187
|
+
state.uploaded[file.sha256] = response.asset;
|
|
188
|
+
await progress('uploading_assets');
|
|
189
|
+
}
|
|
190
|
+
await checkCancel();
|
|
191
|
+
const refs = new Map(portable.manifest.assets.map(a => [a.assetId + ':' + a.revisionId, state.uploaded[a.sha256]]));
|
|
192
|
+
const replaceRefs = (v) => {
|
|
193
|
+
if (!v || typeof v !== 'object')
|
|
194
|
+
return v;
|
|
195
|
+
if (typeof v.assetId === 'string' && typeof v.revisionId === 'string') {
|
|
196
|
+
const ref = refs.get(v.assetId + ':' + v.revisionId);
|
|
197
|
+
if (!ref)
|
|
198
|
+
throw Error('Unresolved asset reference.');
|
|
199
|
+
return { ...ref, ...(v.fileRole ? { fileRole: v.fileRole } : {}) };
|
|
200
|
+
}
|
|
201
|
+
return Array.isArray(v) ? v.map(replaceRefs) : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, replaceRefs(x)]));
|
|
202
|
+
};
|
|
203
|
+
const input = replaceRefs(portable.input);
|
|
204
|
+
input.document.workspaceId = workspace;
|
|
205
|
+
input.idempotency_key = 'ue-import-' + hash;
|
|
206
|
+
await progress('queueing_import');
|
|
207
|
+
if (!state.job_id) {
|
|
208
|
+
const response = await api(url, key, workspace, '/api/v1/maps/import/unreal', 'POST', JSON.stringify(input), { 'content-type': 'application/json' });
|
|
209
|
+
if (typeof response.job_id !== 'string')
|
|
210
|
+
throw Error('Missing persistent import job.');
|
|
211
|
+
state.job_id = response.job_id;
|
|
212
|
+
await progress('saving_scene');
|
|
213
|
+
}
|
|
214
|
+
let first = true;
|
|
215
|
+
for (;;) {
|
|
216
|
+
await checkCancel();
|
|
217
|
+
const response = await api(url, key, workspace, '/api/v1/generation-jobs/' + encodeURIComponent(state.job_id)), job = response.job ?? response;
|
|
218
|
+
if (job.status === 'failed' || job.status === 'cancelled') {
|
|
219
|
+
if (!first)
|
|
220
|
+
throw Error('Import job ' + job.status + ': ' + String(job.error ?? 'Read generation_read for details.'));
|
|
221
|
+
await api(url, key, workspace, '/api/v1/generation-jobs/' + encodeURIComponent(state.job_id), 'POST', JSON.stringify({ action: 'retry' }), { 'content-type': 'application/json' });
|
|
222
|
+
}
|
|
223
|
+
else if (job.status === 'succeeded') {
|
|
224
|
+
if (!job.result?.studio_url)
|
|
225
|
+
throw Error('Import completed without a Studio link.');
|
|
226
|
+
Object.assign(state, { status: 'succeeded', result: job.result, studio_url: new URL(job.result.studio_url, url).href, completed: state.total });
|
|
227
|
+
await progress('awaiting_visual_review');
|
|
228
|
+
return state;
|
|
229
|
+
}
|
|
230
|
+
first = false;
|
|
231
|
+
state.remote_status = job.status;
|
|
232
|
+
await progress('saving_scene');
|
|
233
|
+
await new Promise(ok => setTimeout(ok, 3000));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
state.status = (await optionalJson(join(dir, 'upload-control.json')))?.cancel ? 'cancelled' : 'failed';
|
|
238
|
+
state.error = e instanceof Error ? e.message : String(e);
|
|
239
|
+
await progress(state.stage);
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export async function startUnrealUpload(directory, origin, workspace, key) {
|
|
244
|
+
if (!key)
|
|
245
|
+
throw Error('Connect a GripForge API key before uploading.');
|
|
246
|
+
absolute.parse(directory);
|
|
247
|
+
z.string().regex(/^[\w-]{1,100}$/).parse(workspace);
|
|
248
|
+
const url = apiOrigin(origin), dir = await realpath(directory);
|
|
249
|
+
return withLaunchLock(dir, 'upload', async () => {
|
|
250
|
+
const status = await unrealExportStatus(dir);
|
|
251
|
+
if (status.export.status !== 'succeeded')
|
|
252
|
+
throw Error('Wait for a successful native export.');
|
|
253
|
+
if (status.upload.scope && status.upload.scope !== sha256(url + ':' + workspace))
|
|
254
|
+
throw Error('Job already belongs to another workspace/API.');
|
|
255
|
+
if (status.upload.worker_running || status.upload.status === 'succeeded')
|
|
256
|
+
return status;
|
|
257
|
+
await atomicJson(join(dir, 'upload-control.json'), { cancel: false });
|
|
258
|
+
const log = await open(join(dir, 'upload.log'), 'a');
|
|
259
|
+
try {
|
|
260
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('../runtime/unreal-scene-upload.mjs', import.meta.url)), dir, url, workspace], { detached: true, shell: false, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, GRIPFORGE_API_KEY: key } });
|
|
261
|
+
await new Promise((ok, bad) => { child.once('spawn', ok); child.once('error', bad); });
|
|
262
|
+
child.unref();
|
|
263
|
+
await atomicJson(join(dir, 'upload-launch.json'), { pid: child.pid, started_at: Date.now() / 1000 });
|
|
264
|
+
return { job_directory: dir, status: 'running', stage: 'uploading', next: 'Poll gripforge_map_unreal_export_status_local for progress and studio_url.' };
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await log.close();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
export function registerMapUnrealExportLocalTools(register, options) {
|
|
272
|
+
const job = { job_directory: absolute.describe('Persistent local export directory; retain it to resume completed stages.') };
|
|
273
|
+
const wrap = (f) => async (a) => { try {
|
|
274
|
+
const data = await f(a);
|
|
275
|
+
return { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
|
|
279
|
+
} };
|
|
280
|
+
const annotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
281
|
+
register('gripforge_map_unreal_export_local', { title: 'Export an Unreal map to GripForge', description: 'Generic local UE 5.7 exporter. Pins the selected saved map and runs a separate Unreal Python commandlet without saving source assets. Exports PBR GLBs, all supported static placements, compact foliage patches, Landscape height/holes and baked albedo. Durable per-file checkpoints survive closed MCP calls. Native shader graphs, animated water/sky, Niagara and gameplay Blueprints are reported, not silently recreated. Work version requires visual review in GripForge. Local MCP only; Unreal/Python/GLTFExporter must be installed.', inputSchema: { project_file: absolute, source_level: level, editor_executable: absolute, ...job, ...qualities }, annotations }, wrap(async (a) => { const prepared = await prepareUnrealExport(a); return startUnrealExport(prepared.job_directory, String(a.editor_executable)); }));
|
|
282
|
+
register('gripforge_map_unreal_export_status_local', { title: 'Read Unreal export/upload progress', description: 'Read durable stages, failures, completed assets and final GripForge Studio link. Does not equate technical export success with visual validation.', inputSchema: job, annotations: { ...annotations, readOnlyHint: true } }, wrap(a => unrealExportStatus(String(a.job_directory))));
|
|
283
|
+
register('gripforge_map_unreal_export_cancel_local', { title: 'Cancel an Unreal export or upload', description: 'Request cancellation at the next safe asset checkpoint. Keeps completed files and uploaded private assets; resume with export_local or upload_local using the same directory. Never kills an unrelated Unreal editor.', inputSchema: { ...job, stage: z.enum(['export', 'upload']) }, annotations }, wrap(async (a) => { const state = await unrealExportStatus(String(a.job_directory)); await atomicJson(join(state.job_directory, a.stage === 'export' ? 'control.json' : 'upload-control.json'), { cancel: true }); return { cancel_requested: true, stage: a.stage, job_directory: state.job_directory }; }));
|
|
284
|
+
register('gripforge_map_unreal_upload_local', { title: 'Import exported Unreal map into your workspace', description: 'Assemble a completed native export as a common SceneDocument, upload immutable assets to the authenticated workspace and queue a persistent API import. Detached upload resumes per-file checkpoints. Poll export_status_local for studio_url. Private work only, never auto-promotes or publishes. 0 generation credits; workspace storage quota applies.', inputSchema: { ...job, workspace_id: z.string().regex(/^[\w-]{1,100}$/) }, annotations }, wrap(a => startUnrealUpload(String(a.job_directory), options.apiUrl, String(a.workspace_id), options.getApiKey() ?? '')));
|
|
285
|
+
}
|