@gripforgeai/mcp 0.1.2 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -9
- package/dist/gamekit-deliver-local.js +586 -0
- package/dist/gamekit-tools.js +298 -0
- package/dist/scene-tools.js +77 -0
- package/dist/server-tools.js +143 -0
- package/dist/server.js +2218 -42
- package/dist/vfx-project-tools.js +48 -0
- package/package.json +6 -7
package/dist/server.js
CHANGED
|
@@ -2,31 +2,49 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* GripForge MCP — thin client.
|
|
4
4
|
*
|
|
5
|
-
* Exposes gripforge_attach to any MCP client (
|
|
5
|
+
* Exposes gripforge_attach to any MCP client (Cursor, Windsurf, Grok…) and
|
|
6
6
|
* delegates the compute to the hosted GripForge API. Requires an API key:
|
|
7
7
|
* create one at https://gripforge.ai/login and set GRIPFORGE_API_KEY in the
|
|
8
8
|
* server env. 1 credit = 1 successful attach.
|
|
9
9
|
*/
|
|
10
10
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
11
|
-
import { basename, extname, join, resolve } from 'node:path';
|
|
11
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { registerVfxProjectTools } from './vfx-project-tools.js';
|
|
16
|
+
import { registerSceneTools } from './scene-tools.js';
|
|
17
|
+
import { registerServerTools } from './server-tools.js';
|
|
18
|
+
import { registerGameKitTools } from './gamekit-tools.js';
|
|
19
|
+
import { registerGameKitLocalTools } from './gamekit-deliver-local.js';
|
|
15
20
|
const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
|
|
16
21
|
const API_KEY = process.env.GRIPFORGE_API_KEY;
|
|
22
|
+
const MCP_SELF = '0.1.9';
|
|
17
23
|
const SUPPORTED = ['.glb', '.gltf', '.fbx', '.obj'];
|
|
18
|
-
const server = new McpServer({ name: 'gripforge', version:
|
|
24
|
+
const server = new McpServer({ name: 'gripforge', version: MCP_SELF });
|
|
25
|
+
registerVfxProjectTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
26
|
+
registerSceneTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
27
|
+
registerServerTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
28
|
+
registerGameKitTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
29
|
+
registerGameKitLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY, userAgent: `gripforge-mcp/${MCP_SELF}` });
|
|
19
30
|
const err = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
20
31
|
server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe…) onto a rigged character. ' +
|
|
21
32
|
'Finds the hand bone across naming schemes, scales to body height, seats the grip, ' +
|
|
22
33
|
'and returns a bind + ready-to-paste Three.js / Unity / Godot snippets. ' +
|
|
34
|
+
'Pass local paths or Library ids (character_id / prop_id). After attach the bind ' +
|
|
35
|
+
'(+ armed GLB when export_glb) is saved in Library — not a second credit. ' +
|
|
23
36
|
'Exact placement: add an Empty/locator named "grip" on the handle inside the weapon file — ' +
|
|
24
37
|
'GripForge puts that point in the palm and skips all grip heuristics. ' +
|
|
25
38
|
'Hand closing: rigs WITH finger bones get bind.gripPose rotations (applied by the snippets); ' +
|
|
26
39
|
'rigs WITHOUT finger bones (mitten hands) need export_glb: true + out_dir to receive ' +
|
|
27
40
|
'attached.glb with the fist baked in — the JSON bind alone cannot close a mitten hand.', {
|
|
28
|
-
character_path: z.string().describe('Absolute path to the rigged character (.glb .gltf .fbx .obj)'),
|
|
29
|
-
prop_path: z.string().describe('Absolute path to the prop mesh'),
|
|
41
|
+
character_path: z.string().optional().describe('Absolute path to the rigged character (.glb .gltf .fbx .obj)'),
|
|
42
|
+
prop_path: z.string().optional().describe('Absolute path to the prop mesh'),
|
|
43
|
+
character_id: z.string().optional().describe('Library id of a character (lib_…)'),
|
|
44
|
+
attach_id: z.string().optional().describe('Library bind id — use the armed GLB as the character'),
|
|
45
|
+
prop_id: z.string().optional().describe('Library id of a prop (lib_…)'),
|
|
46
|
+
prop_id_2: z.string().optional().describe('Second held item (off-hand), attached after the first'),
|
|
47
|
+
prop_path_2: z.string().optional().describe('Local path of a second held item (off-hand)'),
|
|
30
48
|
style: z.enum(['melee', 'gun', 'shield', 'staff']).optional().describe('Grip style (default: guessed from the filename)'),
|
|
31
49
|
hand: z.enum(['right', 'left']).optional().describe('Hand side (default right)'),
|
|
32
50
|
height_ratio: z.number().min(0.05).max(1.5).optional().describe('Prop size as a fraction of body height'),
|
|
@@ -46,30 +64,48 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
46
64
|
.describe('Bake character + closed fist + prop into one attached.glb (requires out_dir; .glb/.gltf inputs only). ' +
|
|
47
65
|
'Use this for mitten-hand rigs: the closed fist cannot travel in the bind JSON.'),
|
|
48
66
|
out_dir: z.string().optional().describe('Write bind.json + engine snippets into this folder'),
|
|
49
|
-
}, async ({ character_path, prop_path, style, hand, height_ratio, fist, grip_offset, ai_refine, export_glb, out_dir }) => {
|
|
67
|
+
}, async ({ character_path, prop_path, character_id, attach_id, prop_id, prop_id_2, prop_path_2, style, hand, height_ratio, fist, grip_offset, ai_refine, export_glb, out_dir }) => {
|
|
50
68
|
if (!API_KEY) {
|
|
51
69
|
return err('GRIPFORGE_API_KEY missing. Create a free account at ' +
|
|
52
70
|
API_URL +
|
|
53
71
|
'/login then set the key in this MCP server env:\n' +
|
|
54
72
|
'"env": { "GRIPFORGE_API_KEY": "gf_..." }');
|
|
55
73
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
if (!character_id && !character_path && !attach_id)
|
|
75
|
+
return err('Provide character_path, character_id or attach_id.');
|
|
76
|
+
if (!prop_id && !prop_path)
|
|
77
|
+
return err('Provide prop_path or prop_id.');
|
|
78
|
+
if (export_glb && !out_dir) {
|
|
79
|
+
return err('export_glb needs out_dir — that is where attached.glb will be written.');
|
|
80
|
+
}
|
|
81
|
+
const form = new FormData();
|
|
82
|
+
if (attach_id) {
|
|
83
|
+
form.append('attach_id', attach_id);
|
|
84
|
+
}
|
|
85
|
+
else if (character_id) {
|
|
86
|
+
form.append('character_id', character_id);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const charPath = resolve(character_path);
|
|
90
|
+
if (!SUPPORTED.includes(extname(charPath).toLowerCase())) {
|
|
91
|
+
return err(`Unsupported format: ${charPath} — use ${SUPPORTED.join(' ')}`);
|
|
61
92
|
}
|
|
93
|
+
form.append('character', new Blob([await readFile(charPath)]), basename(charPath));
|
|
62
94
|
}
|
|
63
|
-
if (
|
|
95
|
+
if (prop_id) {
|
|
96
|
+
form.append('prop_id', prop_id);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const propPath = resolve(prop_path);
|
|
100
|
+
if (!SUPPORTED.includes(extname(propPath).toLowerCase())) {
|
|
101
|
+
return err(`Unsupported format: ${propPath} — use ${SUPPORTED.join(' ')}`);
|
|
102
|
+
}
|
|
103
|
+
form.append('prop', new Blob([await readFile(propPath)]), basename(propPath));
|
|
104
|
+
}
|
|
105
|
+
if (character_path && prop_path && resolve(character_path) === resolve(prop_path)) {
|
|
64
106
|
return err('character_path and prop_path point to the same file. Export the weapon as its own GLB ' +
|
|
65
107
|
'(Blender: select the weapon only, File > Export > glTF, check "Selected Objects") and pass that as prop_path.');
|
|
66
108
|
}
|
|
67
|
-
if (export_glb && !out_dir) {
|
|
68
|
-
return err('export_glb needs out_dir — that is where attached.glb will be written.');
|
|
69
|
-
}
|
|
70
|
-
const form = new FormData();
|
|
71
|
-
form.append('character', new Blob([await readFile(charPath)]), basename(charPath));
|
|
72
|
-
form.append('prop', new Blob([await readFile(propPath)]), basename(propPath));
|
|
73
109
|
if (style)
|
|
74
110
|
form.append('style', style);
|
|
75
111
|
if (hand)
|
|
@@ -82,36 +118,57 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
82
118
|
form.append('grip_offset', grip_offset.join(','));
|
|
83
119
|
if (ai_refine)
|
|
84
120
|
form.append('refine', 'ai');
|
|
85
|
-
if (export_glb)
|
|
121
|
+
if (export_glb || prop_id_2 || prop_path_2)
|
|
86
122
|
form.append('export', 'glb');
|
|
87
123
|
form.append('fingers', '1');
|
|
88
124
|
let res;
|
|
89
125
|
try {
|
|
90
126
|
res = await fetch(`${API_URL}/api/v1/attach`, {
|
|
91
127
|
method: 'POST',
|
|
92
|
-
headers:
|
|
93
|
-
'x-api-key': API_KEY,
|
|
94
|
-
'x-gripforge-client': 'mcp',
|
|
95
|
-
'user-agent': 'gripforge-mcp/0.1',
|
|
96
|
-
},
|
|
128
|
+
headers: apiHeaders(),
|
|
97
129
|
body: form,
|
|
98
130
|
});
|
|
99
131
|
}
|
|
100
132
|
catch {
|
|
101
133
|
return err(`GripForge API unreachable at ${API_URL} — check GRIPFORGE_API_URL.`);
|
|
102
134
|
}
|
|
103
|
-
|
|
135
|
+
let data = (await res.json().catch(() => ({})));
|
|
104
136
|
if (res.status === 401)
|
|
105
137
|
return err('Invalid GRIPFORGE_API_KEY — check it on ' + API_URL + '/account#api');
|
|
106
138
|
if (res.status === 403) {
|
|
107
|
-
return err(`MCP access
|
|
139
|
+
return err(`MCP access needs a credit pack after the 3 free API/MCP trial attaches. Buy at ${API_URL}/pricing`);
|
|
108
140
|
}
|
|
109
141
|
if (res.status === 402) {
|
|
110
142
|
const u = data;
|
|
111
|
-
return err(`Quota exceeded (${u.used}/${u.limit}, plan ${u.plan}) —
|
|
143
|
+
return err(`Quota exceeded (${u.used}/${u.limit}, plan ${u.plan}) — buy credits at ${API_URL}/pricing`);
|
|
112
144
|
}
|
|
113
145
|
if (!res.ok)
|
|
114
146
|
return err(`Attach failed (${res.status}): ${String(data.error ?? 'unknown error')}`);
|
|
147
|
+
if (prop_id_2 || prop_path_2) {
|
|
148
|
+
const first = data.library;
|
|
149
|
+
if (!first?.id)
|
|
150
|
+
return err('first attach did not save a Library bind — cannot attach second prop');
|
|
151
|
+
const form2 = new FormData();
|
|
152
|
+
form2.append('attach_id', first.id);
|
|
153
|
+
if (prop_id_2)
|
|
154
|
+
form2.append('prop_id', prop_id_2);
|
|
155
|
+
else {
|
|
156
|
+
const p2 = resolve(prop_path_2);
|
|
157
|
+
form2.append('prop', new Blob([await readFile(p2)]), basename(p2));
|
|
158
|
+
}
|
|
159
|
+
form2.append('export', 'glb');
|
|
160
|
+
form2.append('fingers', '1');
|
|
161
|
+
form2.append('hand', hand === 'left' ? 'right' : 'left');
|
|
162
|
+
const res2 = await fetch(`${API_URL}/api/v1/attach`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: apiHeaders(),
|
|
165
|
+
body: form2,
|
|
166
|
+
});
|
|
167
|
+
const data2 = (await res2.json().catch(() => ({})));
|
|
168
|
+
if (!res2.ok)
|
|
169
|
+
return err(`second attach failed (${res2.status}): ${String(data2.error ?? 'unknown error')}`);
|
|
170
|
+
data = { ...data2, first_attach: first.id };
|
|
171
|
+
}
|
|
115
172
|
const exportsObj = (data.exports ?? {});
|
|
116
173
|
let wrote = null;
|
|
117
174
|
let glbFile = null;
|
|
@@ -136,6 +193,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
136
193
|
wrote = dir;
|
|
137
194
|
}
|
|
138
195
|
const credits = data.credits;
|
|
196
|
+
const library = data.library;
|
|
139
197
|
// Mitten rigs (no real finger bones): the fist is a mesh swap that cannot
|
|
140
198
|
// travel in the bind JSON — steer the caller toward export_glb.
|
|
141
199
|
const bind = data.bind;
|
|
@@ -147,11 +205,17 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
147
205
|
'Re-run gripforge_attach with export_glb: true and an out_dir to get attached.glb ' +
|
|
148
206
|
'(character with the fist closed + prop attached, textures preserved).'
|
|
149
207
|
: '';
|
|
208
|
+
const upgrade = typeof data.mcp_upgrade === 'string'
|
|
209
|
+
? data.mcp_upgrade
|
|
210
|
+
: typeof exportsObj.mcp_upgrade === 'string'
|
|
211
|
+
? exportsObj.mcp_upgrade
|
|
212
|
+
: '';
|
|
150
213
|
return {
|
|
151
214
|
content: [
|
|
152
215
|
{
|
|
153
216
|
type: 'text',
|
|
154
|
-
text:
|
|
217
|
+
text: (upgrade ? upgrade + '\n\n' : '') +
|
|
218
|
+
JSON.stringify({ bind: data.bind, confidence: data.confidence, exports: exportsObj, wrote, glbFile, library }, null, 2) +
|
|
155
219
|
(credits ? `\ncredits: ${credits.remaining}/${credits.limit} remaining (${credits.plan})` : '') +
|
|
156
220
|
(glbNotes.length ? `\nglb notes:\n- ${glbNotes.join('\n- ')}` : '') +
|
|
157
221
|
mittenHint,
|
|
@@ -159,19 +223,2131 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
159
223
|
],
|
|
160
224
|
};
|
|
161
225
|
});
|
|
162
|
-
server.tool('
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
})
|
|
226
|
+
server.tool('getAttachmentFrame', 'Skeleton attachment frame. Outward = R_Thigh−L_Thigh (right) or opposite (left). Up = Knee→Hip / Ankle→Knee. No Euler.', {
|
|
227
|
+
character_path: z.string().describe('Absolute path to the rigged character'),
|
|
228
|
+
bodyPart: z.enum(['right_thigh', 'left_thigh', 'right_shin', 'left_shin']),
|
|
229
|
+
}, async ({ character_path, bodyPart }) => {
|
|
230
|
+
if (!API_KEY)
|
|
231
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
232
|
+
const form = new FormData();
|
|
233
|
+
form.append('character', new Blob([await readFile(resolve(character_path))]), basename(character_path));
|
|
234
|
+
form.append('bodyPart', bodyPart);
|
|
235
|
+
const res = await fetch(`${API_URL}/api/v1/attachment-frame`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp' },
|
|
238
|
+
body: form,
|
|
239
|
+
});
|
|
240
|
+
const data = await res.json().catch(() => ({}));
|
|
241
|
+
if (!res.ok)
|
|
242
|
+
return err(String(data.error ?? res.status));
|
|
243
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
244
|
+
});
|
|
245
|
+
server.tool('getWearableFrame', 'Analyze a wearable once: up, bodyFacing, outward, anchor. Never rotate X = 180°.', {
|
|
246
|
+
asset_path: z.string().describe('Absolute path to the armor piece'),
|
|
247
|
+
type: z.enum(['thigh', 'greave']).optional(),
|
|
248
|
+
side: z.enum(['left', 'right']).optional(),
|
|
249
|
+
}, async ({ asset_path, type, side }) => {
|
|
250
|
+
if (!API_KEY)
|
|
251
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
252
|
+
const form = new FormData();
|
|
253
|
+
form.append('asset', new Blob([await readFile(resolve(asset_path))]), basename(asset_path));
|
|
254
|
+
if (type)
|
|
255
|
+
form.append('type', type);
|
|
256
|
+
if (side)
|
|
257
|
+
form.append('side', side);
|
|
258
|
+
const res = await fetch(`${API_URL}/api/v1/wearable-frame`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp' },
|
|
261
|
+
body: form,
|
|
262
|
+
});
|
|
263
|
+
const data = await res.json().catch(() => ({}));
|
|
264
|
+
if (!res.ok)
|
|
265
|
+
return err(String(data.error ?? res.status));
|
|
266
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
267
|
+
});
|
|
268
|
+
server.tool('gripforge_formats', 'List supported mesh formats, grip styles and hands. Also reports this MCP client version vs latest.', {}, async () => {
|
|
269
|
+
let latest = null;
|
|
270
|
+
let hosted = null;
|
|
271
|
+
let outdated = false;
|
|
272
|
+
let mcp_upgrade = null;
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(`${API_URL}/api/v1/mcp`, { headers: apiHeaders() });
|
|
275
|
+
const cat = (await res.json().catch(() => ({})));
|
|
276
|
+
if (res.ok && typeof cat.latest === 'string') {
|
|
277
|
+
latest = cat.latest;
|
|
278
|
+
hosted = typeof cat.hosted === 'string' ? cat.hosted : null;
|
|
279
|
+
const [a, b, c] = MCP_SELF.split('.').map((n) => parseInt(n, 10) || 0);
|
|
280
|
+
const [x, y, z] = latest.split('.').map((n) => parseInt(n, 10) || 0);
|
|
281
|
+
outdated = a < x || (a === x && (b < y || (b === y && c < z)));
|
|
282
|
+
if (outdated) {
|
|
283
|
+
mcp_upgrade =
|
|
284
|
+
`GripForge MCP npm ${MCP_SELF} is outdated (latest ${latest}). ` +
|
|
285
|
+
`Upgrade: npx -y @gripforgeai/mcp@${latest} then restart your MCP client. ` +
|
|
286
|
+
`Or hosted: ${hosted ?? 'https://gripforge.ai/mcp'}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
/* offline — still return formats */
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
content: [
|
|
295
|
+
{
|
|
296
|
+
type: 'text',
|
|
297
|
+
text: JSON.stringify({
|
|
298
|
+
formats: SUPPORTED,
|
|
299
|
+
styles: ['melee', 'gun', 'shield', 'staff'],
|
|
300
|
+
hands: ['right', 'left'],
|
|
301
|
+
api: API_URL,
|
|
302
|
+
docs: API_URL + '/docs',
|
|
303
|
+
mcp: { client: MCP_SELF, latest: latest ?? MCP_SELF, outdated, npm: '@gripforgeai/mcp' },
|
|
304
|
+
...(mcp_upgrade ? { mcp_upgrade } : {}),
|
|
305
|
+
}, null, 2),
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
function apiHeaders() {
|
|
311
|
+
return {
|
|
312
|
+
'x-api-key': API_KEY ?? '',
|
|
313
|
+
'x-gripforge-client': 'mcp',
|
|
314
|
+
'user-agent': `gripforge-mcp/${MCP_SELF}`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function safeName(name) {
|
|
318
|
+
const base = basename(name.split('?')[0] ?? name);
|
|
319
|
+
return base.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'file';
|
|
320
|
+
}
|
|
321
|
+
function absUrl(url) {
|
|
322
|
+
if (/^https?:\/\//i.test(url))
|
|
323
|
+
return url;
|
|
324
|
+
return `${API_URL}${url.startsWith('/') ? '' : '/'}${url}`;
|
|
325
|
+
}
|
|
326
|
+
async function downloadTo(url, dest) {
|
|
327
|
+
const res = await fetch(absUrl(url), { headers: apiHeaders(), signal: AbortSignal.timeout(60_000) });
|
|
328
|
+
if (!res.ok)
|
|
329
|
+
return false;
|
|
330
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
331
|
+
await writeFile(dest, Buffer.from(await res.arrayBuffer()));
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
const KIND = z.enum(['character', 'fps-arms', 'enemy', 'weapon', 'prop', 'texture', 'skybox', 'loading', 'hud', 'button', 'bind', 'vfx', 'animation', 'audio']);
|
|
335
|
+
const TEX_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
|
|
336
|
+
server.tool('gripforge_texture_prep', 'Prep a game texture (terrain / props): optional 50% wrap + seam blend, then a faithful ' +
|
|
337
|
+
'lanczos upscale (1× / 2× / 4×, capped at 1024 or 2048). The pattern is PRESERVED — ' +
|
|
338
|
+
'nothing is generated. Pass a local path or a Library texture_id. Writes the PNG into ' +
|
|
339
|
+
'out_dir and returns the Library albedo URL. No credit consumed.', {
|
|
340
|
+
path: z.string().optional().describe('Absolute path to a PNG/JPG/WebP'),
|
|
341
|
+
texture_id: z.string().optional().describe('Library id of a texture (lib_…)'),
|
|
342
|
+
seamless: z.boolean().optional().describe('50% offset + seam blend (default true)'),
|
|
343
|
+
scale: z.union([z.literal(1), z.literal(2), z.literal(4)]).optional().describe('Upscale factor (default 1)'),
|
|
344
|
+
size: z.union([z.literal(1024), z.literal(2048)]).optional().describe('Max output side (default 1024)'),
|
|
345
|
+
name: z.string().optional().describe('Library / file name stem'),
|
|
346
|
+
out_dir: z.string().optional().describe('Write the prepped PNG here (default: ./gripforge-library)'),
|
|
347
|
+
save: z.boolean().optional().describe('Save to Library (default true)'),
|
|
348
|
+
}, async ({ path, texture_id, seamless, scale, size, name, out_dir, save }) => {
|
|
349
|
+
if (!API_KEY)
|
|
350
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
351
|
+
if (!path && !texture_id)
|
|
352
|
+
return err('Provide path or texture_id.');
|
|
353
|
+
const form = new FormData();
|
|
354
|
+
if (path) {
|
|
355
|
+
const abs = resolve(path);
|
|
356
|
+
const ext = extname(abs).toLowerCase();
|
|
357
|
+
if (!TEX_EXTS.includes(ext))
|
|
358
|
+
return err(`Unsupported image: ${abs} — use ${TEX_EXTS.join(' ')}`);
|
|
359
|
+
form.append('file', new Blob([await readFile(abs)]), basename(abs));
|
|
360
|
+
}
|
|
361
|
+
if (texture_id)
|
|
362
|
+
form.append('texture_id', texture_id);
|
|
363
|
+
form.append('seamless', seamless === false ? '0' : '1');
|
|
364
|
+
form.append('scale', String(scale ?? 1));
|
|
365
|
+
form.append('size', String(size ?? 1024));
|
|
366
|
+
form.append('save', save === false ? '0' : '1');
|
|
367
|
+
if (name)
|
|
368
|
+
form.append('name', name);
|
|
369
|
+
let res;
|
|
370
|
+
try {
|
|
371
|
+
res = await fetch(`${API_URL}/api/v1/textures/prep`, {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
headers: { ...apiHeaders() },
|
|
374
|
+
body: form,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return err(`GripForge API unreachable at ${API_URL}`);
|
|
379
|
+
}
|
|
380
|
+
const data = (await res.json().catch(() => ({})));
|
|
381
|
+
if (!res.ok)
|
|
382
|
+
return err(String(data.error ?? `texture_prep failed (${res.status})`));
|
|
383
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-library'));
|
|
384
|
+
await mkdir(dir, { recursive: true });
|
|
385
|
+
const wrote = [];
|
|
386
|
+
if (data.urls?.albedo) {
|
|
387
|
+
const fileRes = await fetch(data.urls.albedo);
|
|
388
|
+
if (fileRes.ok) {
|
|
389
|
+
const buf = Buffer.from(await fileRes.arrayBuffer());
|
|
390
|
+
const stem = (name || (path ? basename(path).replace(/\.[^.]+$/, '') : data.id) || 'texture').replace(/[/\\]/g, '');
|
|
391
|
+
const dest = join(dir, `${stem}_prep.png`);
|
|
392
|
+
await writeFile(dest, buf);
|
|
393
|
+
wrote.push(dest);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
content: [
|
|
398
|
+
{
|
|
399
|
+
type: 'text',
|
|
400
|
+
text: JSON.stringify({
|
|
401
|
+
id: data.id,
|
|
402
|
+
library_id: data.library_id ?? null,
|
|
403
|
+
file_url: data.urls?.albedo ?? null,
|
|
404
|
+
width: data.width,
|
|
405
|
+
height: data.height,
|
|
406
|
+
wrote,
|
|
407
|
+
}, null, 2),
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
server.tool('gripforge_style_kit', 'Resolve "Devil May Cry like" / "genshin" to the locker kit already tagged with that game look (characters, enemies, weapons, props). Call this BEFORE generating. Reuse the returned ids.', { prompt: z.string().min(2).max(240).describe('e.g. "devil may cry like", "un ennemi genshin"') }, async ({ prompt }) => {
|
|
413
|
+
if (!API_KEY)
|
|
414
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
415
|
+
const qs = new URLSearchParams({ limit: '200' }); // style= does the matching; q= would also require the prompt in the NAME and empty the kit;
|
|
416
|
+
// Hosted matcher lives on the API: pass style= the prompt; the server canonicalizes aliases.
|
|
417
|
+
qs.set('style', prompt);
|
|
418
|
+
const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
|
|
419
|
+
const data = (await res.json().catch(() => ({})));
|
|
420
|
+
if (!res.ok)
|
|
421
|
+
return err(String(data.error ?? res.status));
|
|
422
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
423
|
+
const of = (k) => items
|
|
424
|
+
.filter((it) => it.kind === k)
|
|
425
|
+
.map((it) => ({ id: it.id, kind: it.kind, name: it.name, file_url: it.file_url }));
|
|
426
|
+
const out = {
|
|
427
|
+
prompt,
|
|
428
|
+
characters: of('character'),
|
|
429
|
+
enemies: of('enemy'),
|
|
430
|
+
weapons: of('weapon'),
|
|
431
|
+
props: of('prop'),
|
|
432
|
+
textures: of('texture'),
|
|
433
|
+
skyboxes: of('skybox'),
|
|
434
|
+
loading: of('loading'),
|
|
435
|
+
huds: [...of('hud'), ...of('button')],
|
|
436
|
+
buttons: of('button'),
|
|
437
|
+
binds: of('bind'),
|
|
438
|
+
vfx: of('vfx'),
|
|
439
|
+
animations: of('animation'),
|
|
440
|
+
hint: items.length > 0
|
|
441
|
+
? of('weapon').length
|
|
442
|
+
? 'Reuse these Library ids. Do not regenerate unless the user wants a NEW mesh.'
|
|
443
|
+
: '0 weapons — grip style is melee/gun on meta.style. Tag swords with gripforge_library_tag(id, style) (writes the look into tags, does not overwrite the grip).'
|
|
444
|
+
: 'No locker assets for this look yet.',
|
|
445
|
+
};
|
|
446
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
447
|
+
});
|
|
448
|
+
server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged character or enemy and save it to Library. ' +
|
|
449
|
+
'To sculpt from a picture (image-to-3D, Meshy): pass concept_item (lib_… from gripforge_concept_correct / character_concepts), or path / file_url of a T-pose or concept PNG. ' +
|
|
450
|
+
'Without an image, this is text-to-3D only. ' +
|
|
451
|
+
'If the look may already be in the locker, call gripforge_style_kit FIRST. ' +
|
|
452
|
+
'For knives/guns use gripforge_generate_weapon. Costs 10 credits. Returns job_id immediately; poll gripforge_generation_read. Closing this call does not cancel it.', {
|
|
453
|
+
prompt: z.string().min(3).max(600).describe('Description. "Devil May Cry like enemy" → tagged devil-may-cry, kind=enemy.'),
|
|
454
|
+
provider: z.enum(['tripo', 'meshy']).optional().describe('Default tripo. Forced meshy when concept_item / path / file_url is set.'),
|
|
455
|
+
name: z.string().max(160).optional().describe('Library item name (default: the prompt)'),
|
|
456
|
+
kind: z.enum(['character', 'enemy', 'boss']).optional().describe('character | enemy | boss (boss → enemy + meta.boss)'),
|
|
457
|
+
polycount: z.number().optional().describe('Target triangles. Meshy characters default 8000.'),
|
|
458
|
+
underwear: z.boolean().optional().describe('false = keep the described outfit. Implied false when an image is passed.'),
|
|
459
|
+
concept_item: z.string().optional().describe('Library concept / T-pose sheet id (lib_…). Image-to-3D from that picture — not text-to-3D.'),
|
|
460
|
+
path: z.string().optional().describe('Absolute path to a local concept or T-pose image (PNG/JPG/WebP). Image-to-3D.'),
|
|
461
|
+
file_url: z.string().optional().describe('https URL of a concept or T-pose image. Image-to-3D.'),
|
|
462
|
+
out_dir: z.string().optional().describe('After the job completes, gripforge_library_pull into this folder.'),
|
|
463
|
+
}, async ({ prompt, provider, name, kind, polycount, underwear, concept_item, path, file_url, out_dir }) => {
|
|
464
|
+
if (!API_KEY)
|
|
465
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
466
|
+
let image_base64;
|
|
467
|
+
if (path) {
|
|
468
|
+
const abs = resolve(path);
|
|
469
|
+
const buf = await readFile(abs);
|
|
470
|
+
image_base64 = buf.toString('base64');
|
|
471
|
+
}
|
|
472
|
+
else if (file_url) {
|
|
473
|
+
let parsed;
|
|
474
|
+
try {
|
|
475
|
+
parsed = new URL(file_url);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return err('file_url: invalid URL');
|
|
479
|
+
}
|
|
480
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
|
|
481
|
+
return err('file_url: http(s) only');
|
|
482
|
+
const got = await fetch(parsed, { signal: AbortSignal.timeout(60_000) });
|
|
483
|
+
if (!got.ok)
|
|
484
|
+
return err(`file_url: download failed (${got.status})`);
|
|
485
|
+
image_base64 = Buffer.from(await got.arrayBuffer()).toString('base64');
|
|
486
|
+
}
|
|
487
|
+
const res = await fetch(`${API_URL}/api/v1/generate-character`, {
|
|
488
|
+
method: 'POST',
|
|
489
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
490
|
+
body: JSON.stringify({
|
|
491
|
+
prompt,
|
|
492
|
+
provider: image_base64 || concept_item ? 'meshy' : provider,
|
|
493
|
+
name,
|
|
494
|
+
kind,
|
|
495
|
+
polycount,
|
|
496
|
+
underwear: image_base64 || concept_item ? false : underwear,
|
|
497
|
+
concept_item,
|
|
498
|
+
image_base64,
|
|
499
|
+
}),
|
|
500
|
+
});
|
|
501
|
+
const data = (await res.json().catch(() => ({})));
|
|
502
|
+
if (!res.ok)
|
|
503
|
+
return err(String(data.error ?? `generate failed (${res.status})`));
|
|
504
|
+
const wrote = [];
|
|
505
|
+
if (out_dir && data.item?.file_url) {
|
|
506
|
+
const dir = resolve(out_dir);
|
|
507
|
+
await mkdir(dir, { recursive: true });
|
|
508
|
+
const fileRes = await fetch(data.item.file_url);
|
|
509
|
+
if (fileRes.ok) {
|
|
510
|
+
const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
|
|
511
|
+
await writeFile(dest, Buffer.from(await fileRes.arrayBuffer()));
|
|
512
|
+
wrote.push(dest);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote, ...(out_dir && !wrote.length ? { next: 'Poll gripforge_generation_read, then gripforge_library_pull with the completed asset id and this out_dir.', requested_out_dir: out_dir } : {}) }, null, 2) }] };
|
|
516
|
+
});
|
|
517
|
+
server.tool('gripforge_generate_weapon', 'CREATE a game-ready weapon GLB (knife, gun, sword) and save it to Library as kind=weapon. ' +
|
|
518
|
+
'Default Meshy, no T-pose, no rig. polycount default 4000. style=melee|gun for attach. 10 credits. ' +
|
|
519
|
+
'Do NOT write one-off Meshy scripts.', {
|
|
520
|
+
prompt: z.string().min(3).max(600).describe('e.g. CS2 default CT combat knife, silver blade, black grip'),
|
|
521
|
+
name: z.string().max(160).optional(),
|
|
522
|
+
polycount: z.number().optional().describe('Target triangles (default 4000)'),
|
|
523
|
+
style: z.enum(['melee', 'gun', 'shield', 'staff']).optional().describe('Grip style for attach (inferred if omitted)'),
|
|
524
|
+
provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
|
|
525
|
+
out_dir: z.string().optional().describe('After the durable job completes, use gripforge_library_pull with this out_dir.'),
|
|
526
|
+
}, async ({ prompt, name, polycount, style, provider, out_dir }) => {
|
|
527
|
+
if (!API_KEY)
|
|
528
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
529
|
+
const res = await fetch(`${API_URL}/api/v1/generate-character`, {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
532
|
+
body: JSON.stringify({
|
|
533
|
+
prompt,
|
|
534
|
+
name,
|
|
535
|
+
kind: 'weapon',
|
|
536
|
+
polycount: polycount ?? 4000,
|
|
537
|
+
style,
|
|
538
|
+
provider: provider ?? 'meshy',
|
|
539
|
+
}),
|
|
540
|
+
});
|
|
541
|
+
const data = (await res.json().catch(() => ({})));
|
|
542
|
+
if (!res.ok)
|
|
543
|
+
return err(String(data.error ?? `generate weapon failed (${res.status})`));
|
|
544
|
+
const wrote = [];
|
|
545
|
+
if (out_dir && data.item?.file_url) {
|
|
546
|
+
const dir = resolve(out_dir);
|
|
547
|
+
await mkdir(dir, { recursive: true });
|
|
548
|
+
const fileRes = await fetch(data.item.file_url);
|
|
549
|
+
if (fileRes.ok) {
|
|
550
|
+
const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
|
|
551
|
+
await writeFile(dest, Buffer.from(await fileRes.arrayBuffer()));
|
|
552
|
+
wrote.push(dest);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote, ...(out_dir && !wrote.length ? { next: 'Poll gripforge_generation_read, then gripforge_library_pull with the completed asset id and this out_dir.', requested_out_dir: out_dir } : {}) }, null, 2) }] };
|
|
556
|
+
});
|
|
557
|
+
server.tool('gripforge_generate_prop', 'CREATE a game-ready prop GLB (kart, crate, banana, barrier…) and save it to Library as kind=prop. Default Meshy, no T-pose, no rig. polycount default 4000. 10 credits. Returns a durable job_id; poll gripforge_generation_read.', {
|
|
558
|
+
prompt: z.string().min(3).max(600).describe('e.g. compact orange open-cockpit racing kart, isolated'),
|
|
559
|
+
name: z.string().max(160).optional(),
|
|
560
|
+
polycount: z.number().optional().describe('Target triangles (default 4000)'),
|
|
561
|
+
provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
|
|
562
|
+
out_dir: z.string().optional().describe('After the durable job completes, use gripforge_library_pull with this out_dir.'),
|
|
563
|
+
}, async ({ prompt, name, polycount, provider, out_dir }) => {
|
|
564
|
+
if (!API_KEY)
|
|
565
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
566
|
+
const res = await fetch(`${API_URL}/api/v1/generate-character`, {
|
|
567
|
+
method: 'POST',
|
|
568
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
569
|
+
body: JSON.stringify({
|
|
570
|
+
prompt,
|
|
571
|
+
name,
|
|
572
|
+
kind: 'prop',
|
|
573
|
+
polycount: polycount ?? 4000,
|
|
574
|
+
provider: provider ?? 'meshy',
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
const data = (await res.json().catch(() => ({})));
|
|
578
|
+
if (!res.ok)
|
|
579
|
+
return err(String(data.error ?? `generate prop failed (${res.status})`));
|
|
580
|
+
const wrote = [];
|
|
581
|
+
if (out_dir && data.item?.file_url) {
|
|
582
|
+
const dir = resolve(out_dir);
|
|
583
|
+
await mkdir(dir, { recursive: true });
|
|
584
|
+
const fileRes = await fetch(data.item.file_url);
|
|
585
|
+
if (fileRes.ok) {
|
|
586
|
+
const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
|
|
587
|
+
await writeFile(dest, Buffer.from(await fileRes.arrayBuffer()));
|
|
588
|
+
wrote.push(dest);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote, ...(out_dir && !wrote.length ? { next: 'Poll gripforge_generation_read, then gripforge_library_pull with the completed asset id and this out_dir.', requested_out_dir: out_dir } : {}) }, null, 2) }] };
|
|
592
|
+
});
|
|
593
|
+
server.tool('gripforge_boss', 'Create a playable boss: stats, phases, attacks bound to anim-pack slots, arena, engine snippets. ' +
|
|
594
|
+
'Kit is 0 credits. Reuses a locker enemy when one exists. Pass character_id to bind a Library mesh. ' +
|
|
595
|
+
'Pass generate=true to forge a NEW kind=enemy mesh (10 credits, 2–8 min) — only if the locker is empty. ' +
|
|
596
|
+
'Then gripforge_animate(archetype) + gripforge_hitbox. Name stays text.', {
|
|
597
|
+
prompt: z.string().min(2).max(240).describe('e.g. "devil may cry like phantom boss"'),
|
|
598
|
+
role: z.enum(['mini', 'mid', 'final']).optional().describe('mini | mid | final (inferred from the prompt)'),
|
|
599
|
+
character_id: z.string().optional().describe('lib_… character/enemy/bind to bind — 0 credits'),
|
|
600
|
+
generate: z.boolean().optional().describe('Forge a new enemy mesh (10 credits)'),
|
|
601
|
+
name: z.string().max(80).optional().describe('Boss display name'),
|
|
602
|
+
provider: z.enum(['tripo', 'meshy']).optional().describe('Only used when generate=true (default tripo)'),
|
|
603
|
+
out_dir: z.string().optional().describe('Write boss.json + engine snippets (+ GLB if generated) here'),
|
|
604
|
+
}, async ({ prompt, role, character_id, generate, name, provider, out_dir }) => {
|
|
605
|
+
if (!API_KEY)
|
|
606
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
607
|
+
let data;
|
|
608
|
+
if (generate || character_id) {
|
|
609
|
+
const res = await fetch(`${API_URL}/api/v1/boss`, {
|
|
610
|
+
method: 'POST',
|
|
611
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
612
|
+
body: JSON.stringify({ prompt, role, character_id, generate, name, provider }),
|
|
613
|
+
});
|
|
614
|
+
data = (await res.json().catch(() => ({})));
|
|
615
|
+
if (!res.ok)
|
|
616
|
+
return err(String(data.error ?? res.status));
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
const qs = new URLSearchParams({ prompt });
|
|
620
|
+
if (role)
|
|
621
|
+
qs.set('role', role);
|
|
622
|
+
if (name)
|
|
623
|
+
qs.set('name', name);
|
|
624
|
+
const res = await fetch(`${API_URL}/api/v1/boss?${qs}`, { headers: apiHeaders() });
|
|
625
|
+
data = (await res.json().catch(() => ({})));
|
|
626
|
+
if (!res.ok)
|
|
627
|
+
return err(String(data.error ?? res.status));
|
|
628
|
+
}
|
|
629
|
+
const wrote = [];
|
|
630
|
+
if (out_dir) {
|
|
631
|
+
const dir = resolve(out_dir);
|
|
632
|
+
await mkdir(dir, { recursive: true });
|
|
633
|
+
const json = typeof data.json === 'string' ? data.json : JSON.stringify(data, null, 2);
|
|
634
|
+
await writeFile(join(dir, 'boss.json'), json);
|
|
635
|
+
wrote.push(join(dir, 'boss.json'));
|
|
636
|
+
const engines = data.engines;
|
|
637
|
+
if (engines?.three) {
|
|
638
|
+
await writeFile(join(dir, 'three.js.txt'), engines.three);
|
|
639
|
+
wrote.push(join(dir, 'three.js.txt'));
|
|
640
|
+
}
|
|
641
|
+
if (engines?.godot) {
|
|
642
|
+
await writeFile(join(dir, 'godot.gd.txt'), engines.godot);
|
|
643
|
+
wrote.push(join(dir, 'godot.gd.txt'));
|
|
644
|
+
}
|
|
645
|
+
if (engines?.unity) {
|
|
646
|
+
await writeFile(join(dir, 'unity.cs.txt'), engines.unity);
|
|
647
|
+
wrote.push(join(dir, 'unity.cs.txt'));
|
|
648
|
+
}
|
|
649
|
+
if (engines?.unreal) {
|
|
650
|
+
await writeFile(join(dir, 'unreal.cpp.txt'), engines.unreal);
|
|
651
|
+
wrote.push(join(dir, 'unreal.cpp.txt'));
|
|
652
|
+
}
|
|
653
|
+
const fileUrl = typeof data.file_url === 'string' ? data.file_url : null;
|
|
654
|
+
if (fileUrl) {
|
|
655
|
+
const bin = Buffer.from(await (await fetch(fileUrl)).arrayBuffer());
|
|
656
|
+
await writeFile(join(dir, 'boss.glb'), bin);
|
|
657
|
+
wrote.push(join(dir, 'boss.glb'));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
661
|
+
});
|
|
662
|
+
server.tool('gripforge_hud', 'Survivor HUD kit: hud.json + cartoon PNGs + Godot .tscn + Three / Unity / Unreal snippets. ' +
|
|
663
|
+
'Writes the folder (npm). 1 credit. Do not redraw the hearts.', {
|
|
664
|
+
preset: z.literal('survivor').optional().describe('v1: survivor only (default)'),
|
|
665
|
+
health_type: z.enum(['hearts', 'bar']).optional().describe('default hearts'),
|
|
666
|
+
health_max: z.number().int().min(1).max(20).optional().describe('default 5'),
|
|
667
|
+
health_style: z.string().optional().describe('hearts skin or bar skin, or custom:<slug> from gripforge_hud_bar'),
|
|
668
|
+
currencies: z.array(z.string()).max(6).optional().describe('default ["gem","mushroom","drop"]'),
|
|
669
|
+
minimap: z.boolean().optional().describe('default false'),
|
|
670
|
+
portrait: z.boolean().optional().describe('default true'),
|
|
671
|
+
out_dir: z.string().optional().describe('Write hud.json, PNGs, tscn, snippets (default ./gripforge-hud)'),
|
|
672
|
+
}, async ({ preset, health_type, health_max, health_style, currencies, minimap, portrait, out_dir }) => {
|
|
673
|
+
if (!API_KEY)
|
|
674
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
675
|
+
const res = await fetch(`${API_URL}/api/v1/hud`, {
|
|
676
|
+
method: 'POST',
|
|
677
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
678
|
+
body: JSON.stringify({
|
|
679
|
+
preset,
|
|
680
|
+
health: { type: health_type, max: health_max, style: health_style },
|
|
681
|
+
currencies,
|
|
682
|
+
minimap,
|
|
683
|
+
portrait,
|
|
684
|
+
}),
|
|
685
|
+
signal: AbortSignal.timeout(30_000),
|
|
686
|
+
});
|
|
687
|
+
const data = (await res.json().catch(() => ({})));
|
|
688
|
+
if (!res.ok)
|
|
689
|
+
return err(String(data.error ?? `hud failed (${res.status})`));
|
|
690
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-hud'));
|
|
691
|
+
await mkdir(dir, { recursive: true });
|
|
692
|
+
const wrote = [];
|
|
693
|
+
for (const [name, body] of Object.entries(data.files ?? {})) {
|
|
694
|
+
const dest = join(dir, safeName(name));
|
|
695
|
+
await writeFile(dest, body);
|
|
696
|
+
wrote.push(dest);
|
|
697
|
+
}
|
|
698
|
+
for (const url of Object.values(data.assets ?? {})) {
|
|
699
|
+
const dest = join(dir, safeName(url));
|
|
700
|
+
if (await downloadTo(url, dest))
|
|
701
|
+
wrote.push(dest);
|
|
702
|
+
}
|
|
703
|
+
if (data.unreal_plugin_url) {
|
|
704
|
+
const dest = join(dir, 'GripForgeHUD-plugin.zip');
|
|
705
|
+
if (await downloadTo(data.unreal_plugin_url, dest))
|
|
706
|
+
wrote.push(dest);
|
|
707
|
+
}
|
|
708
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
709
|
+
});
|
|
710
|
+
server.tool('gripforge_hud_bar', 'Forge an original themed health-bar frame+fill PNG pair (genre/materials, not a specific game). ' +
|
|
711
|
+
'Writes the PNGs (npm). 2 credits.', {
|
|
712
|
+
theme: z.string().min(3).max(300).describe('e.g. gothic demon-hunter, baroque metal'),
|
|
713
|
+
colors: z
|
|
714
|
+
.array(z.string().regex(/^#[0-9a-fA-F]{6}$/))
|
|
715
|
+
.length(3)
|
|
716
|
+
.optional()
|
|
717
|
+
.describe('Fill gradient [light, mid, dark] hex'),
|
|
718
|
+
name: z.string().max(60).optional().describe('Slug for Library items'),
|
|
719
|
+
out_dir: z.string().optional().describe('Write frame + fill PNGs (default ./gripforge-hud)'),
|
|
720
|
+
}, async ({ theme, colors, name, out_dir }) => {
|
|
721
|
+
if (!API_KEY)
|
|
722
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
723
|
+
const res = await fetch(`${API_URL}/api/v1/hud/forge-bar`, {
|
|
724
|
+
method: 'POST',
|
|
725
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
726
|
+
body: JSON.stringify({ theme, colors, name }),
|
|
727
|
+
signal: AbortSignal.timeout(280_000),
|
|
728
|
+
});
|
|
729
|
+
const data = (await res.json().catch(() => ({})));
|
|
730
|
+
if (!res.ok)
|
|
731
|
+
return err(String(data.error ?? `hud_bar failed (${res.status})`));
|
|
732
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-hud'));
|
|
733
|
+
await mkdir(dir, { recursive: true });
|
|
734
|
+
const wrote = [];
|
|
735
|
+
const snippet = data.hud_snippet;
|
|
736
|
+
const frameName = safeName(snippet?.health?.textures?.frame || 'bar_frame.png');
|
|
737
|
+
const fillName = safeName(snippet?.health?.textures?.fill || 'bar_fill.png');
|
|
738
|
+
if (data.frame_url && (await downloadTo(data.frame_url, join(dir, frameName))))
|
|
739
|
+
wrote.push(join(dir, frameName));
|
|
740
|
+
if (data.fill_url && (await downloadTo(data.fill_url, join(dir, fillName))))
|
|
741
|
+
wrote.push(join(dir, fillName));
|
|
742
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
743
|
+
});
|
|
744
|
+
server.tool('gripforge_cape', 'Cape rig: 3-column bone grid on neck/spine, skins the cape, bakes attached_cape.glb + Godot spring / Three verlet snippets. ' +
|
|
745
|
+
'Writes the folder (npm). 1 credit. Inputs: Library ids or https URLs.', {
|
|
746
|
+
character_id: z.string().optional().describe('Library id of a rigged character (lib_…)'),
|
|
747
|
+
character_url: z.string().optional().describe('https URL of a rigged character GLB'),
|
|
748
|
+
cape_id: z.string().optional().describe('Library id of the cape mesh (lib_…)'),
|
|
749
|
+
cape_url: z.string().optional().describe('https URL of the cape GLB'),
|
|
750
|
+
columns: z.number().int().min(2).max(5).optional().describe('Bone columns (default 3)'),
|
|
751
|
+
rows: z.number().int().min(2).max(8).optional().describe('Rows below anchor (default 5)'),
|
|
752
|
+
save: z.boolean().optional().describe('Save attached_cape.glb to Library (default true)'),
|
|
753
|
+
out_dir: z.string().optional().describe('Write bind + scripts + GLB (default ./gripforge-cape)'),
|
|
754
|
+
}, async ({ character_id, character_url, cape_id, cape_url, columns, rows, save, out_dir }) => {
|
|
755
|
+
if (!API_KEY)
|
|
756
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
757
|
+
if (!character_id && !character_url)
|
|
758
|
+
return err('Provide character_id or character_url.');
|
|
759
|
+
if (!cape_id && !cape_url)
|
|
760
|
+
return err('Provide cape_id or cape_url.');
|
|
761
|
+
const res = await fetch(`${API_URL}/api/v1/cape`, {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
764
|
+
body: JSON.stringify({ character_id, character_url, cape_id, cape_url, columns, rows, save }),
|
|
765
|
+
signal: AbortSignal.timeout(110_000),
|
|
766
|
+
});
|
|
767
|
+
const data = (await res.json().catch(() => ({})));
|
|
768
|
+
if (!res.ok)
|
|
769
|
+
return err(String(data.error ?? `cape failed (${res.status})`));
|
|
770
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-cape'));
|
|
771
|
+
await mkdir(dir, { recursive: true });
|
|
772
|
+
const wrote = [];
|
|
773
|
+
for (const [name, body] of Object.entries(data.files ?? {})) {
|
|
774
|
+
const dest = join(dir, safeName(name));
|
|
775
|
+
await writeFile(dest, body);
|
|
776
|
+
wrote.push(dest);
|
|
777
|
+
}
|
|
778
|
+
const glbUrl = data.library?.file_url;
|
|
779
|
+
if (glbUrl) {
|
|
780
|
+
const dest = join(dir, 'attached_cape.glb');
|
|
781
|
+
if (await downloadTo(glbUrl, dest))
|
|
782
|
+
wrote.push(dest);
|
|
783
|
+
}
|
|
784
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
785
|
+
});
|
|
786
|
+
server.tool('gripforge_concept_correct', 'Turn a character concept image into a strict T-pose sheet (optional: remove flowing cape/train only — sleeved jacket stays; outfit or sport base). Saves PNG to Library. 1 credit.', {
|
|
787
|
+
path: z.string().optional().describe('Absolute path to a local concept image'),
|
|
788
|
+
file_url: z.string().optional().describe('https URL of the concept image'),
|
|
789
|
+
clothes: z.enum(['outfit', 'base']).optional().describe('outfit = keep clothes; base = grey athletic sportswear'),
|
|
790
|
+
cape: z.boolean().optional().describe('Remove flowing cape/train only; keep sleeved jacket (outfit only, default true)'),
|
|
791
|
+
notes: z.string().optional().describe('Extra direction'),
|
|
792
|
+
name: z.string().optional().describe('Library name'),
|
|
793
|
+
}, async ({ path, file_url, clothes, cape, notes, name }) => {
|
|
794
|
+
if (!API_KEY)
|
|
795
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
796
|
+
if (!path && !file_url)
|
|
797
|
+
return err('Provide path or file_url.');
|
|
798
|
+
const fd = new FormData();
|
|
799
|
+
if (path) {
|
|
800
|
+
const abs = resolve(path);
|
|
801
|
+
fd.append('file', new Blob([await readFile(abs)]), basename(abs));
|
|
802
|
+
}
|
|
803
|
+
else if (file_url) {
|
|
804
|
+
let parsed;
|
|
805
|
+
try {
|
|
806
|
+
parsed = new URL(file_url);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return err('file_url: invalid URL');
|
|
810
|
+
}
|
|
811
|
+
const got = await fetch(parsed, { signal: AbortSignal.timeout(60_000) });
|
|
812
|
+
if (!got.ok)
|
|
813
|
+
return err(`file_url: download failed (${got.status})`);
|
|
814
|
+
fd.append('file', new Blob([await got.arrayBuffer()]), parsed.pathname.split('/').pop() || 'concept.jpg');
|
|
815
|
+
}
|
|
816
|
+
fd.append('clothes', clothes === 'base' ? 'base' : 'outfit');
|
|
817
|
+
fd.append('cape', cape === false ? '0' : '1');
|
|
818
|
+
if (notes)
|
|
819
|
+
fd.append('notes', notes);
|
|
820
|
+
if (name)
|
|
821
|
+
fd.append('name', name);
|
|
822
|
+
const res = await fetch(`${API_URL}/api/v1/concept-correct`, {
|
|
823
|
+
method: 'POST',
|
|
824
|
+
headers: apiHeaders(),
|
|
825
|
+
body: fd,
|
|
826
|
+
});
|
|
827
|
+
const data = await res.json().catch(() => ({}));
|
|
828
|
+
if (!res.ok)
|
|
829
|
+
return err(String(data.error ?? res.status));
|
|
830
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
831
|
+
});
|
|
832
|
+
server.tool('gripforge_library_list', 'List the GripForge Library locker (characters, enemies, weapons, props, textures, HUD, buttons, binds). Filter by kind and/or game style.', {
|
|
833
|
+
kind: KIND.optional().describe('Filter by kind'),
|
|
834
|
+
q: z.string().optional().describe('Search name/filename'),
|
|
835
|
+
style: z.string().optional().describe('Game look (devil-may-cry, dmc, genshin)'),
|
|
836
|
+
}, async ({ kind, q, style }) => {
|
|
837
|
+
if (!API_KEY)
|
|
838
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
839
|
+
const qs = new URLSearchParams();
|
|
840
|
+
if (kind)
|
|
841
|
+
qs.set('kind', kind);
|
|
842
|
+
if (q)
|
|
843
|
+
qs.set('q', q);
|
|
844
|
+
if (style)
|
|
845
|
+
qs.set('style', style);
|
|
846
|
+
const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
|
|
847
|
+
const data = await res.json().catch(() => ({}));
|
|
848
|
+
if (!res.ok)
|
|
849
|
+
return err(String(data.error ?? res.status));
|
|
850
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
851
|
+
});
|
|
852
|
+
server.tool('gripforge_library_get', 'Get one Library item (metadata + signed file URL).', { id: z.string().describe('Library id (lib_…)') }, async ({ id }) => {
|
|
853
|
+
if (!API_KEY)
|
|
854
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
855
|
+
const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, { headers: apiHeaders() });
|
|
856
|
+
const data = await res.json().catch(() => ({}));
|
|
857
|
+
if (!res.ok)
|
|
858
|
+
return err(String(data.error ?? res.status));
|
|
859
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
860
|
+
});
|
|
861
|
+
server.tool('gripforge_library_push', 'Upload a local file into the GripForge Library locker (character, weapon, prop, texture, HUD, or bind).', {
|
|
862
|
+
path: z.string().describe('Absolute path of the file to upload'),
|
|
863
|
+
kind: KIND.describe('Locker kind'),
|
|
864
|
+
name: z.string().optional().describe('Display name (defaults to the filename stem)'),
|
|
865
|
+
}, async ({ path, kind, name }) => {
|
|
866
|
+
if (!API_KEY)
|
|
867
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
868
|
+
const abs = resolve(path);
|
|
869
|
+
const form = new FormData();
|
|
870
|
+
form.append('file', new Blob([await readFile(abs)]), basename(abs));
|
|
871
|
+
form.append('kind', kind);
|
|
872
|
+
form.append('name', name || basename(abs).replace(/\.[^.]+$/, ''));
|
|
873
|
+
const res = await fetch(`${API_URL}/api/v1/library`, {
|
|
874
|
+
method: 'POST',
|
|
875
|
+
headers: apiHeaders(),
|
|
876
|
+
body: form,
|
|
877
|
+
});
|
|
878
|
+
const data = await res.json().catch(() => ({}));
|
|
879
|
+
if (!res.ok)
|
|
880
|
+
return err(String(data.error ?? res.status));
|
|
881
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
882
|
+
});
|
|
883
|
+
server.tool('gripforge_library_pull', 'Download a Library item into the open repo. This is the sync: writes bind.json and/or the mesh next to the working tree.', {
|
|
884
|
+
id: z.string().describe('Library id (lib_…)'),
|
|
885
|
+
out_dir: z
|
|
886
|
+
.string()
|
|
887
|
+
.optional()
|
|
888
|
+
.describe('Folder to write into (default: ./gripforge-library in the current working directory)'),
|
|
889
|
+
}, async ({ id, out_dir }) => {
|
|
890
|
+
if (!API_KEY)
|
|
891
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
892
|
+
const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, { headers: apiHeaders() });
|
|
893
|
+
const data = (await res.json().catch(() => ({})));
|
|
894
|
+
if (!res.ok || !data.item)
|
|
895
|
+
return err(String(data.error ?? res.status));
|
|
896
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-library'));
|
|
897
|
+
await mkdir(dir, { recursive: true });
|
|
898
|
+
const wrote = [];
|
|
899
|
+
const item = data.item;
|
|
900
|
+
if (item.kind === 'bind' && item.meta?.bind) {
|
|
901
|
+
const bindPath = join(dir, `${item.id}-bind.json`);
|
|
902
|
+
await writeFile(bindPath, JSON.stringify(item.meta.bind, null, 2));
|
|
903
|
+
wrote.push(bindPath);
|
|
904
|
+
}
|
|
905
|
+
if (item.file_url) {
|
|
906
|
+
const fileRes = await fetch(item.file_url);
|
|
907
|
+
if (!fileRes.ok)
|
|
908
|
+
return err(`file download failed (${fileRes.status})`);
|
|
909
|
+
const buf = Buffer.from(await fileRes.arrayBuffer());
|
|
910
|
+
const isJsonBind = item.kind === 'bind' && (item.filename ?? '').endsWith('.json');
|
|
911
|
+
if (!isJsonBind || !item.meta?.bind) {
|
|
912
|
+
const fname = item.filename || `${item.id}.bin`;
|
|
913
|
+
const dest = join(dir, `${item.id}-${fname}`);
|
|
914
|
+
await writeFile(dest, buf);
|
|
915
|
+
wrote.push(dest);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const sidecars = item.sidecars;
|
|
919
|
+
if (Array.isArray(sidecars) && sidecars.length) {
|
|
920
|
+
for (const sc of sidecars) {
|
|
921
|
+
if (!sc?.url || !sc.file)
|
|
922
|
+
continue;
|
|
923
|
+
const extra = await fetch(sc.url);
|
|
924
|
+
if (!extra.ok)
|
|
925
|
+
continue;
|
|
926
|
+
const dest = join(dir, sc.file === 'json' ? `${item.id}.json` : sc.file === 'png' ? `${item.id}.png` : `${item.id}.${sc.file}`);
|
|
927
|
+
let bytes = Buffer.from(await extra.arrayBuffer());
|
|
928
|
+
if (sc.file === 'json') {
|
|
929
|
+
try {
|
|
930
|
+
const text = bytes.toString('utf8').replace(/sidecar:(tex\d+|png|json)/g, `${item.id}.$1`);
|
|
931
|
+
bytes = Buffer.from(text, 'utf8');
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
/* keep original */
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
await writeFile(dest, bytes);
|
|
938
|
+
wrote.push(dest);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
else if (item.kind === 'vfx') {
|
|
942
|
+
for (const [url, fname] of [
|
|
943
|
+
[item.json_url, `${item.id}.json`],
|
|
944
|
+
[item.thumb_url, `${item.id}.png`],
|
|
945
|
+
]) {
|
|
946
|
+
if (!url)
|
|
947
|
+
continue;
|
|
948
|
+
const extra = await fetch(url);
|
|
949
|
+
if (!extra.ok)
|
|
950
|
+
continue;
|
|
951
|
+
const dest = join(dir, fname);
|
|
952
|
+
await writeFile(dest, Buffer.from(await extra.arrayBuffer()));
|
|
953
|
+
wrote.push(dest);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
content: [
|
|
958
|
+
{
|
|
959
|
+
type: 'text',
|
|
960
|
+
text: JSON.stringify({ id: item.id, kind: item.kind, name: item.name, wrote }, null, 2),
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
};
|
|
964
|
+
});
|
|
965
|
+
server.tool('gripforge_library_tag', 'Add a game look to tags so style_kit can find the item. Does not overwrite grip style melee/gun.', {
|
|
966
|
+
id: z.string().describe('Library id (lib_…)'),
|
|
967
|
+
style: z.string().optional().describe('Game look id or alias (devil-may-cry, dmc)'),
|
|
968
|
+
tags: z.array(z.string()).optional().describe('Replace tags. Omit to keep existing and just add style.'),
|
|
969
|
+
}, async ({ id, style, tags }) => {
|
|
970
|
+
if (!API_KEY)
|
|
971
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
972
|
+
const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, {
|
|
973
|
+
method: 'PATCH',
|
|
974
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
975
|
+
body: JSON.stringify({ style, tags }),
|
|
976
|
+
});
|
|
977
|
+
const data = await res.json().catch(() => ({}));
|
|
978
|
+
if (!res.ok)
|
|
979
|
+
return err(String(data.error ?? res.status));
|
|
980
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
981
|
+
});
|
|
982
|
+
server.tool('gripforge_hand_rig', 'Complete a rigged Library character with finger bones (3 phalanges × 5 fingers per hand, Mixamo names) built from its hand mesh — no provider rigs fingers, so without this a hand cannot close on a weapon and pistol/knife clips have nothing to drive. New characters get it automatically in gripforge_generate_character; use this on characters generated before. Updates the item in place. 0 credits. Then re-run gripforge_animate.', { character_id: z.string().describe('Library character / enemy / bind (lib_…)') }, async ({ character_id }) => {
|
|
983
|
+
if (!API_KEY)
|
|
984
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
985
|
+
const res = await fetch(`${API_URL}/api/v1/hand-rig`, {
|
|
986
|
+
method: 'POST',
|
|
987
|
+
headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
|
|
988
|
+
body: JSON.stringify({ item: character_id }),
|
|
989
|
+
});
|
|
990
|
+
const data = (await res.json().catch(() => ({})));
|
|
991
|
+
if (!res.ok)
|
|
992
|
+
return err(String(data.error ?? res.statusText));
|
|
993
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
994
|
+
});
|
|
995
|
+
server.tool('gripforge_viewmodel_arms', 'First-person arms cut out of a rigged + animated Library character (animation kit preferred): keeps the skeleton, the 23 clips and the materials, drops every triangle that is not skinned to the arm chains. The player then sees the same gloves/sleeves the others see on the body, and weapons attach on RightHand with the gripforge_attach binds. New Library item. 0 credits.', { item: z.string().describe('Library character or animation kit (lib_…)'), out_dir: z.string().optional().describe('write the GLB there (e.g. <godot>/assets/characters), named <team>_arms.glb when team is given'), team: z.string().optional().describe('file prefix, e.g. T or CT') }, async ({ item, out_dir, team }) => {
|
|
996
|
+
if (!API_KEY)
|
|
997
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
998
|
+
const res = await fetch(`${API_URL}/api/v1/viewmodel-arms`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': API_KEY }, body: JSON.stringify({ item }) });
|
|
999
|
+
const data = (await res.json().catch(() => ({})));
|
|
1000
|
+
if (!res.ok)
|
|
1001
|
+
return err(String(data.error ?? res.statusText));
|
|
1002
|
+
const wrote = [];
|
|
1003
|
+
if (out_dir && data.item?.file_url) {
|
|
1004
|
+
const fs = await import('node:fs/promises');
|
|
1005
|
+
const path = await import('node:path');
|
|
1006
|
+
await fs.mkdir(out_dir, { recursive: true });
|
|
1007
|
+
const r = await fetch(data.item.file_url);
|
|
1008
|
+
if (r.ok) {
|
|
1009
|
+
const f = path.join(out_dir, team ? `${team}_arms.glb` : (data.item.filename ?? 'arms.glb'));
|
|
1010
|
+
await fs.writeFile(f, Buffer.from(await r.arrayBuffer()));
|
|
1011
|
+
wrote.push(f);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
1015
|
+
});
|
|
1016
|
+
server.tool('gripforge_hand_check', 'Verify that a rigged character\'s fingers fold cleanly — per hand and per clip: skin-weight sanity, fingertip ownership, skin coherence (animated reach vs rest), bend angles, spike vertices. Run it after gripforge_hand_rig or gripforge_animate; ok=false comes with a problems list and what to re-run. The Character Studio shows the same clips visually (hand check panel). 0 credits.', { item: z.string().describe('Library character / enemy / bind / animation kit (lib_…)') }, async ({ item }) => {
|
|
1017
|
+
if (!API_KEY)
|
|
1018
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1019
|
+
const res = await fetch(`${API_URL}/api/v1/hand-check`, {
|
|
1020
|
+
method: 'POST',
|
|
1021
|
+
headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
|
|
1022
|
+
body: JSON.stringify({ item }),
|
|
1023
|
+
});
|
|
1024
|
+
const data = (await res.json().catch(() => ({})));
|
|
1025
|
+
if (!res.ok)
|
|
1026
|
+
return err(String(data.error ?? res.statusText));
|
|
1027
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1028
|
+
});
|
|
1029
|
+
server.tool('gripforge_animate', 'Retarget the standard clip pack onto a Mixamo-named Library character/enemy/armed bind. Base slots (idle, walk, run, attack1-3, hit, death, dodge) + extras (taunt, stinger, knockback, block, jump_start/loop/land, idle_guns, shoot, reload, aim, crouch_idle, crouch_walk, knife). archetype remaps the base slots: sword (default), claws (zombie scratch), heavy (slow + heavy combo), brawler (punches), puppet (stiff walk + throws), operator (CS-style: knife/punch strikes). Opt-in slots fire_breath (mouth exhalation) and energy_cast (two-hand azure wave) use the Studio VFX character motions. Saves kind=animation. 0 credits.', {
|
|
1030
|
+
character_id: z.string().describe('Library character, enemy, or armed bind'),
|
|
1031
|
+
archetype: z.enum(['sword', 'claws', 'heavy', 'brawler', 'puppet', 'operator']).optional().describe('Movement/strike archetype — remaps the base slots (default sword); operator = CS-style (knife/punch strikes, guns via idle_guns/shoot/reload/aim, crouch_idle/crouch_walk)'),
|
|
1032
|
+
slots: z
|
|
1033
|
+
.array(z.enum(['idle', 'walk', 'run', 'attack1', 'attack2', 'attack3', 'hit', 'death', 'dodge', 'taunt', 'stinger', 'knockback', 'block', 'jump_start', 'jump_loop', 'jump_land', 'idle_guns', 'shoot', 'fire_breath', 'energy_cast', 'reload', 'aim', 'crouch_idle', 'crouch_walk', 'knife']))
|
|
1034
|
+
.optional(),
|
|
1035
|
+
name: z.string().optional(),
|
|
1036
|
+
out_dir: z.string().optional().describe('Also write the animated GLB here'),
|
|
1037
|
+
}, async ({ character_id, archetype, slots, name, out_dir }) => {
|
|
1038
|
+
if (!API_KEY)
|
|
1039
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1040
|
+
const res = await fetch(`${API_URL}/api/v1/animate-pack`, {
|
|
1041
|
+
method: 'POST',
|
|
1042
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1043
|
+
body: JSON.stringify({ character_id, archetype, slots, name }),
|
|
1044
|
+
});
|
|
1045
|
+
const data = (await res.json().catch(() => ({})));
|
|
1046
|
+
if (!res.ok)
|
|
1047
|
+
return err(String(data.error ?? res.status));
|
|
1048
|
+
const wrote = [];
|
|
1049
|
+
if (out_dir && data.item?.file_url) {
|
|
1050
|
+
const dir = resolve(out_dir);
|
|
1051
|
+
await mkdir(dir, { recursive: true });
|
|
1052
|
+
const bin = Buffer.from(await (await fetch(data.item.file_url)).arrayBuffer());
|
|
1053
|
+
const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
|
|
1054
|
+
await writeFile(dest, bin);
|
|
1055
|
+
wrote.push(dest);
|
|
1056
|
+
}
|
|
1057
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
1058
|
+
});
|
|
1059
|
+
server.tool('gripforge_audio_kit', 'Search locker + Community SFX by description/tags (gunshot, ui, magie, pas, dinosaure…). Hits include description + use[]. Community: clone then pull. out_dir downloads matched files. Free to search.', {
|
|
1060
|
+
prompt: z.string().optional().describe('What you need, e.g. "gunshot", "ui click", "dinosaur roar" (omit for a short catalog)'),
|
|
1061
|
+
out_dir: z.string().optional().describe('Download matched audio files here'),
|
|
1062
|
+
}, async ({ prompt, out_dir }) => {
|
|
1063
|
+
if (!API_KEY)
|
|
1064
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1065
|
+
const wanted = (prompt ?? '').toLowerCase().trim();
|
|
1066
|
+
const qTokens = wanted.split(/[^a-z0-9]+/).filter((w) => w.length >= 2);
|
|
1067
|
+
const blobOf = (it) => {
|
|
1068
|
+
const m = it.meta ?? {};
|
|
1069
|
+
const tags = Array.isArray(m.tags) ? m.tags.join(' ') : '';
|
|
1070
|
+
const use = Array.isArray(m.use) ? m.use.join(' ') : '';
|
|
1071
|
+
return `${it.name} ${it.filename ?? ''} ${m.description ?? ''} ${m.search ?? ''} ${m.pack ?? ''} ${tags} ${use}`.toLowerCase();
|
|
1072
|
+
};
|
|
1073
|
+
const match = (it) => {
|
|
1074
|
+
if (!qTokens.length)
|
|
1075
|
+
return true;
|
|
1076
|
+
const hay = blobOf(it).split(/[^a-z0-9]+/).filter(Boolean);
|
|
1077
|
+
return qTokens.every((w) => hay.some((h) => h === w || (w.length >= 4 && h.startsWith(w))));
|
|
1078
|
+
};
|
|
1079
|
+
const lite = (it, source) => {
|
|
1080
|
+
const m = it.meta ?? {};
|
|
1081
|
+
return {
|
|
1082
|
+
id: it.id,
|
|
1083
|
+
name: it.name,
|
|
1084
|
+
filename: it.filename ?? null,
|
|
1085
|
+
description: typeof m.description === 'string' ? m.description : null,
|
|
1086
|
+
use: Array.isArray(m.use) ? m.use : [],
|
|
1087
|
+
pack: typeof m.pack === 'string' ? m.pack : null,
|
|
1088
|
+
file_url: it.file_url ?? it.fileUrl ?? null,
|
|
1089
|
+
source,
|
|
1090
|
+
};
|
|
1091
|
+
};
|
|
1092
|
+
const [lockRes, commRes] = await Promise.all([
|
|
1093
|
+
fetch(`${API_URL}/api/v1/library?kind=audio&limit=500`, { headers: apiHeaders() }),
|
|
1094
|
+
fetch(`${API_URL}/api/v1/community`),
|
|
1095
|
+
]);
|
|
1096
|
+
const lockData = (await lockRes.json().catch(() => ({})));
|
|
1097
|
+
const commData = (await commRes.json().catch(() => ({})));
|
|
1098
|
+
if (!lockRes.ok)
|
|
1099
|
+
return err('library list failed');
|
|
1100
|
+
const locker = (lockData.items ?? []).filter(match);
|
|
1101
|
+
const have = new Set(locker.map((it) => it.id));
|
|
1102
|
+
const community = (commData.items ?? []).filter((it) => it.kind === 'audio' && !have.has(it.id) && match(it));
|
|
1103
|
+
const all = [...locker.map((it) => lite(it, 'locker')), ...community.map((it) => lite(it, 'community'))];
|
|
1104
|
+
const hits = all.filter((i) => !/^(sfx|music)[_-]/i.test(i.name)).slice(0, wanted ? 40 : 24);
|
|
1105
|
+
const out = {
|
|
1106
|
+
query: wanted || null,
|
|
1107
|
+
total: all.length,
|
|
1108
|
+
sfx: all.filter((i) => /^sfx[_-]/i.test(i.name)),
|
|
1109
|
+
music: all.filter((i) => /^music[_-]/i.test(i.name)),
|
|
1110
|
+
hits,
|
|
1111
|
+
wrote: [],
|
|
1112
|
+
hint: wanted
|
|
1113
|
+
? 'Pick by description/use. Locker ids: gripforge_library_pull. Community ids: clone first (1 credit), then pull.'
|
|
1114
|
+
: 'Pass prompt to filter. Descriptions are on each hit.',
|
|
1115
|
+
};
|
|
1116
|
+
if (out_dir) {
|
|
1117
|
+
const dir = resolve(out_dir);
|
|
1118
|
+
await mkdir(dir, { recursive: true });
|
|
1119
|
+
const download = [...out.sfx, ...out.music, ...out.hits];
|
|
1120
|
+
for (const it of download) {
|
|
1121
|
+
if (!it.file_url)
|
|
1122
|
+
continue;
|
|
1123
|
+
const bin = Buffer.from(await (await fetch(it.file_url)).arrayBuffer());
|
|
1124
|
+
const dest = join(dir, it.filename || `${it.name.replace(/[^\w.-]+/g, '_')}.wav`);
|
|
1125
|
+
await writeFile(dest, bin);
|
|
1126
|
+
out.wrote.push(dest);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
1130
|
+
});
|
|
1131
|
+
server.tool('gripforge_aim', 'Aim-offset poses for a Library character/armed bind (0 credits): for each hand, two-bone IK + wrist alignment so the WEAPON AXIS (gf_muzzle locator from gripforge_loadout, else the seat forward) points at every direction of a yaw×pitch grid. Returns bone-local quaternions per pose + rest, and a Three.js/Godot blend snippet. Works for dual wield (independent hands) and any Mixamo/UAL/Tripo rig. out_dir writes aim.json.', {
|
|
1132
|
+
attach_id: z.string().optional().describe('Armed bind / loadout id (lib_…) — preferred'),
|
|
1133
|
+
character_id: z.string().optional().describe('Character id if no bind'),
|
|
1134
|
+
hands: z.array(z.enum(['left', 'right'])).optional().describe('Default both'),
|
|
1135
|
+
yaws: z.array(z.number()).optional().describe('Default [-60,-30,0,30,60] (left positive)'),
|
|
1136
|
+
pitches: z.array(z.number()).optional().describe('Default [-40,-20,0,20,40] (up positive)'),
|
|
1137
|
+
extension: z.number().optional().describe('Arm extension 0.5–0.98 (default 0.9)'),
|
|
1138
|
+
out_dir: z.string().optional().describe('Write aim.json here'),
|
|
1139
|
+
}, async ({ attach_id, character_id, hands, yaws, pitches, extension, out_dir }) => {
|
|
1140
|
+
if (!API_KEY)
|
|
1141
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1142
|
+
if (!attach_id && !character_id)
|
|
1143
|
+
return err('attach_id or character_id required');
|
|
1144
|
+
const res = await fetch(`${API_URL}/api/v1/aim`, {
|
|
1145
|
+
method: 'POST',
|
|
1146
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1147
|
+
body: JSON.stringify({ attach_id, character_id, hands, yaws, pitches, extension }),
|
|
1148
|
+
});
|
|
1149
|
+
const data = (await res.json().catch(() => ({})));
|
|
1150
|
+
if (!res.ok)
|
|
1151
|
+
return err(String(data.error ?? res.status));
|
|
1152
|
+
const wrote = [];
|
|
1153
|
+
if (out_dir) {
|
|
1154
|
+
const dir = resolve(out_dir);
|
|
1155
|
+
await mkdir(dir, { recursive: true });
|
|
1156
|
+
const dest = join(dir, 'aim.json');
|
|
1157
|
+
await writeFile(dest, JSON.stringify(data, null, 2));
|
|
1158
|
+
wrote.push(dest);
|
|
1159
|
+
}
|
|
1160
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
1161
|
+
});
|
|
1162
|
+
server.tool('gripforge_terrain_concepts', 'Three concept pictures of a level (aerial three-quarter view, readable layout) BEFORE any 3D: pick one, then build the terrain from it in the Terrain Studio (the picture is read into a LevelIntent) or pass its id as concept_item. Each image is a Library concept item. 1 credit per image (default 3).', { prompt: z.string().min(3).max(600).describe('the level, e.g. "snowy ARPG zone, castle north, dense forests, frozen lake"'), n: z.number().min(1).max(4).optional().describe('default 3') }, async ({ prompt, n }) => {
|
|
1163
|
+
if (!API_KEY)
|
|
1164
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1165
|
+
const res = await fetch(`${API_URL}/api/v1/terrain-concepts`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': API_KEY }, body: JSON.stringify({ prompt, n: n ?? 3 }) });
|
|
1166
|
+
const data = (await res.json().catch(() => ({})));
|
|
1167
|
+
if (!res.ok)
|
|
1168
|
+
return err(String(data.error ?? res.statusText));
|
|
1169
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1170
|
+
});
|
|
1171
|
+
server.tool('gripforge_character_concepts', 'Three LOOK pictures (3/4, Imagine low 1K) of a named character, enemy or boss BEFORE any 3D. Pass weapon to show it on the looks; 3D later is T-pose empty hands. Pick one, then gripforge_generate_character with concept_item. 1 credit per image (default 3).', {
|
|
1172
|
+
name: z.string().min(2).max(80).describe('Creation name, stored on each concept'),
|
|
1173
|
+
prompt: z.string().min(3).max(600).describe('Look, outfit, silhouette'),
|
|
1174
|
+
kind: z.enum(['character', 'enemy', 'boss']).optional().describe('Default character'),
|
|
1175
|
+
weapon: z.string().max(160).optional().describe('Shown on the looks only; empty hands if omitted'),
|
|
1176
|
+
n: z.number().min(1).max(4).optional().describe('Default 3'),
|
|
1177
|
+
}, async ({ name, prompt, kind, weapon, n }) => {
|
|
1178
|
+
if (!API_KEY)
|
|
1179
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1180
|
+
const res = await fetch(`${API_URL}/api/v1/character-concepts`, {
|
|
1181
|
+
method: 'POST',
|
|
1182
|
+
headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
|
|
1183
|
+
body: JSON.stringify({ name, prompt, kind, weapon, n: n ?? 3 }),
|
|
1184
|
+
});
|
|
1185
|
+
const data = (await res.json().catch(() => ({})));
|
|
1186
|
+
if (!res.ok)
|
|
1187
|
+
return err(String(data.error ?? res.statusText));
|
|
1188
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1189
|
+
});
|
|
1190
|
+
server.tool('gripforge_level', 'Plan a playable level from a prompt (0 credits): rooms placed in metres (entrance, combat rooms, junctions, key/lock, boss antechamber + boss room, treasure, secret room), connections, critical path, shortcuts, and gameplay spawns (player/packs/boss/rewards). Same seed → same level. Pair with gripforge_scene_kit for dressing props and gripforge_style_kit for enemies.', {
|
|
1191
|
+
prompt: z.string().describe('e.g. "gothic castle dungeon, 3 combat rooms, one boss, exploration pacing"'),
|
|
1192
|
+
seed: z.number().optional(),
|
|
1193
|
+
combat_rooms: z.number().optional(),
|
|
1194
|
+
floors: z.number().optional(),
|
|
1195
|
+
optional_branches: z.number().optional(),
|
|
1196
|
+
out_dir: z.string().optional().describe('Write level.json here'),
|
|
1197
|
+
}, async ({ prompt, seed, combat_rooms, floors, optional_branches, out_dir }) => {
|
|
1198
|
+
if (!API_KEY)
|
|
1199
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1200
|
+
const res = await fetch(`${API_URL}/api/v1/level`, {
|
|
1201
|
+
method: 'POST',
|
|
1202
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1203
|
+
body: JSON.stringify({ prompt, seed, combat_rooms, floors, optional_branches }),
|
|
1204
|
+
});
|
|
1205
|
+
const data = (await res.json().catch(() => ({})));
|
|
1206
|
+
if (!res.ok)
|
|
1207
|
+
return err(String(data.error ?? res.status));
|
|
1208
|
+
const wrote = [];
|
|
1209
|
+
if (out_dir) {
|
|
1210
|
+
const dir = resolve(out_dir);
|
|
1211
|
+
await mkdir(dir, { recursive: true });
|
|
1212
|
+
const dest = join(dir, 'level.json');
|
|
1213
|
+
await writeFile(dest, JSON.stringify(data, null, 2));
|
|
1214
|
+
wrote.push(dest);
|
|
1215
|
+
}
|
|
1216
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
1217
|
+
});
|
|
1218
|
+
server.tool('gripforge_loadout', 'Build a multi-weapon character in ONE call: attaches every prop of every set onto the same rig, ' +
|
|
1219
|
+
'names each prop node gf_prop_<slug>_<l|r>, stamps glTF extras { gripforge: { prop, set, hand } } and a ' +
|
|
1220
|
+
'scene manifest { default, sets }, then (by default) retargets the standard clip pack. ' +
|
|
1221
|
+
'The engine switches weapons by toggling node visibility — the returned Three.js snippet does it. ' +
|
|
1222
|
+
'Example sets: { "sword": [{"prop_id":"lib_sword"}], "guns": [{"prop_id":"lib_gun","hand":"left","style":"gun"}, {"prop_id":"lib_gun","hand":"right","style":"gun"}] }. ' +
|
|
1223
|
+
'Credits: one attach per prop.', {
|
|
1224
|
+
character_id: z.string().describe('Library character id (lib_…)'),
|
|
1225
|
+
sets: z
|
|
1226
|
+
.record(z.string(), z.array(z.object({
|
|
1227
|
+
prop_id: z.string(),
|
|
1228
|
+
hand: z.enum(['left', 'right']).optional(),
|
|
1229
|
+
style: z.enum(['melee', 'gun', 'shield', 'staff']).optional(),
|
|
1230
|
+
})))
|
|
1231
|
+
.describe('set name → props to hold in that set'),
|
|
1232
|
+
default_set: z.string().optional().describe('Set visible by default (first set otherwise)'),
|
|
1233
|
+
archetype: z.enum(['sword', 'claws', 'heavy', 'brawler', 'puppet', 'operator']).optional().describe('Movement/strike archetype — remaps the base slots (default sword); operator = CS-style (knife/punch strikes, guns via idle_guns/shoot/reload/aim, crouch_idle/crouch_walk)'),
|
|
1234
|
+
name: z.string().optional(),
|
|
1235
|
+
animate: z.boolean().optional().describe('Also retarget the standard clip pack (default true)'),
|
|
1236
|
+
out_dir: z.string().optional().describe('Write the animated loadout GLB (or the bind GLB) + manifest.json here'),
|
|
1237
|
+
}, async ({ character_id, sets, default_set, archetype, name, animate, out_dir }) => {
|
|
1238
|
+
if (!API_KEY)
|
|
1239
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1240
|
+
const res = await fetch(`${API_URL}/api/v1/loadout`, {
|
|
1241
|
+
method: 'POST',
|
|
1242
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1243
|
+
body: JSON.stringify({ character_id, sets, default: default_set, archetype, name, animate }),
|
|
1244
|
+
});
|
|
1245
|
+
const data = (await res.json().catch(() => ({})));
|
|
1246
|
+
if (!res.ok)
|
|
1247
|
+
return err(String(data.error ?? res.status));
|
|
1248
|
+
const wrote = [];
|
|
1249
|
+
if (out_dir) {
|
|
1250
|
+
const dir = resolve(out_dir);
|
|
1251
|
+
await mkdir(dir, { recursive: true });
|
|
1252
|
+
const best = data.animation?.file_url ? data.animation : data.item;
|
|
1253
|
+
if (best?.file_url) {
|
|
1254
|
+
const bin = Buffer.from(await (await fetch(best.file_url)).arrayBuffer());
|
|
1255
|
+
const dest = join(dir, 'loadout.glb');
|
|
1256
|
+
await writeFile(dest, bin);
|
|
1257
|
+
wrote.push(dest);
|
|
1258
|
+
}
|
|
1259
|
+
if (data.manifest) {
|
|
1260
|
+
const mf = join(dir, 'manifest.json');
|
|
1261
|
+
await writeFile(mf, JSON.stringify(data.manifest, null, 2));
|
|
1262
|
+
wrote.push(mf);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
1266
|
+
});
|
|
1267
|
+
server.tool('gripforge_retarget', 'Convert clips from skeleton A (source_id) onto skeleton B (target_id). Saves kind=animation.', {
|
|
1268
|
+
source_id: z.string(),
|
|
1269
|
+
target_id: z.string(),
|
|
1270
|
+
clip: z.string().optional(),
|
|
1271
|
+
name: z.string().optional(),
|
|
1272
|
+
}, async ({ source_id, target_id, clip, name }) => {
|
|
1273
|
+
if (!API_KEY)
|
|
1274
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1275
|
+
const res = await fetch(`${API_URL}/api/v1/retarget`, {
|
|
1276
|
+
method: 'POST',
|
|
1277
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1278
|
+
body: JSON.stringify({ source_id, target_id, clip, name }),
|
|
1279
|
+
});
|
|
1280
|
+
const data = await res.json().catch(() => ({}));
|
|
1281
|
+
if (!res.ok)
|
|
1282
|
+
return err(String(data.error ?? res.status));
|
|
1283
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1284
|
+
});
|
|
1285
|
+
server.tool('gripforge_rest_pose', 'Lower arms from T-pose until they would clip this mesh. Saves arms-down rest (clip idle). Armed binds keep weapons in the hands. 0 credits.', { character_id: z.string(), name: z.string().optional() }, async ({ character_id, name }) => {
|
|
1286
|
+
if (!API_KEY)
|
|
1287
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1288
|
+
const res = await fetch(`${API_URL}/api/v1/rest-pose`, {
|
|
1289
|
+
method: 'POST',
|
|
1290
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1291
|
+
body: JSON.stringify({ character_id, name }),
|
|
1292
|
+
});
|
|
1293
|
+
const data = await res.json().catch(() => ({}));
|
|
1294
|
+
if (!res.ok)
|
|
1295
|
+
return err(String(data.error ?? res.status));
|
|
1296
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1297
|
+
});
|
|
1298
|
+
server.tool('gripforge_render', 'Server PNG preview of a Library GLB so the agent can see pose/attach without a browser.', { id: z.string(), width: z.number().optional(), height: z.number().optional() }, async ({ id, width, height }) => {
|
|
1299
|
+
if (!API_KEY)
|
|
1300
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1301
|
+
const res = await fetch(`${API_URL}/api/v1/render`, {
|
|
1302
|
+
method: 'POST',
|
|
1303
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1304
|
+
body: JSON.stringify({ id, width, height }),
|
|
1305
|
+
});
|
|
1306
|
+
if (!res.ok) {
|
|
1307
|
+
const data = await res.json().catch(() => ({}));
|
|
1308
|
+
return err(String(data.error ?? res.status));
|
|
1309
|
+
}
|
|
1310
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1311
|
+
return {
|
|
1312
|
+
content: [
|
|
1313
|
+
{ type: 'text', text: JSON.stringify({ id, bytes: buf.length, mime: 'image/png' }) },
|
|
1314
|
+
{ type: 'image', data: buf.toString('base64'), mimeType: 'image/png' },
|
|
1315
|
+
],
|
|
1316
|
+
};
|
|
1317
|
+
});
|
|
1318
|
+
server.tool('gripforge_scene_kit', 'Locker props for a game look plus a suggested layout (metres, Y-up). Call after style_kit. Empty slots → generate that dressing prop, tag it, retry.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
|
|
1319
|
+
if (!API_KEY)
|
|
1320
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1321
|
+
const res = await fetch(`${API_URL}/api/v1/scene-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1322
|
+
const data = await res.json().catch(() => ({}));
|
|
1323
|
+
if (!res.ok)
|
|
1324
|
+
return err(String(data.error ?? res.status));
|
|
1325
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1326
|
+
});
|
|
1327
|
+
server.tool('gripforge_hitbox', 'Body capsules + weapons[] (one per gf_prop_ wrapper) from a Library character, armed bind or loadout. 0 credits.', {
|
|
1328
|
+
character_id: z.string().optional().describe('Library character/enemy id'),
|
|
1329
|
+
attach_id: z.string().optional().describe('Library armed bind id'),
|
|
1330
|
+
id: z.string().optional().describe('Library id of a loadout / bind / character'),
|
|
1331
|
+
}, async ({ character_id, attach_id, id }) => {
|
|
1332
|
+
if (!API_KEY)
|
|
1333
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1334
|
+
if (!character_id && !attach_id && !id)
|
|
1335
|
+
return err('Provide character_id, attach_id or id.');
|
|
1336
|
+
const res = await fetch(`${API_URL}/api/v1/hitbox`, {
|
|
1337
|
+
method: 'POST',
|
|
1338
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1339
|
+
body: JSON.stringify({ character_id, attach_id, id }),
|
|
1340
|
+
});
|
|
1341
|
+
const data = await res.json().catch(() => ({}));
|
|
1342
|
+
if (!res.ok)
|
|
1343
|
+
return err(String(data.error ?? res.status));
|
|
1344
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1345
|
+
});
|
|
1346
|
+
server.tool('gripforge_vehicle_wheels', 'Split the 4 wheels out of a fused kart/car GLB so they can spin: Wheel_FL/FR/RL/RR nodes, hub pivots, extras gf_wheel {radius, axle, spin}. Geometric cut (cylinder + plane, seam invariant under rotation, capped). Run gripforge_vehicle_orient first. 0 credits. apply=false → analysis only.', {
|
|
1347
|
+
id: z.string().describe('Library id of the vehicle GLB (lib_…)'),
|
|
1348
|
+
apply: z.boolean().optional().describe('default true — rewrite the Library file with the split wheels'),
|
|
1349
|
+
}, async ({ id, apply }) => {
|
|
1350
|
+
if (!API_KEY)
|
|
1351
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1352
|
+
const res = await fetch(`${API_URL}/api/v1/vehicle-wheels`, {
|
|
1353
|
+
method: 'POST',
|
|
1354
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1355
|
+
body: JSON.stringify({ id, apply }),
|
|
1356
|
+
});
|
|
1357
|
+
const data = await res.json().catch(() => ({}));
|
|
1358
|
+
if (!res.ok)
|
|
1359
|
+
return err(String(data.error ?? res.status));
|
|
1360
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1361
|
+
});
|
|
1362
|
+
server.tool('gripforge_vehicle_orient', 'Yaw so a kart/vehicle GLB nose is local +Z (Meshy often faces +X). Then heading = yawToward(tangent) = atan2(fx, fz). Pass Library id. 0 credits.', {
|
|
1363
|
+
id: z.string().optional().describe('Library id of the vehicle/kart GLB (lib_…)'),
|
|
1364
|
+
prop_id: z.string().optional().describe('Same as id'),
|
|
1365
|
+
}, async ({ id, prop_id }) => {
|
|
1366
|
+
if (!API_KEY)
|
|
1367
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1368
|
+
if (!id && !prop_id)
|
|
1369
|
+
return err('Provide id or prop_id.');
|
|
1370
|
+
const res = await fetch(`${API_URL}/api/v1/vehicle-orient`, {
|
|
1371
|
+
method: 'POST',
|
|
1372
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1373
|
+
body: JSON.stringify({ id, prop_id }),
|
|
1374
|
+
});
|
|
1375
|
+
const data = await res.json().catch(() => ({}));
|
|
1376
|
+
if (!res.ok)
|
|
1377
|
+
return err(String(data.error ?? res.status));
|
|
1378
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1379
|
+
});
|
|
1380
|
+
server.tool('gripforge_map_plan', 'FPS/bomb map. Interprets overhead as 3D compound (hollow rooms, corniche, A plat+pit, arches, vaulted tunnels, sidewalks). Preview https://gripforge.ai/map-plan?prompt=dust2. Pair gripforge_map_props + gripforge_map_wires. 0 credits. Dungeons: gripforge_level.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike dust2 bomb defusal'),
|
|
1381
|
+
item: z.string().optional().describe('lib_… traced plan from gripforge_map_concept (overrides the preset)'),
|
|
1382
|
+
windows: z.enum(['shuttered', 'barred', 'none']).optional().describe('Boundary windows: volets (default), grilles, ou aucune'),
|
|
1383
|
+
window_density: z.number().min(0).max(1).optional().describe('Share of boundary segments that get a window (default 0.25)'),
|
|
1384
|
+
}, async ({ prompt, item, windows, window_density }) => {
|
|
1385
|
+
if (!API_KEY)
|
|
1386
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1387
|
+
const qs = new URLSearchParams({ prompt });
|
|
1388
|
+
if (item)
|
|
1389
|
+
qs.set('item', item);
|
|
1390
|
+
if (windows)
|
|
1391
|
+
qs.set('windows', windows);
|
|
1392
|
+
if (window_density != null)
|
|
1393
|
+
qs.set('window_density', String(window_density));
|
|
1394
|
+
const res = await fetch(`${API_URL}/api/v1/map-plan?${qs}`, { headers: apiHeaders() });
|
|
1395
|
+
const data = await res.json().catch(() => ({}));
|
|
1396
|
+
if (!res.ok)
|
|
1397
|
+
return err(String(data.error ?? res.status));
|
|
1398
|
+
const rec = data;
|
|
1399
|
+
const preview = typeof rec.preview_url === 'string' ? `Preview: ${rec.preview_url}\n\n` : '';
|
|
1400
|
+
return { content: [{ type: 'text', text: `${preview}${JSON.stringify(data, null, 2)}` }] };
|
|
1401
|
+
});
|
|
1402
|
+
server.tool('gripforge_map_concept', 'AI-DRAW a map layout: gpt-image-2 generates a top-down plan schematic (white walls on black) + a 3/4 greybox concept render, auto-traces the plan into MapPlan wall boxes, saves both to the Library. Reuse via gripforge_map_plan { item } or https://gripforge.ai/map-plan?item=lib_…&greybox=1. Alternative to the code presets when the layout should come from an image.', {
|
|
1403
|
+
prompt: z.string().min(3).max(400).describe('e.g. "moroccan desert village bomb map, tight alleys, two plazas"'),
|
|
1404
|
+
extent_m: z.number().min(40).max(300).optional().describe('Playfield size in metres the image maps onto (default 110)'),
|
|
1405
|
+
threshold: z.number().min(30).max(240).optional().describe('Luminance above which a pixel is wall (default 145 — higher = thinner walls, wider streets)'),
|
|
1406
|
+
plan_item: z.string().optional().describe('Existing traced plan: re-render its concept image, or with retrace=true re-trace it'),
|
|
1407
|
+
retrace: z.boolean().optional().describe('With plan_item: re-trace the stored plan image with the new threshold/extent (no image regen)'),
|
|
1408
|
+
min_street_m: z.number().min(1.5).max(6).optional().describe('Guaranteed minimum street width in metres — narrower passages are carved wider (default 2.6)'),
|
|
1409
|
+
image_path: z.string().optional().describe('Trace THIS local image instead of generating one (hand-drawn plan, radar crop). White walls on black — or set invert for radar-style (bright walkable on dark)'),
|
|
1410
|
+
concept_path: z.string().optional().describe('With image_path: local concept/reference image stored alongside'),
|
|
1411
|
+
invert: z.boolean().optional().describe('image_path is radar-style: walkable is bright, everything dark becomes wall'),
|
|
1412
|
+
convert: z.boolean().optional().describe('image_path is an ILLUSTRATED/textured top view: gpt-image-2 converts it into the binary wall diagram first (the original becomes the concept image)'),
|
|
1413
|
+
views: z.number().min(0).max(4).optional().describe('Also generate N street-level KEY-ANGLE views (main lane, plaza, chokepoint, site doors) conditioned on the aerial concept — linked to the plan, shown in the studio CONCEPT tab'),
|
|
1414
|
+
out_dir: z.string().optional().describe('Also write plan.png + greybox.png here'),
|
|
1415
|
+
}, async ({ prompt, extent_m, threshold, plan_item, retrace, min_street_m, image_path, concept_path, invert, convert, views, out_dir }) => {
|
|
1416
|
+
if (!API_KEY)
|
|
1417
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1418
|
+
let image_b64;
|
|
1419
|
+
let concept_b64;
|
|
1420
|
+
if (image_path) {
|
|
1421
|
+
const { readFileSync } = await import('node:fs');
|
|
1422
|
+
image_b64 = readFileSync(image_path).toString('base64');
|
|
1423
|
+
if (concept_path)
|
|
1424
|
+
concept_b64 = readFileSync(concept_path).toString('base64');
|
|
1425
|
+
}
|
|
1426
|
+
const res = await fetch(`${API_URL}/api/v1/map-concept`, {
|
|
1427
|
+
method: 'POST',
|
|
1428
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1429
|
+
body: JSON.stringify({ prompt, extent_m, threshold, plan_item, retrace, min_street_m, image_b64, concept_b64, invert, convert, views }),
|
|
1430
|
+
});
|
|
1431
|
+
const data = (await res.json().catch(() => ({})));
|
|
1432
|
+
if (!res.ok)
|
|
1433
|
+
return err(String(data.error ?? res.status));
|
|
1434
|
+
const wrote = [];
|
|
1435
|
+
if (out_dir) {
|
|
1436
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
1437
|
+
const { join } = await import('node:path');
|
|
1438
|
+
mkdirSync(out_dir, { recursive: true });
|
|
1439
|
+
for (const [name, url] of [['map-concept-plan.png', data.plan_item?.file_url], ['map-concept-greybox.png', data.concept_item?.file_url]]) {
|
|
1440
|
+
if (!url)
|
|
1441
|
+
continue;
|
|
1442
|
+
const bin = await fetch(url, { headers: apiHeaders() });
|
|
1443
|
+
if (bin.ok) {
|
|
1444
|
+
const fp = join(out_dir, name);
|
|
1445
|
+
writeFileSync(fp, Buffer.from(await bin.arrayBuffer()));
|
|
1446
|
+
wrote.push(fp);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
const preview = typeof data.preview_url === 'string' ? `Preview: ${data.preview_url}\n\n` : '';
|
|
1451
|
+
return { content: [{ type: 'text', text: `${preview}${JSON.stringify({ ...data, wrote }, null, 2)}` }] };
|
|
1452
|
+
});
|
|
1453
|
+
server.tool('gripforge_map_match', 'stage=match — the reference-constrained loop: renders the greybox top-down orthographic (the concept camera), compares it to the stored reference diagram (IoU, missing/extra %, per-quadrant errors), and with iterate=true grid-searches the tracer parameters and PERSISTS the best-scoring plan (manual spawns kept). Chain: map_concept -> map_analyze -> map_match -> map_paths -> map_export.', {
|
|
1454
|
+
item: z.string().describe('lib_… traced plan item'),
|
|
1455
|
+
iterate: z.boolean().optional().describe('run the correction loop (12 tracer-parameter candidates) and persist the best plan'),
|
|
1456
|
+
}, async ({ item, iterate }) => {
|
|
1457
|
+
if (!API_KEY)
|
|
1458
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1459
|
+
const res = await fetch(`${API_URL}/api/v1/map-match`, {
|
|
1460
|
+
method: 'POST',
|
|
1461
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1462
|
+
body: JSON.stringify({ item, iterate }),
|
|
1463
|
+
});
|
|
1464
|
+
const data = await res.json().catch(() => ({}));
|
|
1465
|
+
if (!res.ok)
|
|
1466
|
+
return err(String(data.error ?? res.status));
|
|
1467
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1468
|
+
});
|
|
1469
|
+
server.tool('gripforge_map_paths', 'WALKABILITY report of a traced greybox: free-space grid, connected regions (sealed pockets), spawns validated/snapped onto the main region, T-to-CT path length + tightest corridor width + waypoints. Read it before shipping a map — spawns-in-walls and unreachable areas show up here.', { item: z.string().describe('lib_… traced plan item') }, async ({ item }) => {
|
|
1470
|
+
if (!API_KEY)
|
|
1471
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1472
|
+
const res = await fetch(`${API_URL}/api/v1/map-paths?${new URLSearchParams({ item })}`, { headers: apiHeaders() });
|
|
1473
|
+
const data = await res.json().catch(() => ({}));
|
|
1474
|
+
if (!res.ok)
|
|
1475
|
+
return err(String(data.error ?? res.status));
|
|
1476
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1477
|
+
});
|
|
1478
|
+
server.tool('gripforge_map_textures', 'OUR texture pipeline (not a black-box retexture): generates 5 seamless tileable surfaces FROM the concept (4 wall variants + ground stone), conditioned on a street-level view and the analysis palette. Linked to the plan (meta.texture_items) — map-export embeds them with computed normal maps instead of the stock stucco files. The tiling-killer step after map_analyze.', {
|
|
1479
|
+
plan_item: z.string().describe('lib_… traced plan item'),
|
|
1480
|
+
prompt: z.string().max(200).optional().describe('Extra flavor, e.g. "more cracked, war-torn"'),
|
|
1481
|
+
}, async ({ plan_item, prompt }) => {
|
|
1482
|
+
if (!API_KEY)
|
|
1483
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1484
|
+
const res = await fetch(`${API_URL}/api/v1/map-textures`, {
|
|
1485
|
+
method: 'POST',
|
|
1486
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1487
|
+
body: JSON.stringify({ plan_item, prompt }),
|
|
1488
|
+
});
|
|
1489
|
+
const data = await res.json().catch(() => ({}));
|
|
1490
|
+
if (!res.ok)
|
|
1491
|
+
return err(String(data.error ?? res.status));
|
|
1492
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1493
|
+
});
|
|
1494
|
+
server.tool('gripforge_map_analyze', 'READ a map concept image like a level designer (GPT-5 vision): counts doors, windows, stairs, domes, awnings, crates, vehicles, palms + wall height, elevation levels, palette. Stored on the plan item (meta.analysis) — the traced dressing then uses the counts as QUOTAS so the greybox carries exactly what the concept drew. Run after gripforge_map_concept.', {
|
|
1495
|
+
item: z.string().describe('lib_… traced plan item'),
|
|
1496
|
+
image: z.string().optional().describe('lib_… concept image to read (default: the linked concept_item)'),
|
|
1497
|
+
}, async ({ item, image }) => {
|
|
1498
|
+
if (!API_KEY)
|
|
1499
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1500
|
+
const res = await fetch(`${API_URL}/api/v1/map-analyze`, {
|
|
1501
|
+
method: 'POST',
|
|
1502
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1503
|
+
body: JSON.stringify({ item, image }),
|
|
1504
|
+
});
|
|
1505
|
+
const data = await res.json().catch(() => ({}));
|
|
1506
|
+
if (!res.ok)
|
|
1507
|
+
return err(String(data.error ?? res.status));
|
|
1508
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1509
|
+
});
|
|
1510
|
+
server.tool('gripforge_map_decor', 'AI street-dressing sheet for AI-traced maps: gpt-image-2 draws a 3x2 TRANSPARENT sheet of weathered street posters, saved to the Library. Pass plan_item to link it — the traced dressing already pastes poster quads at eye height on facades, and map-plan/map-export embed the linked sheet automatically. Realism layer over gripforge_map_concept.', {
|
|
1511
|
+
prompt: z.string().max(300).optional().describe('Flavor for the 6 items'),
|
|
1512
|
+
sheet: z.enum(['posters', 'doors', 'debris', 'windows']).optional().describe('posters (default) = street posters at eye height; doors = photoreal weathered double doors, one per house facade'),
|
|
1513
|
+
plan_item: z.string().optional().describe('lib_… traced plan to link (meta.decor_item)'),
|
|
1514
|
+
}, async ({ prompt, plan_item, sheet }) => {
|
|
1515
|
+
if (!API_KEY)
|
|
1516
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1517
|
+
const res = await fetch(`${API_URL}/api/v1/map-decor`, {
|
|
1518
|
+
method: 'POST',
|
|
1519
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1520
|
+
body: JSON.stringify({ prompt, plan_item, sheet }),
|
|
1521
|
+
});
|
|
1522
|
+
const data = await res.json().catch(() => ({}));
|
|
1523
|
+
if (!res.ok)
|
|
1524
|
+
return err(String(data.error ?? res.status));
|
|
1525
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1526
|
+
});
|
|
1527
|
+
server.tool('gripforge_map_refs', 'Official in-game stills for a map (Dust II A/B/mid/cat/tunnels). HTTPS links, Valve copyright — reference only, never Meshy image-to-3d. live=true HEAD-checks. 0 credits.', {
|
|
1528
|
+
prompt: z.string().min(2).max(240).describe('e.g. dust2'),
|
|
1529
|
+
live: z.boolean().optional().describe('HEAD-check URLs'),
|
|
1530
|
+
}, async ({ prompt, live }) => {
|
|
1531
|
+
if (!API_KEY)
|
|
1532
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1533
|
+
const qs = new URLSearchParams({ prompt });
|
|
1534
|
+
if (live)
|
|
1535
|
+
qs.set('live', '1');
|
|
1536
|
+
const res = await fetch(`${API_URL}/api/v1/map-refs?${qs}`, { headers: apiHeaders() });
|
|
1537
|
+
const data = await res.json().catch(() => ({}));
|
|
1538
|
+
if (!res.ok)
|
|
1539
|
+
return err(String(data.error ?? res.status));
|
|
1540
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1541
|
+
});
|
|
1542
|
+
server.tool('gripforge_map_props', 'FPS map callout props (xbox, goose, A/B crates, mid/long doors, T palms). Matches locker. Pair with gripforge_map_plan. Preview https://gripforge.ai/map-plan?prompt=dust2. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike dust2') }, async ({ prompt }) => {
|
|
1543
|
+
if (!API_KEY)
|
|
1544
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1545
|
+
const res = await fetch(`${API_URL}/api/v1/map-props?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1546
|
+
const data = await res.json().catch(() => ({}));
|
|
1547
|
+
if (!res.ok)
|
|
1548
|
+
return err(String(data.error ?? res.status));
|
|
1549
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1550
|
+
});
|
|
1551
|
+
server.tool('gripforge_map_wires', 'Sagging electrical cables for an FPS map: two anchors + sag + strands. Not a generated mesh. Pair with gripforge_map_plan. Dust2: T yard, cat (shot5a), mid, A. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike dust2') }, async ({ prompt }) => {
|
|
1552
|
+
if (!API_KEY)
|
|
1553
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1554
|
+
const res = await fetch(`${API_URL}/api/v1/map-wires?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1555
|
+
const data = await res.json().catch(() => ({}));
|
|
1556
|
+
if (!res.ok)
|
|
1557
|
+
return err(String(data.error ?? res.status));
|
|
1558
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1559
|
+
});
|
|
1560
|
+
server.tool('gripforge_map_reconstruct', 'Hierarchical FPS reconstruction: classified official stills (LINK) → MapGraph → greybox (no props) → zone parts → camera match → dress (original textures + map_props + map_wires). prompt e.g. Reconstruct map: Counter-Strike 2 Dust II. stage=collect|graph|greybox|zone|dress|all. Dust II is Valve IP — benchmark only, no BSP/VPK, never Meshy a screenshot. Greybox preview https://gripforge.ai/map-plan?prompt=dust2&greybox=1 — dressed /map-plan?prompt=dust2. 0 credits.', {
|
|
1561
|
+
prompt: z.string().min(2).max(240).describe('e.g. Reconstruct map: Counter-Strike 2 Dust II'),
|
|
1562
|
+
stage: z
|
|
1563
|
+
.enum(['collect', 'graph', 'greybox', 'zone', 'validate', 'capture', 'dress', 'all'])
|
|
1564
|
+
.optional()
|
|
1565
|
+
.describe('collect | graph | greybox | zone | validate | capture | dress | all'),
|
|
1566
|
+
zone: z.string().min(2).max(40).optional().describe('LONG_A | A_SITE | CATWALK | …'),
|
|
1567
|
+
}, async ({ prompt, stage, zone }) => {
|
|
1568
|
+
if (!API_KEY)
|
|
1569
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1570
|
+
const qs = new URLSearchParams({ prompt });
|
|
1571
|
+
if (stage)
|
|
1572
|
+
qs.set('stage', stage);
|
|
1573
|
+
if (zone)
|
|
1574
|
+
qs.set('zone', zone);
|
|
1575
|
+
const res = await fetch(`${API_URL}/api/v1/map-reconstruct?${qs}`, { headers: apiHeaders() });
|
|
1576
|
+
const data = await res.json().catch(() => ({}));
|
|
1577
|
+
if (!res.ok)
|
|
1578
|
+
return err(String(data.error ?? res.status));
|
|
1579
|
+
const rec = data;
|
|
1580
|
+
const preview = typeof rec.preview_url === 'string' ? `Preview: ${rec.preview_url}\n\n` : '';
|
|
1581
|
+
return { content: [{ type: 'text', text: `${preview}${JSON.stringify(data, null, 2)}` }] };
|
|
1582
|
+
});
|
|
1583
|
+
server.tool('gripforge_map_export', 'Export the dressed FPS map as ONE engine-ready GLB: merged textured meshes named <family>-col (Godot builds trimesh colliders on import), metric UVs, ground slab. Pair with a NavigationRegion3D bake. 0 credits.', {
|
|
1584
|
+
prompt: z.string().min(2).max(240).describe('e.g. "dust2"'),
|
|
1585
|
+
item: z.string().optional().describe('lib_… traced plan from gripforge_map_concept — exports the AI-drawn layout instead of dust2'),
|
|
1586
|
+
extent_m: z.number().min(30).max(300).optional().describe('Rescale the traced plan to this playfield size in metres'),
|
|
1587
|
+
out_dir: z.string().optional().describe('Write dust2-map.glb here'),
|
|
1588
|
+
save: z.boolean().optional().describe('Also store the GLB in the Library (kind prop)'),
|
|
1589
|
+
godot_project: z.boolean().optional().describe('Write a ready-to-run Godot 4 project around the GLB into out_dir (scenes, FPS player, navmesh bake) — then: godot --path <out_dir>'),
|
|
1590
|
+
lighting: z.enum(['noon', 'golden', 'dusk', 'night', 'overcast']).optional().describe('Godot mood preset (SDFGI real-time GI, no bake): noon = desert midday (default), golden = warm low sun, dusk = orange sunset, night = cool moonlight, overcast = soft grey. Tunable afterwards in scenes/main.tscn (Sun.light_energy, ambient_light_energy, sdfgi_energy — see the kit README)'),
|
|
1591
|
+
}, async ({ prompt, item, extent_m, out_dir, save, godot_project, lighting }) => {
|
|
1592
|
+
if (!API_KEY)
|
|
1593
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1594
|
+
const qs = new URLSearchParams({ prompt });
|
|
1595
|
+
if (item)
|
|
1596
|
+
qs.set('item', item);
|
|
1597
|
+
if (extent_m != null)
|
|
1598
|
+
qs.set('extent_m', String(extent_m));
|
|
1599
|
+
if (save)
|
|
1600
|
+
qs.set('save', '1');
|
|
1601
|
+
const res = await fetch(`${API_URL}/api/v1/map-export?${qs}`, { headers: apiHeaders() });
|
|
1602
|
+
if (!res.ok) {
|
|
1603
|
+
const data = await res.json().catch(() => ({}));
|
|
1604
|
+
return err(String(data.error ?? res.status));
|
|
1605
|
+
}
|
|
1606
|
+
if (save) {
|
|
1607
|
+
const data = await res.json();
|
|
1608
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1609
|
+
}
|
|
1610
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1611
|
+
const wrote = [];
|
|
1612
|
+
if (out_dir) {
|
|
1613
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
1614
|
+
const { join, dirname } = await import('node:path');
|
|
1615
|
+
mkdirSync(out_dir, { recursive: true });
|
|
1616
|
+
if (godot_project) {
|
|
1617
|
+
const kitQs = new URLSearchParams({ prompt, godot: '1' });
|
|
1618
|
+
if (item)
|
|
1619
|
+
kitQs.set('item', item);
|
|
1620
|
+
if (extent_m != null)
|
|
1621
|
+
kitQs.set('extent_m', String(extent_m));
|
|
1622
|
+
if (lighting)
|
|
1623
|
+
kitQs.set('lighting', lighting);
|
|
1624
|
+
const kitRes = await fetch(`${API_URL}/api/v1/map-export?${kitQs}`, { headers: apiHeaders() });
|
|
1625
|
+
const kit = (await kitRes.json());
|
|
1626
|
+
for (const [rel, content] of Object.entries(kit.files ?? {})) {
|
|
1627
|
+
const fp = join(out_dir, rel);
|
|
1628
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
1629
|
+
writeFileSync(fp, content);
|
|
1630
|
+
wrote.push(fp);
|
|
1631
|
+
}
|
|
1632
|
+
for (const [rel, b64] of Object.entries(kit.binaries ?? {})) {
|
|
1633
|
+
const fp = join(out_dir, rel);
|
|
1634
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
1635
|
+
writeFileSync(fp, Buffer.from(b64, 'base64'));
|
|
1636
|
+
wrote.push(fp);
|
|
1637
|
+
}
|
|
1638
|
+
mkdirSync(join(out_dir, 'assets'), { recursive: true });
|
|
1639
|
+
const gp = join(out_dir, 'assets', 'dust2.glb');
|
|
1640
|
+
writeFileSync(gp, buf);
|
|
1641
|
+
wrote.push(gp);
|
|
1642
|
+
}
|
|
1643
|
+
else {
|
|
1644
|
+
const gp = join(out_dir, 'dust2-map.glb');
|
|
1645
|
+
writeFileSync(gp, buf);
|
|
1646
|
+
wrote.push(gp);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return { content: [{ type: 'text', text: JSON.stringify({ bytes: buf.length, wrote, run: godot_project && out_dir ? `godot --path ${out_dir} --import --headless && godot --path ${out_dir}` : undefined, meshes: res.headers.get('x-gripforge-meshes') }, null, 2) }] };
|
|
1650
|
+
});
|
|
1651
|
+
server.tool('gripforge_map_look', 'Reproduce a Dust II still without copying pixels: camera + volume/prop/wire ids. shot=shot21a (A), shot1a (T), shot5a (cat). LINK only, never Meshy image-to-3d. Pair map_plan. 0 credits.', {
|
|
1652
|
+
prompt: z.string().min(2).max(240).describe('e.g. dust2'),
|
|
1653
|
+
shot: z.string().min(2).max(80).optional().describe('shot21a | a-site | shot1a | catwalk'),
|
|
1654
|
+
}, async ({ prompt, shot }) => {
|
|
1655
|
+
if (!API_KEY)
|
|
1656
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1657
|
+
const qs = new URLSearchParams({ prompt });
|
|
1658
|
+
if (shot)
|
|
1659
|
+
qs.set('shot', shot);
|
|
1660
|
+
const res = await fetch(`${API_URL}/api/v1/map-look?${qs}`, { headers: apiHeaders() });
|
|
1661
|
+
const data = await res.json().catch(() => ({}));
|
|
1662
|
+
if (!res.ok)
|
|
1663
|
+
return err(String(data.error ?? res.status));
|
|
1664
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1665
|
+
});
|
|
1666
|
+
server.tool('gripforge_kit', 'Read and compose reusable game engines. list/get return manifests and GameRules; create/patch persist rules and asset slots in the authenticated workspace. FPS rules are shared by Godot and web. Existing vehicle kits remain supported.', { action: z.enum(['list', 'get', 'create', 'patch']), id: z.string().max(80).optional(), style: z.string().max(64).optional(), name: z.string().max(160).optional(), rules: z.record(z.string(), z.unknown()).nullable().optional(), slots: z.array(z.object({ id: z.string().max(40), itemId: z.string().max(64).nullable() })).optional(), prompts: z.record(z.string(), z.string().max(4000)).optional() }, async (args) => {
|
|
1667
|
+
if (!API_KEY)
|
|
1668
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1669
|
+
if ((args.action === 'get' || args.action === 'patch') && !args.id)
|
|
1670
|
+
return err('id required');
|
|
1671
|
+
const endpoint = args.action === 'list' ? '/api/v1/library?kind=kit&limit=100' : args.action === 'create' ? '/api/v1/library/kit' : `/api/v1/library/${encodeURIComponent(args.id)}/kit`;
|
|
1672
|
+
const method = args.action === 'create' ? 'POST' : args.action === 'patch' ? 'PATCH' : 'GET';
|
|
1673
|
+
const body = args.action === 'create' ? { style: args.style ?? 'custom', name: args.name, rules: args.rules, slots: args.slots, prompts: args.prompts } : args;
|
|
1674
|
+
const response = await fetch(API_URL + endpoint, { method, headers: { ...apiHeaders(), 'content-type': 'application/json' }, ...(method !== 'GET' ? { body: JSON.stringify(body) } : {}) });
|
|
1675
|
+
const data = await response.json();
|
|
1676
|
+
if (!response.ok)
|
|
1677
|
+
return err(String(data.error ?? response.status));
|
|
1678
|
+
const result = args.action === 'list' ? { kits: (data.items ?? []).map((o) => ({ id: o.id, name: o.name, style: o.meta?.style, engine: o.meta?.engine ?? null, rules: o.meta?.rulesSummary ?? null, slotsFilled: o.meta?.slotsFilled, slotsTotal: o.meta?.slotsTotal })) } : data;
|
|
1679
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
1680
|
+
});
|
|
1681
|
+
server.tool('gripforge_fps_kit', 'Reusable tactical FPS engine for web and Godot. Get resolved rules/map, validate asset dependencies or export runtime files. Same weapon, animation, grenade, bot and bomb rules in both targets. Offline bots; no network claim. Persist edits with gripforge_kit. 0 credits.', { action: z.enum(['get', 'validate', 'export']).default('get'), target: z.enum(['web', 'godot', 'both']).default('both'), kit_id: z.string().optional(), rules: z.record(z.string(), z.unknown()).optional(), map: z.record(z.string(), z.unknown()).optional(), asset_slots: z.array(z.string()).optional() }, async (args) => {
|
|
1682
|
+
if (!API_KEY)
|
|
1683
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1684
|
+
const res = await fetch(`${API_URL}/api/v1/fps-kit`, { method: 'POST', headers: { ...apiHeaders(), 'content-type': 'application/json' }, body: JSON.stringify(args) });
|
|
1685
|
+
const data = await res.json();
|
|
1686
|
+
if (!res.ok)
|
|
1687
|
+
return err(String(data.error ?? res.status));
|
|
1688
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1689
|
+
});
|
|
1690
|
+
server.tool('gripforge_viewmodel_kit', 'CS-style FPS viewmodel: arms + gun under Camera3D/Hold. Write viewmodel.json + gf_viewmodel.gd. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. fps or counter-strike dust2') }, async ({ prompt }) => {
|
|
1691
|
+
if (!API_KEY)
|
|
1692
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1693
|
+
const res = await fetch(`${API_URL}/api/v1/viewmodel-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1694
|
+
const data = await res.json().catch(() => ({}));
|
|
1695
|
+
if (!res.ok)
|
|
1696
|
+
return err(String(data.error ?? res.status));
|
|
1697
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1698
|
+
});
|
|
1699
|
+
server.tool('gripforge_screen_concept', 'Rebuild a UI screen from a concept image: vision pass returns every button/panel/label with a normalised rect, colours and action; gf_screen.gd renders it as real Godot controls at any resolution. Saved in the Library (kind concept, category ui). 1 credit.', {
|
|
1700
|
+
image_path: z.string().optional().describe('local PNG/JPG of the mockup'),
|
|
1701
|
+
image_id: z.string().optional().describe('or a Library id (lib_...)'),
|
|
1702
|
+
name: z.string().optional().describe('slug for the screen (main_menu, lobby, hud)'),
|
|
1703
|
+
out_dir: z.string().optional().describe('write screen json + gf_screen.gd here'),
|
|
1704
|
+
}, async ({ image_path, image_id, name, out_dir }) => {
|
|
1705
|
+
if (!API_KEY)
|
|
1706
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1707
|
+
const payload = {};
|
|
1708
|
+
if (image_path)
|
|
1709
|
+
payload.image_base64 = (await readFile(image_path)).toString('base64');
|
|
1710
|
+
else if (image_id)
|
|
1711
|
+
payload.image = image_id;
|
|
1712
|
+
else
|
|
1713
|
+
return err('image_path or image_id required');
|
|
1714
|
+
if (name)
|
|
1715
|
+
payload.name = name;
|
|
1716
|
+
const res = await fetch(`${API_URL}/api/v1/screen-concept`, {
|
|
1717
|
+
method: 'POST',
|
|
1718
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1719
|
+
body: JSON.stringify(payload),
|
|
1720
|
+
});
|
|
1721
|
+
const data = (await res.json().catch(() => ({})));
|
|
1722
|
+
if (!res.ok)
|
|
1723
|
+
return err(String(data.error ?? res.status));
|
|
1724
|
+
const wrote = [];
|
|
1725
|
+
if (out_dir) {
|
|
1726
|
+
for (const [rel, content] of Object.entries(data.files ?? {})) {
|
|
1727
|
+
const fp = join(out_dir, rel);
|
|
1728
|
+
await mkdir(dirname(fp), { recursive: true });
|
|
1729
|
+
await writeFile(fp, content);
|
|
1730
|
+
wrote.push(fp);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, files: undefined, wrote }, null, 2) }] };
|
|
1734
|
+
});
|
|
1735
|
+
server.tool('gripforge_screen_assets', 'Cut the sprites of an analysed screen out of its own concept image (buttons, panels, icons, background) with 9-slice margins, and write them next to the layout. Pixel-identical to the concept, 0 credits.', {
|
|
1736
|
+
item: z.string().describe('Library id of a screen analysed by gripforge_screen_concept'),
|
|
1737
|
+
out_dir: z.string().describe('project root: files land under gripforge/screens/<id>/'),
|
|
1738
|
+
}, async ({ item, out_dir }) => {
|
|
1739
|
+
if (!API_KEY)
|
|
1740
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1741
|
+
const res = await fetch(`${API_URL}/api/v1/screen-assets`, {
|
|
1742
|
+
method: 'POST',
|
|
1743
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1744
|
+
body: JSON.stringify({ item }),
|
|
1745
|
+
});
|
|
1746
|
+
const data = (await res.json().catch(() => ({})));
|
|
1747
|
+
if (!res.ok)
|
|
1748
|
+
return err(String(data.error ?? res.status));
|
|
1749
|
+
const wrote = [];
|
|
1750
|
+
for (const [rel, b64] of Object.entries(data.files ?? {})) {
|
|
1751
|
+
const fp = join(out_dir, rel);
|
|
1752
|
+
await mkdir(dirname(fp), { recursive: true });
|
|
1753
|
+
await writeFile(fp, Buffer.from(b64, 'base64'));
|
|
1754
|
+
wrote.push(fp);
|
|
1755
|
+
}
|
|
1756
|
+
if (data.layout?.id) {
|
|
1757
|
+
const lp = join(out_dir, `gripforge/screens/${data.layout.id}.json`);
|
|
1758
|
+
await mkdir(dirname(lp), { recursive: true });
|
|
1759
|
+
await writeFile(lp, JSON.stringify(data.layout, null, 2));
|
|
1760
|
+
wrote.push(lp);
|
|
1761
|
+
}
|
|
1762
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, files: undefined, layout: undefined, wrote: wrote.length, dir: wrote[0] }, null, 2) }] };
|
|
1763
|
+
});
|
|
1764
|
+
server.tool('gripforge_screen_icons', 'Redraw the icons of an analysed screen as clean TRANSPARENT PNGs (crops carry the panel behind them and cannot be reused). Uses each crop as the reference so the symbol stays the same. 1 credit per 4 icons.', {
|
|
1765
|
+
item: z.string().describe('Library id of an analysed screen'),
|
|
1766
|
+
ids: z.array(z.string()).optional().describe('specific element ids, default every icon'),
|
|
1767
|
+
style: z.string().optional().describe('art direction, e.g. "crisp flat white symbol"'),
|
|
1768
|
+
out_dir: z.string().describe('project root: icons land under gripforge/screens/<id>/icons/'),
|
|
1769
|
+
}, async ({ item, ids, style, out_dir }) => {
|
|
1770
|
+
if (!API_KEY)
|
|
1771
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1772
|
+
const res = await fetch(`${API_URL}/api/v1/screen-icons`, {
|
|
1773
|
+
method: 'POST',
|
|
1774
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1775
|
+
body: JSON.stringify({ item, ids, style }),
|
|
1776
|
+
});
|
|
1777
|
+
const data = (await res.json().catch(() => ({})));
|
|
1778
|
+
if (!res.ok)
|
|
1779
|
+
return err(String(data.error ?? res.status));
|
|
1780
|
+
const wrote = [];
|
|
1781
|
+
for (const [rel, b64] of Object.entries(data.files ?? {})) {
|
|
1782
|
+
const fp = join(out_dir, rel);
|
|
1783
|
+
await mkdir(dirname(fp), { recursive: true });
|
|
1784
|
+
await writeFile(fp, Buffer.from(b64, 'base64'));
|
|
1785
|
+
wrote.push(fp);
|
|
1786
|
+
}
|
|
1787
|
+
if (data.layout?.id) {
|
|
1788
|
+
const lp = join(out_dir, `gripforge/screens/${data.layout.id}.json`);
|
|
1789
|
+
await writeFile(lp, JSON.stringify(data.layout, null, 2));
|
|
1790
|
+
wrote.push(lp);
|
|
1791
|
+
}
|
|
1792
|
+
return { content: [{ type: 'text', text: JSON.stringify({ icons: data.icons, wrote: wrote.length }, null, 2) }] };
|
|
1793
|
+
});
|
|
1794
|
+
server.tool('gripforge_screen_background', 'Clean backdrop for an analysed screen: inpaints the interface away (mask built from the layout rects) so the art no longer carries buttons and text baked in. 1 credit.', {
|
|
1795
|
+
item: z.string().describe('Library id of an analysed screen'),
|
|
1796
|
+
keep: z.array(z.string()).optional().describe('element ids to leave in the art'),
|
|
1797
|
+
prompt: z.string().optional().describe('extra art direction'),
|
|
1798
|
+
out_dir: z.string().describe('project root'),
|
|
1799
|
+
}, async ({ item, keep, prompt, out_dir }) => {
|
|
1800
|
+
if (!API_KEY)
|
|
1801
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1802
|
+
const res = await fetch(`${API_URL}/api/v1/screen-background`, {
|
|
1803
|
+
method: 'POST',
|
|
1804
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
1805
|
+
body: JSON.stringify({ item, keep, prompt }),
|
|
1806
|
+
});
|
|
1807
|
+
const data = (await res.json().catch(() => ({})));
|
|
1808
|
+
if (!res.ok)
|
|
1809
|
+
return err(String(data.error ?? res.status));
|
|
1810
|
+
const wrote = [];
|
|
1811
|
+
for (const [rel, b64] of Object.entries(data.files ?? {})) {
|
|
1812
|
+
const fp = join(out_dir, rel);
|
|
1813
|
+
await mkdir(dirname(fp), { recursive: true });
|
|
1814
|
+
await writeFile(fp, Buffer.from(b64, 'base64'));
|
|
1815
|
+
wrote.push(fp);
|
|
1816
|
+
}
|
|
1817
|
+
if (data.layout?.id) {
|
|
1818
|
+
const lp = join(out_dir, `gripforge/screens/${data.layout.id}.json`);
|
|
1819
|
+
await writeFile(lp, JSON.stringify(data.layout, null, 2));
|
|
1820
|
+
wrote.push(lp);
|
|
1821
|
+
}
|
|
1822
|
+
return { content: [{ type: 'text', text: JSON.stringify({ erased: data.erased, wrote }, null, 2) }] };
|
|
1823
|
+
});
|
|
1824
|
+
server.tool('gripforge_menu_kit', 'Front-end for a multiplayer FPS: main menu (host/join/quit), lobby listing connected peers where only the host starts, and an in-game HUD (health, round timer, score, weapon, kill feed) fed by replicated state. Pairs with gripforge_net_kit. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike, minimal') }, async ({ prompt }) => {
|
|
1825
|
+
if (!API_KEY)
|
|
1826
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1827
|
+
const res = await fetch(`${API_URL}/api/v1/menu-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1828
|
+
const data = await res.json().catch(() => ({}));
|
|
1829
|
+
if (!res.ok)
|
|
1830
|
+
return err(String(data.error ?? res.status));
|
|
1831
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1832
|
+
});
|
|
1833
|
+
server.tool('gripforge_bot_kit', 'AI opponents for the Godot FPS: navmesh bots (patrol/hunt/shoot, reaction time + aim error, respawn) driven by one autoload; they reuse the player scene under server authority so spawner/synchronizer/net-kit damage apply unchanged. 0 credits.', { prompt: z.string().min(2).max(240).describe('difficulty: easy, normal, hard') }, async ({ prompt }) => {
|
|
1834
|
+
if (!API_KEY)
|
|
1835
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1836
|
+
const res = await fetch(`${API_URL}/api/v1/bot-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1837
|
+
const data = await res.json().catch(() => ({}));
|
|
1838
|
+
if (!res.ok)
|
|
1839
|
+
return err(String(data.error ?? res.status));
|
|
1840
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1841
|
+
});
|
|
1842
|
+
server.tool('gripforge_build', 'Build the Godot game and register the version in the workspace: smoke test (GF_SMOKE=1, script errors fail the build), headless export for the preset (macos | windows | linux | web; presets written into export_presets.cfg when missing), zip, upload as a Library "build" (kind kit, meta.tool=build: game, version, preset, commit, engine, size, smoke) and return the download URL. Needs Godot installed locally (GODOT_BIN or /Applications/Godot.app) and the export templates for the platform. 0 credits.', {
|
|
1843
|
+
project: z.string().describe('Godot project directory (contains project.godot)'),
|
|
1844
|
+
preset: z.enum(['macos', 'windows', 'linux', 'web']).default('macos'),
|
|
1845
|
+
version: z.string().optional().describe('e.g. 0.3.0 — default: project.godot config/version, else git describe, else date'),
|
|
1846
|
+
changelog: z.string().optional(),
|
|
1847
|
+
smoke: z.boolean().default(true).describe('run the GF_SMOKE=1 launch before exporting'),
|
|
1848
|
+
upload: z.boolean().default(true).describe('push the zip to the workspace'),
|
|
1849
|
+
}, async ({ project, preset, version, changelog, smoke, upload }) => {
|
|
1850
|
+
const fs = await import('node:fs/promises');
|
|
1851
|
+
const path = await import('node:path');
|
|
1852
|
+
const os = await import('node:os');
|
|
1853
|
+
const { execFile } = await import('node:child_process');
|
|
1854
|
+
const run = (cmd, args, opts = {}) => new Promise((resolve) => {
|
|
1855
|
+
const child = execFile(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...(opts.env ?? {}) }, maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 600000 }, (err, stdout, stderr) => {
|
|
1856
|
+
resolve({ code: err && typeof err.code === 'number' ? Number(err.code) : err ? 1 : 0, out: `${stdout}\n${stderr}` });
|
|
1857
|
+
});
|
|
1858
|
+
void child;
|
|
1859
|
+
});
|
|
1860
|
+
const notes = [];
|
|
1861
|
+
const proj = path.resolve(project);
|
|
1862
|
+
const pg = path.join(proj, 'project.godot');
|
|
1863
|
+
let pgText = '';
|
|
1864
|
+
try {
|
|
1865
|
+
pgText = await fs.readFile(pg, 'utf8');
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
return err(`no project.godot in ${proj}`);
|
|
1869
|
+
}
|
|
1870
|
+
const game = (pgText.match(/config\/name="([^"]+)"/)?.[1] ?? path.basename(proj)).trim();
|
|
1871
|
+
// godot binary
|
|
1872
|
+
let godot = process.env.GODOT_BIN ?? '';
|
|
1873
|
+
if (!godot) {
|
|
1874
|
+
for (const c of ['/Applications/Godot.app/Contents/MacOS/Godot', '/Applications/Godot_mono.app/Contents/MacOS/Godot', '/usr/local/bin/godot', '/usr/bin/godot']) {
|
|
1875
|
+
try {
|
|
1876
|
+
await fs.access(c);
|
|
1877
|
+
godot = c;
|
|
1878
|
+
break;
|
|
1879
|
+
}
|
|
1880
|
+
catch { /* next */ }
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if (!godot)
|
|
1884
|
+
return err('Godot not found: set GODOT_BIN');
|
|
1885
|
+
const ver = (await run(godot, ['--version'])).out.trim().split('\n').pop() ?? '';
|
|
1886
|
+
const verKey = ver.match(/^(\d+\.\d+(?:\.\d+)?)\.(\w+)/) ? `${ver.match(/^(\d+\.\d+(?:\.\d+)?)\.(\w+)/)[1]}.${ver.match(/^(\d+\.\d+(?:\.\d+)?)\.(\w+)/)[2]}` : ver;
|
|
1887
|
+
notes.push(`godot ${ver} (${godot})`);
|
|
1888
|
+
// export templates
|
|
1889
|
+
const tplDir = process.platform === 'darwin'
|
|
1890
|
+
? path.join(os.homedir(), 'Library/Application Support/Godot/export_templates', verKey)
|
|
1891
|
+
: path.join(os.homedir(), '.local/share/godot/export_templates', verKey);
|
|
1892
|
+
const need = { macos: ['macos.zip'], windows: ['windows_release_x86_64.exe'], linux: ['linux_release.x86_64'], web: ['web_release.zip', 'web_nothreads_release.zip'] };
|
|
1893
|
+
let have = [];
|
|
1894
|
+
try {
|
|
1895
|
+
have = await fs.readdir(tplDir);
|
|
1896
|
+
}
|
|
1897
|
+
catch { /* none */ }
|
|
1898
|
+
const ok = need[preset].some((f) => have.includes(f));
|
|
1899
|
+
if (!ok) {
|
|
1900
|
+
return err(`export templates for ${preset} missing in ${tplDir} (have: ${have.join(', ') || 'none'}). Install: Godot → Editor → Manage Export Templates → Download, or unzip Godot_v${verKey.replace('.stable', '-stable')}_export_templates.tpz (templates/*) into that folder.`);
|
|
1901
|
+
}
|
|
1902
|
+
// version
|
|
1903
|
+
let v = version ?? (pgText.match(/config\/version="([^"]+)"/)?.[1] ?? '');
|
|
1904
|
+
if (!v) {
|
|
1905
|
+
const g = await run('git', ['describe', '--tags', '--always'], { cwd: proj });
|
|
1906
|
+
v = g.code === 0 ? g.out.trim() : '';
|
|
1907
|
+
}
|
|
1908
|
+
if (!v)
|
|
1909
|
+
v = new Date().toISOString().slice(0, 10).replace(/-/g, '.');
|
|
1910
|
+
const commit = (await run('git', ['rev-parse', '--short', 'HEAD'], { cwd: proj })).out.trim() || null;
|
|
1911
|
+
// presets
|
|
1912
|
+
const presetName = `gf-${preset}`;
|
|
1913
|
+
const presetsPath = path.join(proj, 'export_presets.cfg');
|
|
1914
|
+
let presets = '';
|
|
1915
|
+
try {
|
|
1916
|
+
presets = await fs.readFile(presetsPath, 'utf8');
|
|
1917
|
+
}
|
|
1918
|
+
catch { /* new */ }
|
|
1919
|
+
const outDir = path.join(proj, 'builds', v, preset);
|
|
1920
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
1921
|
+
const outFile = { macos: `${game.replace(/[^\w.-]+/g, '_')}.zip`, windows: `${game.replace(/[^\w.-]+/g, '_')}.exe`, linux: `${game.replace(/[^\w.-]+/g, '_')}.x86_64`, web: 'index.html' }[preset];
|
|
1922
|
+
if (!presets.includes(`name="${presetName}"`)) {
|
|
1923
|
+
const idx = (presets.match(/\[preset\.\d+\]/g) ?? []).length;
|
|
1924
|
+
const platform = { macos: 'macOS', windows: 'Windows Desktop', linux: 'Linux', web: 'Web' }[preset];
|
|
1925
|
+
const useNoThreads = preset === 'web' && !have.includes('web_release.zip');
|
|
1926
|
+
const opts = preset === 'web'
|
|
1927
|
+
? `variant/thread_support=${useNoThreads ? 'false' : 'true'}\nhtml/export_icon=true\nprogressive_web_app/enabled=false\n`
|
|
1928
|
+
: preset === 'macos'
|
|
1929
|
+
? `application/bundle_identifier="ai.gripforge.${game.replace(/[^a-z0-9]+/gi, '').toLowerCase()}"\ncodesign/codesign=0\nnotarization/notarization=0\napplication/export_format=0\n`
|
|
1930
|
+
: preset === 'windows'
|
|
1931
|
+
? `binary_format/embed_pck=true\napplication/modify_resources=false\n`
|
|
1932
|
+
: `binary_format/embed_pck=true\n`;
|
|
1933
|
+
presets += `${presets.trim() ? '\n' : ''}[preset.${idx}]\n\nname="${presetName}"\nplatform="${platform}"\nruntime/platform="${platform}"\ncustom_features=""\nexport_filter="all_resources"\ninclude_filter=""\nexclude_filter="builds/*"\nexport_path="builds/${v}/${preset}/${outFile}"\npatches=PackedStringArray()\nencryption_include_filters=""\nencryption_exclude_filters=""\nseed=0\nencrypt_pck=false\nencrypt_directory=false\nscript_export_mode=2\n\n[preset.${idx}.options]\n\n${opts}`;
|
|
1934
|
+
await fs.writeFile(presetsPath, presets);
|
|
1935
|
+
notes.push(`export preset "${presetName}" written to export_presets.cfg`);
|
|
1936
|
+
}
|
|
1937
|
+
// smoke
|
|
1938
|
+
let smokeVerdict = 'skipped';
|
|
1939
|
+
if (smoke) {
|
|
1940
|
+
const s = await run(godot, ['--path', proj, '--resolution', '960x600', '--position', '40,40', '--always-on-top'], { env: { GF_SMOKE: '1' }, timeoutMs: 120000 });
|
|
1941
|
+
const errors = (s.out.match(/SCRIPT ERROR:[^\n]*/g) ?? []).slice(0, 5);
|
|
1942
|
+
const saved = /SMOKE saved/.test(s.out);
|
|
1943
|
+
smokeVerdict = errors.length ? `failed: ${errors.join(' | ')}` : saved ? 'ok' : `no screenshot (exit ${s.code})`;
|
|
1944
|
+
notes.push(`smoke: ${smokeVerdict}`);
|
|
1945
|
+
if (errors.length)
|
|
1946
|
+
return err(`smoke test failed, build refused — ${errors.join(' | ')}`);
|
|
1947
|
+
}
|
|
1948
|
+
// export
|
|
1949
|
+
const target = path.join(outDir, outFile);
|
|
1950
|
+
const ex = await run(godot, ['--headless', '--path', proj, '--export-release', presetName, target], { timeoutMs: 900000 });
|
|
1951
|
+
const exErrors = (ex.out.match(/ERROR:[^\n]*/g) ?? []).filter((l) => !/nav_mesh|navigation|Image format/i.test(l)).slice(0, 5);
|
|
1952
|
+
let exists = false;
|
|
1953
|
+
try {
|
|
1954
|
+
await fs.access(target);
|
|
1955
|
+
exists = true;
|
|
1956
|
+
}
|
|
1957
|
+
catch { /* missing */ }
|
|
1958
|
+
if (!exists)
|
|
1959
|
+
return err(`export failed (exit ${ex.code}): ${exErrors.join(' | ') || ex.out.slice(-600)}`);
|
|
1960
|
+
notes.push(`exported ${presetName} → ${target}${exErrors.length ? ` (warnings: ${exErrors.length})` : ''}`);
|
|
1961
|
+
// zip
|
|
1962
|
+
const zipName = `${game.replace(/[^\w.-]+/g, '_')}-${v}-${preset}.zip`;
|
|
1963
|
+
const zipPath = path.join(proj, 'builds', zipName);
|
|
1964
|
+
try {
|
|
1965
|
+
await fs.unlink(zipPath);
|
|
1966
|
+
}
|
|
1967
|
+
catch { /* fresh */ }
|
|
1968
|
+
const z = await run('zip', ['-qr', zipPath, '.'], { cwd: outDir, timeoutMs: 600000 });
|
|
1969
|
+
if (z.code !== 0)
|
|
1970
|
+
return err(`zip failed: ${z.out.slice(-300)}`);
|
|
1971
|
+
const bytes = (await fs.stat(zipPath)).size;
|
|
1972
|
+
notes.push(`zip ${zipName} (${(bytes / 1048576).toFixed(1)} MB)`);
|
|
1973
|
+
// upload
|
|
1974
|
+
let item = null;
|
|
1975
|
+
if (upload) {
|
|
1976
|
+
if (!API_KEY)
|
|
1977
|
+
return err('GRIPFORGE_API_KEY missing (build kept locally at ' + zipPath + ')');
|
|
1978
|
+
const form = new FormData();
|
|
1979
|
+
form.append('file', new Blob([await fs.readFile(zipPath)]), zipName);
|
|
1980
|
+
form.append('kind', 'kit');
|
|
1981
|
+
form.append('name', `${game} v${v} · ${preset}`);
|
|
1982
|
+
form.append('meta', JSON.stringify({ tool: 'build', game, version: v, preset, platform: preset, commit, engine: ver, bytes, smoke: smokeVerdict, changelog: changelog ?? null, built_at: new Date().toISOString(), tags: ['build', preset] }));
|
|
1983
|
+
const res = await fetch(`${API_URL}/api/v1/library`, { method: 'POST', headers: apiHeaders(), body: form });
|
|
1984
|
+
const data = (await res.json().catch(() => ({})));
|
|
1985
|
+
if (!res.ok)
|
|
1986
|
+
return err(`upload failed: ${String(data.error ?? res.status)} (build kept locally at ${zipPath})`);
|
|
1987
|
+
item = data.item ?? data;
|
|
1988
|
+
notes.push('registered in the workspace');
|
|
1989
|
+
}
|
|
1990
|
+
return { content: [{ type: 'text', text: JSON.stringify({ game, version: v, preset, commit, engine: ver, smoke: smokeVerdict, zip: zipPath, bytes, item, notes }, null, 2) }] };
|
|
1991
|
+
});
|
|
1992
|
+
server.tool('gripforge_gunfx_kit', 'Gun feedback for the Godot FPS: muzzle flash, tracer, impact sparks + bullet-hole decal, blood spray (+ wall splat). One procedural autoload GfGunfx (no textures) fired by the net kit through a shot_fx RPC after the server raycast, misses included. Presets counter-strike / arcade / subtle. 0 credits.', {
|
|
1993
|
+
prompt: z.string().min(2).max(240).describe('e.g. counter-strike, arcade, subtle tactical'),
|
|
1994
|
+
out_dir: z.string().optional().describe('Godot project root: writes scripts/gf_gunfx.gd, gripforge/gunfx.json and the CC0 sprites into gripforge/gunfx/ (+ LICENSES.txt)'),
|
|
1995
|
+
}, async ({ prompt, out_dir }) => {
|
|
1996
|
+
if (!API_KEY)
|
|
1997
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
1998
|
+
const res = await fetch(`${API_URL}/api/v1/gunfx-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
1999
|
+
const data = (await res.json().catch(() => ({})));
|
|
2000
|
+
if (!res.ok)
|
|
2001
|
+
return err(String(data.error ?? res.status));
|
|
2002
|
+
const wrote = [];
|
|
2003
|
+
if (out_dir) {
|
|
2004
|
+
const fs = await import('node:fs/promises');
|
|
2005
|
+
const path = await import('node:path');
|
|
2006
|
+
await fs.mkdir(path.join(out_dir, 'scripts'), { recursive: true });
|
|
2007
|
+
await fs.mkdir(path.join(out_dir, 'gripforge', 'gunfx'), { recursive: true });
|
|
2008
|
+
await fs.writeFile(path.join(out_dir, 'scripts', 'gf_gunfx.gd'), data.snippet_godot ?? '');
|
|
2009
|
+
await fs.writeFile(path.join(out_dir, 'gripforge', 'gunfx.json'), data.json ?? '{}');
|
|
2010
|
+
wrote.push('scripts/gf_gunfx.gd', 'gripforge/gunfx.json');
|
|
2011
|
+
for (const sp of data.sprites ?? []) {
|
|
2012
|
+
const r = await fetch(sp.url);
|
|
2013
|
+
if (!r.ok)
|
|
2014
|
+
continue;
|
|
2015
|
+
await fs.writeFile(path.join(out_dir, 'gripforge', 'gunfx', `${sp.name}.png`), Buffer.from(await r.arrayBuffer()));
|
|
2016
|
+
wrote.push(`gripforge/gunfx/${sp.name}.png`);
|
|
2017
|
+
}
|
|
2018
|
+
if (data.licenses_url) {
|
|
2019
|
+
const r = await fetch(data.licenses_url);
|
|
2020
|
+
if (r.ok) {
|
|
2021
|
+
await fs.writeFile(path.join(out_dir, 'gripforge', 'gunfx', 'LICENSES.txt'), await r.text());
|
|
2022
|
+
wrote.push('gripforge/gunfx/LICENSES.txt');
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, snippet_godot: out_dir ? '(written)' : data.snippet_godot, wrote }, null, 2) }] };
|
|
2027
|
+
});
|
|
2028
|
+
server.tool('gripforge_match_kit', 'Listen-server match contract: party size, teams, maps, ranked flag, join-by-IP. Write match.json + autoload GfMatch on top of GfNet. GripForge does not host a queue. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike, deathmatch, ranked 5v5') }, async ({ prompt }) => {
|
|
2029
|
+
if (!API_KEY)
|
|
2030
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2031
|
+
const res = await fetch(`${API_URL}/api/v1/match-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
2032
|
+
const data = await res.json().catch(() => ({}));
|
|
2033
|
+
if (!res.ok)
|
|
2034
|
+
return err(String(data.error ?? res.status));
|
|
2035
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2036
|
+
});
|
|
2037
|
+
server.tool('gripforge_net_kit', 'Godot 4 multiplayer (ENet): GfNet autoload host/join/spawn with server-side hit validation, gf_round.gd for the round + bomb, MultiplayerSpawner/Synchronizer wiring. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike, deathmatch, fps') }, async ({ prompt }) => {
|
|
2038
|
+
if (!API_KEY)
|
|
2039
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2040
|
+
const res = await fetch(`${API_URL}/api/v1/net-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
2041
|
+
const data = await res.json().catch(() => ({}));
|
|
2042
|
+
if (!res.ok)
|
|
2043
|
+
return err(String(data.error ?? res.status));
|
|
2044
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2045
|
+
});
|
|
2046
|
+
server.tool('gripforge_input_kit', 'InputMap presets: fps (WASD + arrows on move_*) or kart (accelerate/brake/steer + standard gamepad RT/LT/stick, same indices as the web Gamepad API). Write gripforge/input.json, autoload GfInput. Optional third_person=true adds portable character-facing JavaScript and a Three.js adapter; orientation only, not a physics controller. 0 credits.', {
|
|
2047
|
+
prompt: z.string().min(2).max(240).describe('e.g. fps or counter-strike dust2'),
|
|
2048
|
+
third_person: z.boolean().optional().describe('Include character-facing JavaScript and Three.js usage (default false; existing input map unchanged).'),
|
|
2049
|
+
}, async ({ prompt, third_person }) => {
|
|
2050
|
+
if (!API_KEY)
|
|
2051
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2052
|
+
const qs = new URLSearchParams({ prompt });
|
|
2053
|
+
if (third_person)
|
|
2054
|
+
qs.set('third_person', '1');
|
|
2055
|
+
const res = await fetch(`${API_URL}/api/v1/input-kit?${qs}`, { headers: apiHeaders() });
|
|
2056
|
+
const data = await res.json().catch(() => ({}));
|
|
2057
|
+
if (!res.ok)
|
|
2058
|
+
return err(String(data.error ?? res.status));
|
|
2059
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2060
|
+
});
|
|
2061
|
+
server.tool('gripforge_light_kit', 'Moon / sun / candle lights + fog + Three.js snippet for a game look. 0 credits. Pair with scene_kit.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
|
|
2062
|
+
if (!API_KEY)
|
|
2063
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2064
|
+
const res = await fetch(`${API_URL}/api/v1/light-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
2065
|
+
const data = await res.json().catch(() => ({}));
|
|
2066
|
+
if (!res.ok)
|
|
2067
|
+
return err(String(data.error ?? res.status));
|
|
2068
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2069
|
+
});
|
|
2070
|
+
server.tool('gripforge_font', 'Google-font CSS + exact HUD copy (DMC: Cinzel D–SSS, kart: Teko, CS: Barlow Condensed). Put letters in HTML — do not rasterize. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
|
|
2071
|
+
if (!API_KEY)
|
|
2072
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2073
|
+
const res = await fetch(`${API_URL}/api/v1/font-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
2074
|
+
const data = await res.json().catch(() => ({}));
|
|
2075
|
+
if (!res.ok)
|
|
2076
|
+
return err(String(data.error ?? res.status));
|
|
2077
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2078
|
+
});
|
|
2079
|
+
server.tool('gripforge_loading_page', 'Loading overlay for Three/Godot/Unity/Unreal. Title is text. Overlay 0 credits. Pass character_id to generate a 16:9 cinematic still of that Library character (1 credit, no letters in the image).', {
|
|
2080
|
+
prompt: z.string().min(2).max(240).describe('e.g. devil may cry like'),
|
|
2081
|
+
character_id: z.string().optional().describe('lib_… character/enemy/bind — generate background still'),
|
|
2082
|
+
}, async ({ prompt, character_id }) => {
|
|
2083
|
+
if (!API_KEY)
|
|
2084
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2085
|
+
if (character_id) {
|
|
2086
|
+
const res = await fetch(`${API_URL}/api/v1/loading-page`, {
|
|
2087
|
+
method: 'POST',
|
|
2088
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
2089
|
+
body: JSON.stringify({ prompt, character_id }),
|
|
2090
|
+
});
|
|
2091
|
+
const data = await res.json().catch(() => ({}));
|
|
2092
|
+
if (!res.ok)
|
|
2093
|
+
return err(String(data.error ?? res.status));
|
|
2094
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2095
|
+
}
|
|
2096
|
+
const res = await fetch(`${API_URL}/api/v1/loading-page?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
2097
|
+
const data = await res.json().catch(() => ({}));
|
|
2098
|
+
if (!res.ok)
|
|
2099
|
+
return err(String(data.error ?? res.status));
|
|
2100
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2101
|
+
});
|
|
2102
|
+
server.tool('gripforge_navmesh', 'Walkable XZ AABB + pillar holes for a scene_kit room. JSON clamp, not a baked navmesh. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
|
|
2103
|
+
if (!API_KEY)
|
|
2104
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2105
|
+
const res = await fetch(`${API_URL}/api/v1/navmesh?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
2106
|
+
const data = await res.json().catch(() => ({}));
|
|
2107
|
+
if (!res.ok)
|
|
2108
|
+
return err(String(data.error ?? res.status));
|
|
2109
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2110
|
+
});
|
|
2111
|
+
server.tool('gripforge_vfx_generate', 'ADMIN ONLY. Text-to-VFX or image-to-VFX: interpret a prompt or reference into an editable animated effect specification. No Library save or credit charge. Pass the resulting type, style, look, color, duration, width, name, emitters and generation to gripforge_vfx to save/export (1 credit). Image mode requires a configured vision provider.', { preset: z.enum(['slash-trail', 'slash-emerald', 'slash-steel', 'slash-fire', 'slash-ice', 'slash-nature', 'arc-pulse', 'fire-breath', 'neon-impact']).optional().describe('Load a ready-to-play preset instantly, without AI. Supply this field alone.'),
|
|
2112
|
+
prompt: z.string().max(500).optional().describe('Effect description; optional with an image.'),
|
|
2113
|
+
visual_style: z.enum(['realistic', 'stylized', 'anime', 'lowpoly']).optional(),
|
|
2114
|
+
image_data: z.string().max(12 * 1024 * 1024).optional().describe('PNG/JPEG/WebP base64 data URI, up to 8 MB decoded.'),
|
|
2115
|
+
image_id: z.string().optional().describe('Owned Library image id; use instead of image_data.'), }, async (args) => {
|
|
2116
|
+
if (!API_KEY)
|
|
2117
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2118
|
+
const res = await fetch(`${API_URL}/api/v1/vfx/prompt`, {
|
|
2119
|
+
method: 'POST', headers: { ...apiHeaders(), 'content-type': 'application/json' }, body: JSON.stringify(args), signal: AbortSignal.timeout(55_000),
|
|
2120
|
+
});
|
|
2121
|
+
const data = await res.json().catch(() => ({}));
|
|
2122
|
+
if (!res.ok)
|
|
2123
|
+
return err(String(data.error ?? res.status));
|
|
2124
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2125
|
+
});
|
|
2126
|
+
server.tool('gripforge_vfx', 'ADMIN ONLY. Generate + export 3D slash/thrust/spin VFX on an existing Library bind, or a world-space fire meteor (head + trail + impact). Pass prompt to fill type/style/color/emitters. Returns studio_url to edit the saved effect in GripForge, workspace_id, GLB, PNG, vfx.json + snippets. Refused for non-admin keys.', {
|
|
2127
|
+
name: z.string().max(80).optional(),
|
|
2128
|
+
emitters: z.array(z.record(z.string(), z.unknown())).max(6).optional().describe('Emitters returned by gripforge_vfx_generate.'),
|
|
2129
|
+
generation: z.record(z.string(), z.unknown()).optional().describe('Generation provenance returned by gripforge_vfx_generate.'),
|
|
2130
|
+
attach_id: z.string().optional().describe('Library bind id (lib_…). Not required for meteor.'),
|
|
2131
|
+
prompt: z.string().optional().describe('Natural-language VFX prompt. Fills type/style/color/emitters when omitted.'),
|
|
2132
|
+
type: z.enum(['slash', 'thrust', 'spin', 'meteor', 'beam', 'swing', 'claw', 'charge', 'shield', 'portal', 'slam']).optional().describe('Strike type (default slash). portal = standing blood rift / demonic seal (world-space, loops); slam = ground-impact eruption: light pillar + fireball + sparks/debris (world-space, one-shot)'),
|
|
2133
|
+
style: z.enum(['energy', 'metal', 'magic']).optional().describe('Look (default energy)'),
|
|
2134
|
+
look: z.enum(['toon', 'solid', 'crystal', 'flame', 'pulse', 'firejet', 'impact']).optional().describe('toon: stylized ribbon/lightning; solid: filled beam; crystal: faceted weapon trail; flame: burning crescent; pulse: released electric orb; firejet: flared fire stream with torn tongues and pressure rings; impact: slam-family comic explosion with red telegraph, violet core, ink debris and optional impact frames'),
|
|
2135
|
+
color: z.string().optional().describe('Hex color'),
|
|
2136
|
+
duration: z.number().optional().describe('Seconds (default 0.25)'),
|
|
2137
|
+
width: z.number().optional().describe('Ribbon width in metres'),
|
|
2138
|
+
core: z.number().min(0.02).max(0.4).optional().describe('portal only: shadow-core radius as a fraction of the portal (default 0.09)'),
|
|
2139
|
+
out_dir: z.string().optional().describe('Write vfx.glb, additive.png, vfx.json here'),
|
|
2140
|
+
}, async ({ name, emitters, generation, attach_id, prompt, type, style, look, color, duration, width, core, out_dir }) => {
|
|
2141
|
+
if (!API_KEY)
|
|
2142
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2143
|
+
const res = await fetch(`${API_URL}/api/v1/vfx`, {
|
|
2144
|
+
method: 'POST',
|
|
2145
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'content-type': 'application/json' },
|
|
2146
|
+
body: JSON.stringify({
|
|
2147
|
+
name, emitters, generation,
|
|
2148
|
+
attach_id,
|
|
2149
|
+
prompt,
|
|
2150
|
+
type,
|
|
2151
|
+
style,
|
|
2152
|
+
look,
|
|
2153
|
+
color,
|
|
2154
|
+
duration,
|
|
2155
|
+
width,
|
|
2156
|
+
core,
|
|
2157
|
+
}),
|
|
2158
|
+
});
|
|
2159
|
+
const data = (await res.json().catch(() => ({})));
|
|
2160
|
+
if (res.status === 403)
|
|
2161
|
+
return err('admin only — gripforge_vfx is not available on this key.');
|
|
2162
|
+
if (!res.ok)
|
|
2163
|
+
return err(String(data.error ?? res.status));
|
|
2164
|
+
const wrote = [];
|
|
2165
|
+
if (out_dir) {
|
|
2166
|
+
const dir = resolve(out_dir);
|
|
2167
|
+
await mkdir(dir, { recursive: true });
|
|
2168
|
+
if (data.urls?.glb) {
|
|
2169
|
+
const bin = Buffer.from(await (await fetch(data.urls.glb)).arrayBuffer());
|
|
2170
|
+
await writeFile(join(dir, 'vfx.glb'), bin);
|
|
2171
|
+
wrote.push(join(dir, 'vfx.glb'));
|
|
2172
|
+
}
|
|
2173
|
+
if (data.urls?.textures?.additive) {
|
|
2174
|
+
const bin = Buffer.from(await (await fetch(data.urls.textures.additive)).arrayBuffer());
|
|
2175
|
+
await writeFile(join(dir, 'additive.png'), bin);
|
|
2176
|
+
wrote.push(join(dir, 'additive.png'));
|
|
2177
|
+
}
|
|
2178
|
+
if (data.snippets?.json) {
|
|
2179
|
+
await writeFile(join(dir, 'vfx.json'), data.snippets.json);
|
|
2180
|
+
wrote.push(join(dir, 'vfx.json'));
|
|
2181
|
+
}
|
|
2182
|
+
const snip = data.snippets;
|
|
2183
|
+
if (snip?.three) {
|
|
2184
|
+
await writeFile(join(dir, 'three.js.txt'), snip.three);
|
|
2185
|
+
wrote.push(join(dir, 'three.js.txt'));
|
|
2186
|
+
}
|
|
2187
|
+
if (snip?.unity) {
|
|
2188
|
+
await writeFile(join(dir, 'unity.cs.txt'), snip.unity);
|
|
2189
|
+
wrote.push(join(dir, 'unity.cs.txt'));
|
|
2190
|
+
}
|
|
2191
|
+
if (snip?.godot) {
|
|
2192
|
+
await writeFile(join(dir, 'godot.gd.txt'), snip.godot);
|
|
2193
|
+
wrote.push(join(dir, 'godot.gd.txt'));
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return {
|
|
2197
|
+
content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }],
|
|
2198
|
+
};
|
|
2199
|
+
});
|
|
2200
|
+
server.tool('gripforge_vfx_preview', 'ADMIN ONLY. Evaluate the VFX timeline at time t (t=0 empty). No credits. Play/scrub pose.', {
|
|
2201
|
+
bind: z.record(z.string(), z.unknown()).describe('Attach bind.json object'),
|
|
2202
|
+
t: z.number().optional().describe('Seconds (default 0)'),
|
|
2203
|
+
type: z.enum(['slash', 'thrust', 'spin', 'meteor', 'beam', 'swing', 'claw', 'charge', 'shield']).optional(),
|
|
2204
|
+
duration: z.number().optional(),
|
|
2205
|
+
}, async ({ bind, t, type, duration }) => {
|
|
2206
|
+
if (!API_KEY)
|
|
2207
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2208
|
+
const res = await fetch(`${API_URL}/api/v1/vfx/preview`, {
|
|
2209
|
+
method: 'POST',
|
|
2210
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'content-type': 'application/json' },
|
|
2211
|
+
body: JSON.stringify({ bind, t: t ?? 0, type, duration }),
|
|
2212
|
+
});
|
|
2213
|
+
const data = (await res.json().catch(() => ({})));
|
|
2214
|
+
if (res.status === 403)
|
|
2215
|
+
return err('admin only — gripforge_vfx_preview is not available on this key.');
|
|
2216
|
+
if (!res.ok)
|
|
2217
|
+
return err(String(data.error ?? res.status));
|
|
2218
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2219
|
+
});
|
|
2220
|
+
server.tool('gripforge_shaders', 'List shaders from the GripForge catalog (authorized GodotShaders.com mirror). ' +
|
|
2221
|
+
'Returns { id, name, engines }. Search q=slash_reveal (alias) or a name. ' +
|
|
2222
|
+
'Then pull with gripforge_shader_pull. Listing is free.', {
|
|
2223
|
+
q: z.string().optional().describe('Search id/name/alias (e.g. slash_reveal)'),
|
|
2224
|
+
type: z
|
|
2225
|
+
.enum(['canvas_item', 'spatial', 'sky', 'particles', 'fog'])
|
|
2226
|
+
.optional()
|
|
2227
|
+
.describe('Godot shader_type filter'),
|
|
2228
|
+
limit: z.number().optional().describe('Page size (default 50, max 200)'),
|
|
2229
|
+
offset: z.number().optional().describe('Offset (default 0)'),
|
|
2230
|
+
}, async ({ q, type, limit, offset }) => {
|
|
2231
|
+
if (!API_KEY)
|
|
2232
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2233
|
+
const qs = new URLSearchParams();
|
|
2234
|
+
if (q)
|
|
2235
|
+
qs.set('q', q);
|
|
2236
|
+
if (type)
|
|
2237
|
+
qs.set('type', type);
|
|
2238
|
+
if (limit != null)
|
|
2239
|
+
qs.set('limit', String(limit));
|
|
2240
|
+
if (offset != null)
|
|
2241
|
+
qs.set('offset', String(offset));
|
|
2242
|
+
const res = await fetch(`${API_URL}/api/v1/shaders?${qs}`, { headers: apiHeaders() });
|
|
2243
|
+
const data = await res.json().catch(() => ({}));
|
|
2244
|
+
if (!res.ok)
|
|
2245
|
+
return err(String(data.error ?? res.status));
|
|
2246
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2247
|
+
});
|
|
2248
|
+
server.tool('gripforge_shader_pull', 'Pull a catalog shader into the open repo and return an assignment snippet. ' +
|
|
2249
|
+
'id = slug or alias (slash_reveal). engine = godot | unity | three. ' +
|
|
2250
|
+
'Writes sources into out_dir. Godot → ShaderMaterial (SpatialMaterial/StandardMaterial3D cannot run custom code). ' +
|
|
2251
|
+
'Unity → ShaderLab Material. Three → ShaderMaterial. Example: "put slash_reveal Godot on my arc". ' +
|
|
2252
|
+
'v1 pull is free (no credit).', {
|
|
2253
|
+
id: z.string().describe('Shader id or alias (slash_reveal, circular_cresent_slash_2d_mask, …)'),
|
|
2254
|
+
engine: z.enum(['godot', 'unity', 'three']).describe('Target engine'),
|
|
2255
|
+
out_dir: z.string().describe('Folder to write shader files into (repo path)'),
|
|
2256
|
+
}, async ({ id, engine, out_dir }) => {
|
|
2257
|
+
if (!API_KEY)
|
|
2258
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
2259
|
+
const res = await fetch(`${API_URL}/api/v1/shaders/pull`, {
|
|
2260
|
+
method: 'POST',
|
|
2261
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
2262
|
+
body: JSON.stringify({ id, engine, out_dir }),
|
|
2263
|
+
});
|
|
2264
|
+
const data = (await res.json().catch(() => ({})));
|
|
2265
|
+
if (!res.ok)
|
|
2266
|
+
return err(String(data.error ?? res.status));
|
|
2267
|
+
const dir = resolve(out_dir);
|
|
2268
|
+
await mkdir(dir, { recursive: true });
|
|
2269
|
+
const wrote = [];
|
|
2270
|
+
for (const f of data.files ?? []) {
|
|
2271
|
+
const rel = (f.relative || f.path || '').replace(/^.*[/\\]/, '');
|
|
2272
|
+
if (!rel || typeof f.content !== 'string')
|
|
2273
|
+
continue;
|
|
2274
|
+
const dest = join(dir, rel);
|
|
2275
|
+
await writeFile(dest, f.content);
|
|
2276
|
+
wrote.push(dest);
|
|
2277
|
+
}
|
|
2278
|
+
return {
|
|
2279
|
+
content: [
|
|
2280
|
+
{
|
|
2281
|
+
type: 'text',
|
|
2282
|
+
text: JSON.stringify({
|
|
2283
|
+
id: data.id,
|
|
2284
|
+
name: data.name,
|
|
2285
|
+
engine: data.engine,
|
|
2286
|
+
credit: 0,
|
|
2287
|
+
note: data.note,
|
|
2288
|
+
wrote,
|
|
2289
|
+
snippet: data.snippet,
|
|
2290
|
+
warnings: data.warnings ?? [],
|
|
2291
|
+
}, null, 2),
|
|
2292
|
+
},
|
|
2293
|
+
],
|
|
2294
|
+
};
|
|
2295
|
+
});
|
|
2296
|
+
// Kept in sync with apps/web/src/lib/performance.ts; this package ships alone.
|
|
2297
|
+
const performanceMs = z.number().finite().min(0).max(60_000);
|
|
2298
|
+
const performanceSampleSchema = z.object({
|
|
2299
|
+
fps: z.number().finite().min(0).max(1000).optional(),
|
|
2300
|
+
frameMs: performanceMs.optional(),
|
|
2301
|
+
p95Ms: performanceMs.optional(),
|
|
2302
|
+
pixelRatio: z.number().finite().min(.01).max(8).optional(),
|
|
2303
|
+
quality: z.enum(['auto', 'high', 'performance']).optional(),
|
|
2304
|
+
level: z.number().int().min(0).max(10).optional(),
|
|
2305
|
+
cpuMs: performanceMs.optional(),
|
|
2306
|
+
submitMs: performanceMs.optional(),
|
|
2307
|
+
gpuMs: performanceMs.nullable().optional(),
|
|
2308
|
+
drawCalls: z.number().int().min(0).max(1_000_000).optional(),
|
|
2309
|
+
triangles: z.number().int().min(0).max(1_000_000_000).optional(),
|
|
2310
|
+
networkMs: performanceMs.nullable().optional(),
|
|
2311
|
+
snapshotAgeMs: performanceMs.optional(),
|
|
2312
|
+
}).refine(sample => sample.fps !== undefined || sample.frameMs !== undefined, 'Each sample needs fps or frameMs.');
|
|
2313
|
+
server.tool('gripforge_performance', 'Analyze measured game performance: FPS, frame times, CPU/submission work, draw calls and network latency. ' +
|
|
2314
|
+
'Pass a numeric report to analyze it without saving, or omit report to retrieve the latest explicitly shared capture in your workspace. ' +
|
|
2315
|
+
'Optional kit_id filters the capture. Returns measured findings and limitations, not a claimed optimization or GPU benchmark. 0 credits.', {
|
|
2316
|
+
kit_id: z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional().describe('Kit id for the latest workspace capture (optional).'),
|
|
2317
|
+
report: z.object({
|
|
2318
|
+
game: z.string().trim().max(80).optional(),
|
|
2319
|
+
version: z.string().trim().max(80).optional(),
|
|
2320
|
+
viewport: z.object({ width: z.number().int().min(1).max(32_768), height: z.number().int().min(1).max(32_768) }).optional(),
|
|
2321
|
+
samples: z.array(performanceSampleSchema).min(1).max(120),
|
|
2322
|
+
}).optional().describe('Measured diagnostic JSON, including 1–120 numeric samples. Only documented performance fields are sent.'),
|
|
2323
|
+
}, async ({ kit_id, report }) => {
|
|
2324
|
+
if (!API_KEY)
|
|
2325
|
+
return err('GRIPFORGE_API_KEY missing. Set a workspace API key to access performance diagnostics.');
|
|
2326
|
+
const query = kit_id ? `?${new URLSearchParams({ kit_id })}` : '';
|
|
2327
|
+
const body = report ? JSON.stringify({ mode: 'analyze', kitId: kit_id, report }) : undefined;
|
|
2328
|
+
if (body && Buffer.byteLength(body, 'utf8') > 65_536)
|
|
2329
|
+
return err('Performance report exceeds the 64 KiB request limit. Reduce the number of samples.');
|
|
2330
|
+
try {
|
|
2331
|
+
const response = await fetch(`${API_URL}/api/v1/performance${report ? '' : query}`, {
|
|
2332
|
+
method: report ? 'POST' : 'GET',
|
|
2333
|
+
headers: { ...apiHeaders(), ...(report ? { 'content-type': 'application/json' } : {}) },
|
|
2334
|
+
body,
|
|
2335
|
+
signal: AbortSignal.timeout(15_000),
|
|
2336
|
+
});
|
|
2337
|
+
const data = await response.json().catch(() => ({}));
|
|
2338
|
+
if (response.status === 401)
|
|
2339
|
+
return err('Invalid GRIPFORGE_API_KEY. Check your workspace API key.');
|
|
2340
|
+
if (response.status === 404 && !report)
|
|
2341
|
+
return err('No performance capture is available for this workspace or kit. Share a capture from the game, or provide a measured report.');
|
|
2342
|
+
if (!response.ok)
|
|
2343
|
+
return err(String(data.error ?? `Performance diagnostics failed (${response.status}).`));
|
|
2344
|
+
if (data.schema !== 'gripforge.performance.v1')
|
|
2345
|
+
return err('The API returned an unsupported performance report. Check that the GripForge server is up to date.');
|
|
2346
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2347
|
+
}
|
|
2348
|
+
catch {
|
|
2349
|
+
return err('Performance diagnostics could not be retrieved within 15 seconds. Check the GripForge API connection and try again.');
|
|
2350
|
+
}
|
|
2351
|
+
});
|
|
176
2352
|
const transport = new StdioServerTransport();
|
|
177
2353
|
await server.connect(transport);
|