@threenative/core 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/capabilities.json +1881 -131
- package/dist/assets-kyoF7JlJ.d.ts +103 -0
- package/dist/{audio-Dp2mXpD3.d.ts → audio-BFiGneTL.d.ts} +62 -0
- package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
- package/dist/{game-CYIaKhgl.d.ts → game-XGrTzapq.d.ts} +350 -164
- package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
- package/dist/hot.d.ts +5 -3
- package/dist/hot.js +5 -1
- package/dist/index.d.ts +813 -143
- package/dist/index.js +4548 -941
- package/dist/net.d.ts +65 -0
- package/dist/net.js +643 -0
- package/dist/playtest.d.ts +29 -5
- package/dist/playtest.js +181 -35
- package/dist/react.d.ts +4 -2
- package/dist/{canvas-layer-CtrZHgIh.d.ts → renderer-C6hqZpoG.d.ts} +237 -75
- package/dist/world.d.ts +203 -4
- package/dist/world.js +2536 -25
- package/gpl/LICENSE.GPL +117 -0
- package/gpl/convert.py +192 -0
- package/gpl/recipes/_common.py +169 -0
- package/gpl/recipes/bake_ao.py +111 -0
- package/gpl/recipes/decimate.py +64 -0
- package/gpl/recipes/retarget.py +131 -0
- package/gpl/recipes/unwrap.py +71 -0
- package/mcp/blender-server.mjs +632 -0
- package/mcp/blender.mjs +27 -0
- package/mcp/engine-server.mjs +271 -23
- package/mcp/engine.mjs +15 -7
- package/mcp/install.d.mts +37 -0
- package/mcp/install.mjs +94 -26
- package/mcp/servers.d.mts +34 -0
- package/mcp/servers.mjs +110 -9
- package/package.json +36 -8
- package/patches/three@0.185.1.patch +249 -14
- package/scripts/ensure-mcp.mjs +20 -13
- package/scripts/bundle-engine-mcp.mjs +0 -15
- package/scripts/generate-version.mjs +0 -13
package/mcp/engine-server.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { realpathSync, readFileSync } from 'fs';
|
|
2
|
+
import { realpathSync, readFileSync, existsSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
@@ -12,32 +12,143 @@ var DEFAULT_MANIFEST_FILE = "capabilities.json";
|
|
|
12
12
|
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
13
13
|
"a",
|
|
14
14
|
"add",
|
|
15
|
+
"after",
|
|
16
|
+
"again",
|
|
17
|
+
"all",
|
|
18
|
+
"also",
|
|
15
19
|
"an",
|
|
16
20
|
"and",
|
|
21
|
+
"any",
|
|
22
|
+
"are",
|
|
17
23
|
"around",
|
|
24
|
+
"as",
|
|
25
|
+
"at",
|
|
26
|
+
"be",
|
|
27
|
+
"because",
|
|
28
|
+
"been",
|
|
29
|
+
"being",
|
|
30
|
+
"both",
|
|
18
31
|
"build",
|
|
32
|
+
"but",
|
|
19
33
|
"by",
|
|
34
|
+
"can",
|
|
20
35
|
"create",
|
|
36
|
+
"do",
|
|
37
|
+
"does",
|
|
38
|
+
"each",
|
|
39
|
+
"either",
|
|
40
|
+
"else",
|
|
41
|
+
"every",
|
|
21
42
|
"for",
|
|
22
43
|
"from",
|
|
23
44
|
"game",
|
|
45
|
+
"get",
|
|
46
|
+
"give",
|
|
47
|
+
"had",
|
|
48
|
+
"has",
|
|
49
|
+
"have",
|
|
50
|
+
"how",
|
|
51
|
+
"if",
|
|
24
52
|
"in",
|
|
25
53
|
"into",
|
|
54
|
+
"is",
|
|
26
55
|
"it",
|
|
56
|
+
"its",
|
|
57
|
+
"let",
|
|
58
|
+
"made",
|
|
27
59
|
"make",
|
|
60
|
+
"may",
|
|
61
|
+
"might",
|
|
62
|
+
"must",
|
|
63
|
+
"no",
|
|
64
|
+
"nor",
|
|
65
|
+
"not",
|
|
28
66
|
"of",
|
|
29
67
|
"on",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
68
|
+
"once",
|
|
69
|
+
"one",
|
|
70
|
+
"only",
|
|
71
|
+
"or",
|
|
72
|
+
"other",
|
|
73
|
+
"our",
|
|
74
|
+
"own",
|
|
75
|
+
"put",
|
|
76
|
+
"rather",
|
|
77
|
+
"same",
|
|
78
|
+
"shall",
|
|
79
|
+
"should",
|
|
80
|
+
"since",
|
|
81
|
+
"so",
|
|
82
|
+
"some",
|
|
83
|
+
"such",
|
|
84
|
+
"take",
|
|
85
|
+
"than",
|
|
86
|
+
"their",
|
|
87
|
+
"them",
|
|
88
|
+
"then",
|
|
89
|
+
"there",
|
|
90
|
+
"these",
|
|
91
|
+
"they",
|
|
92
|
+
"this",
|
|
93
|
+
"those",
|
|
94
|
+
"though",
|
|
95
|
+
"thus",
|
|
32
96
|
"to",
|
|
97
|
+
"too",
|
|
33
98
|
"use",
|
|
34
|
-
"
|
|
99
|
+
"very",
|
|
100
|
+
"was",
|
|
101
|
+
"want",
|
|
102
|
+
"way",
|
|
103
|
+
"we",
|
|
104
|
+
"well",
|
|
105
|
+
"were",
|
|
106
|
+
"what",
|
|
107
|
+
"when",
|
|
108
|
+
"whenever",
|
|
109
|
+
"where",
|
|
110
|
+
"whether",
|
|
111
|
+
"which",
|
|
112
|
+
"while",
|
|
113
|
+
"who",
|
|
114
|
+
"whose",
|
|
115
|
+
"why",
|
|
116
|
+
"will",
|
|
117
|
+
"with",
|
|
118
|
+
"within",
|
|
119
|
+
"without",
|
|
120
|
+
"would",
|
|
121
|
+
"yet",
|
|
122
|
+
"you",
|
|
123
|
+
"your"
|
|
35
124
|
]);
|
|
36
125
|
var MAX_COMPLETE_REQUEST_RESULTS = 15;
|
|
37
126
|
var MAX_SITUATION_RESULTS = 5;
|
|
38
|
-
var
|
|
127
|
+
var RELEVANCE_FLOOR = 0.27;
|
|
128
|
+
var AUTHORING_INSTRUCTIONS = `Before authoring, infer concrete gameplay mechanics. Preserve the request's distinctive fantasy: choose the smallest loop that uses its characteristic setting, traversal medium, or simulation instead of a generic character game with themed props, and search those implied mechanics even when the user did not name engine terms. Search the mechanically explicit complete request with scope "request", then each mechanic with scope "mechanic". A genre label alone is not a capability query: clarify or decompose it; do not assume a preset. Inspect capability detail and obey constraints before implementing. Capability detail is authoritative on platform support: never invent a platform limitation it does not state. A response with verdict "none" is an actionable answer: follow its guidance and write game-owned behavior in src/ instead of rephrasing the same request.`;
|
|
129
|
+
var GENERIC_GUIDANCE = "No installed engine capability matches this situation. Decompose it into concrete mechanics and write the game-owned behavior in your project's src/; inspect the relevant template AGENTS.md before adding a package.";
|
|
39
130
|
function defaultManifestPath(cwd = process.cwd()) {
|
|
40
|
-
|
|
131
|
+
const override = process.env.THREENATIVE_CAPABILITIES_MANIFEST;
|
|
132
|
+
if (override !== void 0 && override.trim().length > 0) return path.resolve(cwd, override);
|
|
133
|
+
for (let directory = path.resolve(cwd); ; directory = path.dirname(directory)) {
|
|
134
|
+
const installed = path.join(
|
|
135
|
+
directory,
|
|
136
|
+
"node_modules",
|
|
137
|
+
"@threenative",
|
|
138
|
+
"core",
|
|
139
|
+
"capabilities.json"
|
|
140
|
+
);
|
|
141
|
+
if (existsSync(installed)) return installed;
|
|
142
|
+
const parent = path.dirname(directory);
|
|
143
|
+
if (parent === directory) break;
|
|
144
|
+
}
|
|
145
|
+
for (let directory = path.dirname(fileURLToPath(import.meta.url)); ; directory = path.dirname(directory)) {
|
|
146
|
+
const repository = path.join(directory, "packages", "core", "capabilities.json");
|
|
147
|
+
if (existsSync(repository)) return repository;
|
|
148
|
+
const parent = path.dirname(directory);
|
|
149
|
+
if (parent === directory) break;
|
|
150
|
+
}
|
|
151
|
+
return path.resolve(cwd, DEFAULT_MANIFEST_FILE);
|
|
41
152
|
}
|
|
42
153
|
function manifestError(file, reason) {
|
|
43
154
|
return new Error(`TN_ENGINE_CAPABILITIES_MANIFEST: ${file}: ${reason}`);
|
|
@@ -46,14 +157,29 @@ function isRecord(value) {
|
|
|
46
157
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
158
|
}
|
|
48
159
|
function validateManifest(value, file) {
|
|
49
|
-
if (!isRecord(value) ||
|
|
50
|
-
throw manifestError(
|
|
160
|
+
if (!isRecord(value) || value.version !== 2 || !Array.isArray(value.entries) || !Array.isArray(value.notOwned)) {
|
|
161
|
+
throw manifestError(
|
|
162
|
+
file,
|
|
163
|
+
"root must contain manifest version 2, entries array, and notOwned array"
|
|
164
|
+
);
|
|
51
165
|
}
|
|
52
166
|
for (const [index, raw] of value.entries.entries()) {
|
|
53
|
-
if (!isRecord(raw) || typeof raw.symbol !== "string" || typeof raw.package !== "string" || typeof raw.importPath !== "string" || typeof raw.kind !== "string" || typeof raw.signature !== "string" || typeof raw.summary !== "string" || typeof raw.example !== "string" || !Array.isArray(raw.situations) || !Array.isArray(raw.constraints) || !raw.situations.every((situation) => typeof situation === "string") || !raw.constraints.every((constraint) => typeof constraint === "string")) {
|
|
167
|
+
if (!isRecord(raw) || typeof raw.symbol !== "string" || typeof raw.package !== "string" || typeof raw.importPath !== "string" || typeof raw.kind !== "string" || typeof raw.signature !== "string" || typeof raw.summary !== "string" || typeof raw.example !== "string" || !Array.isArray(raw.situations) || !Array.isArray(raw.aliases) || !Array.isArray(raw.constraints) || raw.requires !== void 0 && !Array.isArray(raw.requires) || !raw.situations.every((situation) => typeof situation === "string") || !raw.aliases.every((alias) => typeof alias === "string") || !raw.constraints.every((constraint) => typeof constraint === "string") || Array.isArray(raw.requires) && !raw.requires.every((requirement) => typeof requirement === "string")) {
|
|
54
168
|
throw manifestError(file, `entry ${index} is malformed`);
|
|
55
169
|
}
|
|
56
170
|
}
|
|
171
|
+
const notOwnedIds = /* @__PURE__ */ new Set();
|
|
172
|
+
for (const [index, raw] of value.notOwned.entries()) {
|
|
173
|
+
if (!isRecord(raw) || typeof raw.id !== "string" || raw.id.trim().length === 0 || !Array.isArray(raw.situations) || raw.situations.length === 0 || !raw.situations.every(
|
|
174
|
+
(situation) => typeof situation === "string" && situation.trim().length > 0
|
|
175
|
+
) || typeof raw.guidance !== "string" || raw.guidance.trim().length === 0) {
|
|
176
|
+
throw manifestError(file, `notOwned ${index} is malformed`);
|
|
177
|
+
}
|
|
178
|
+
if (notOwnedIds.has(raw.id)) {
|
|
179
|
+
throw manifestError(file, `notOwned contains duplicate id '${raw.id}'`);
|
|
180
|
+
}
|
|
181
|
+
notOwnedIds.add(raw.id);
|
|
182
|
+
}
|
|
57
183
|
return value;
|
|
58
184
|
}
|
|
59
185
|
function loadCapabilityManifest(file = defaultManifestPath()) {
|
|
@@ -71,15 +197,103 @@ function loadCapabilityManifest(file = defaultManifestPath()) {
|
|
|
71
197
|
throw manifestError(file, `cannot parse JSON: ${String(error)}`);
|
|
72
198
|
}
|
|
73
199
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
200
|
+
function undouble(stem2) {
|
|
201
|
+
const last = stem2.at(-1);
|
|
202
|
+
return stem2.length > 3 && last !== void 0 && last === stem2.at(-2) && !"aeiou".includes(last) ? stem2.slice(0, -1) : stem2;
|
|
203
|
+
}
|
|
204
|
+
function stem(token) {
|
|
205
|
+
const stripped = suffixStripped(token);
|
|
206
|
+
return stripped.length > 3 && stripped.endsWith("e") ? stripped.slice(0, -1) : stripped;
|
|
207
|
+
}
|
|
208
|
+
function suffixStripped(token) {
|
|
209
|
+
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
210
|
+
if (token.length > 4 && token.endsWith("ing")) return undouble(token.slice(0, -3));
|
|
211
|
+
if (token.length > 4 && token.endsWith("ed")) return undouble(token.slice(0, -2));
|
|
212
|
+
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
|
|
213
|
+
return token;
|
|
214
|
+
}
|
|
215
|
+
function capabilitySituationTokens(value) {
|
|
216
|
+
return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem);
|
|
217
|
+
}
|
|
218
|
+
var tokens = capabilitySituationTokens;
|
|
219
|
+
function tokenWeights(entries) {
|
|
220
|
+
const frequency = /* @__PURE__ */ new Map();
|
|
221
|
+
let situations = 0;
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
for (const situation of [...entry.situations, ...entry.aliases]) {
|
|
224
|
+
situations += 1;
|
|
225
|
+
for (const token of new Set(tokens(situation)))
|
|
226
|
+
frequency.set(token, (frequency.get(token) ?? 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const weights = /* @__PURE__ */ new Map();
|
|
230
|
+
for (const [token, count] of frequency) weights.set(token, Math.log(1 + situations / count));
|
|
231
|
+
return weights;
|
|
232
|
+
}
|
|
233
|
+
function weightOf(weights, token) {
|
|
234
|
+
return weights.get(token) ?? 0;
|
|
235
|
+
}
|
|
236
|
+
var DISTINCTIVE_SITUATION_SHARE = 0.02;
|
|
237
|
+
var DISTINCTIVE_FLOOR = Math.log(1 + 1 / DISTINCTIVE_SITUATION_SHARE);
|
|
238
|
+
var AGREEMENT_WEIGHT = 0.4;
|
|
239
|
+
var LONE_WORD_COVERAGE = 0.22;
|
|
240
|
+
function agreement(situation, query) {
|
|
241
|
+
const matched = situation.filter((token) => query.has(token)).length;
|
|
242
|
+
return 1 + AGREEMENT_WEIGHT * Math.max(0, matched - 1);
|
|
243
|
+
}
|
|
244
|
+
function scorePhrase(value, queryTokens, queryText, weights) {
|
|
245
|
+
const phrase = tokens(value);
|
|
246
|
+
const unique = [...new Set(phrase)];
|
|
247
|
+
const total = unique.reduce((sum, token) => sum + weightOf(weights, token), 0);
|
|
248
|
+
const matched = unique.filter((token) => queryTokens.has(token)).reduce((sum, token) => sum + weightOf(weights, token), 0);
|
|
249
|
+
const phraseBonus = phrase.join(" ").includes(queryText) || queryText.includes(phrase.join(" ")) ? 1 : 0;
|
|
250
|
+
if (total === 0 || matched === 0 && phraseBonus === 0) return void 0;
|
|
251
|
+
const coverage = matched / total;
|
|
252
|
+
const agreed = agreement(unique, queryTokens);
|
|
253
|
+
return { agreed, coverage, matched, phraseBonus, score: coverage * agreed + phraseBonus };
|
|
254
|
+
}
|
|
255
|
+
function isDistinctivePhrase(candidate) {
|
|
256
|
+
if (candidate.phraseBonus > 0) return true;
|
|
257
|
+
if (candidate.matched < DISTINCTIVE_FLOOR) return false;
|
|
258
|
+
return !(candidate.agreed === 1 && candidate.coverage < LONE_WORD_COVERAGE);
|
|
259
|
+
}
|
|
260
|
+
function scoreReadableSituations(situations, queryTokens, queryText, weights) {
|
|
261
|
+
let best = 0;
|
|
262
|
+
let matchedSituation = "";
|
|
263
|
+
let bestReadableScore = 0;
|
|
264
|
+
let bestReadableSituation = situations[0] ?? "";
|
|
265
|
+
for (const situation of situations) {
|
|
266
|
+
const candidate = scorePhrase(situation, queryTokens, queryText, weights);
|
|
267
|
+
if (candidate === void 0) continue;
|
|
268
|
+
if (candidate.score > bestReadableScore) {
|
|
269
|
+
bestReadableScore = candidate.score;
|
|
270
|
+
bestReadableSituation = situation;
|
|
271
|
+
}
|
|
272
|
+
if (isDistinctivePhrase(candidate) && candidate.score > best) {
|
|
273
|
+
best = candidate.score;
|
|
274
|
+
matchedSituation = situation;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { best, bestReadableSituation, matchedSituation };
|
|
81
278
|
}
|
|
82
|
-
function
|
|
279
|
+
function scoreAliases(aliases, queryTokens, queryText, weights) {
|
|
280
|
+
let best = 0;
|
|
281
|
+
for (const alias of aliases) {
|
|
282
|
+
const candidate = scorePhrase(alias, queryTokens, queryText, weights);
|
|
283
|
+
if (candidate !== void 0) best = Math.max(best, candidate.score);
|
|
284
|
+
}
|
|
285
|
+
return best;
|
|
286
|
+
}
|
|
287
|
+
function situationScore(query, situations, aliases, weights) {
|
|
288
|
+
if (query.length === 0) return { matchedSituation: "", score: 0 };
|
|
289
|
+
const queryTokens = new Set(query);
|
|
290
|
+
const queryText = query.join(" ");
|
|
291
|
+
const readable = scoreReadableSituations(situations, queryTokens, queryText, weights);
|
|
292
|
+
const best = Math.max(readable.best, scoreAliases(aliases, queryTokens, queryText, weights));
|
|
293
|
+
const matchedSituation = readable.matchedSituation.length > 0 ? readable.matchedSituation : best >= RELEVANCE_FLOOR ? readable.bestReadableSituation : "";
|
|
294
|
+
return matchedSituation.length > 0 ? { matchedSituation, score: best } : { matchedSituation: "", score: 0 };
|
|
295
|
+
}
|
|
296
|
+
function notOwnedSituationScore(query, situations) {
|
|
83
297
|
if (query.length === 0) return { matchedSituation: "", score: 0 };
|
|
84
298
|
const queryText = query.join(" ");
|
|
85
299
|
let best = 0;
|
|
@@ -98,9 +312,24 @@ function situationScore(query, situations) {
|
|
|
98
312
|
}
|
|
99
313
|
return { matchedSituation, score: best };
|
|
100
314
|
}
|
|
315
|
+
function notOwnedMatch(manifest, query) {
|
|
316
|
+
return manifest.notOwned.map((entry) => ({ entry, ...notOwnedSituationScore(query, entry.situations) })).filter(
|
|
317
|
+
({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR
|
|
318
|
+
).sort(
|
|
319
|
+
(left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id)
|
|
320
|
+
)[0];
|
|
321
|
+
}
|
|
322
|
+
function isSpecificNotOwnedMatch(query, matchedSituation) {
|
|
323
|
+
const queryText = query.join(" ");
|
|
324
|
+
const phrase = tokens(matchedSituation);
|
|
325
|
+
const phraseText = phrase.join(" ");
|
|
326
|
+
if (queryText === phraseText) return true;
|
|
327
|
+
if (query.length === 1 && phrase.length <= 2 && phrase.includes(query[0] ?? "")) return true;
|
|
328
|
+
if (query.length > phrase.length + 1) return false;
|
|
329
|
+
return new Set(phrase.filter((token) => query.includes(token))).size >= 2;
|
|
330
|
+
}
|
|
101
331
|
function capabilitySearchKey(entry) {
|
|
102
332
|
return `${entry.importPath}
|
|
103
|
-
${entry.signature}
|
|
104
333
|
${entry.summary}
|
|
105
334
|
${entry.situations.join("\n")}`;
|
|
106
335
|
}
|
|
@@ -110,7 +339,18 @@ function searchCapabilities(situation, manifestFile = defaultManifestPath(), sco
|
|
|
110
339
|
const manifest = loadCapabilityManifest(manifestFile);
|
|
111
340
|
const query = tokens(situation);
|
|
112
341
|
const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS;
|
|
113
|
-
|
|
342
|
+
const weights = tokenWeights(manifest.entries);
|
|
343
|
+
const notOwned = notOwnedMatch(manifest, query);
|
|
344
|
+
if (notOwned !== void 0 && isSpecificNotOwnedMatch(query, notOwned.matchedSituation)) {
|
|
345
|
+
return {
|
|
346
|
+
guidance: notOwned.entry.guidance,
|
|
347
|
+
results: [],
|
|
348
|
+
verdict: "none"
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const results = manifest.entries.map((entry) => ({ entry, ...situationScore(query, entry.situations, entry.aliases, weights) })).filter(
|
|
352
|
+
({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR
|
|
353
|
+
).sort(
|
|
114
354
|
(left, right) => right.score - left.score || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare(
|
|
115
355
|
`${right.entry.importPath}:${right.entry.symbol}`
|
|
116
356
|
)
|
|
@@ -118,14 +358,22 @@ function searchCapabilities(situation, manifestFile = defaultManifestPath(), sco
|
|
|
118
358
|
(candidate, index, candidates) => candidates.findIndex(
|
|
119
359
|
(other) => capabilitySearchKey(other.entry) === capabilitySearchKey(candidate.entry)
|
|
120
360
|
) === index
|
|
121
|
-
).slice(0, limit).map(({ entry, matchedSituation }) => ({
|
|
361
|
+
).slice(0, limit).map(({ entry, matchedSituation, score }) => ({
|
|
122
362
|
constraints: entry.constraints,
|
|
123
363
|
example: entry.example,
|
|
124
364
|
importPath: entry.importPath,
|
|
125
365
|
matchedSituation,
|
|
366
|
+
...entry.requires === void 0 ? {} : { requires: entry.requires },
|
|
367
|
+
score,
|
|
126
368
|
summary: entry.summary,
|
|
127
369
|
symbol: entry.symbol
|
|
128
370
|
}));
|
|
371
|
+
if (results.length > 0) return { guidance: "", results, verdict: "matched" };
|
|
372
|
+
return {
|
|
373
|
+
guidance: notOwned?.entry.guidance ?? GENERIC_GUIDANCE,
|
|
374
|
+
results: [],
|
|
375
|
+
verdict: "none"
|
|
376
|
+
};
|
|
129
377
|
}
|
|
130
378
|
function capabilityDetail(symbol, manifestFile = defaultManifestPath()) {
|
|
131
379
|
if (typeof symbol !== "string" || symbol.trim().length === 0)
|
|
@@ -139,7 +387,7 @@ var TOOL_DEFINITIONS = [
|
|
|
139
387
|
{
|
|
140
388
|
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
|
|
141
389
|
name: "engine_search_capabilities",
|
|
142
|
-
description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation
|
|
390
|
+
description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. The response verdict "none" is an actionable answer with guidance, not a failed search.',
|
|
143
391
|
inputSchema: {
|
|
144
392
|
additionalProperties: false,
|
|
145
393
|
properties: {
|
|
@@ -216,7 +464,7 @@ function handleLine(line, manifestFile) {
|
|
|
216
464
|
capabilities: { tools: { listChanged: false } },
|
|
217
465
|
instructions: AUTHORING_INSTRUCTIONS,
|
|
218
466
|
protocolVersion: "2025-06-18",
|
|
219
|
-
serverInfo: { name: "threenative-engine-mcp", version: "0.2.
|
|
467
|
+
serverInfo: { name: "threenative-engine-mcp", version: "0.2.1" }
|
|
220
468
|
});
|
|
221
469
|
}
|
|
222
470
|
if (request.method === "tools/list") {
|
|
@@ -250,4 +498,4 @@ if (entryPath !== void 0 && realpathSync(path.resolve(entryPath)) === realpathSy
|
|
|
250
498
|
}
|
|
251
499
|
}
|
|
252
500
|
|
|
253
|
-
export { ENGINE_MCP_TOOL_NAMES, capabilityDetail, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
|
|
501
|
+
export { ENGINE_MCP_TOOL_NAMES, RELEVANCE_FLOOR, capabilityDetail, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
|
package/mcp/engine.mjs
CHANGED
|
@@ -5,19 +5,27 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { launchMcpServer } from "./launch.mjs";
|
|
6
6
|
import { MCP_PACKAGES } from "./servers.mjs";
|
|
7
7
|
|
|
8
|
-
// The capability server
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// The capability server must answer with the manifest of the engine the game actually runs, and
|
|
9
|
+
// the copy this package ships is generated by the same build as that engine. Prefer it over any
|
|
10
|
+
// copy the project committed: a committed snapshot drifts the moment the engine dependency moves.
|
|
11
|
+
// With no bundled copy (a stripped install), fall through to the server's own defaults.
|
|
11
12
|
const bundled = path.resolve(fileURLToPath(import.meta.url), "..", "..", "capabilities.json");
|
|
12
|
-
const env =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
: Object.fromEntries([["THREENATIVE_CAPABILITIES_MANIFEST", bundled]]);
|
|
13
|
+
const env = existsSync(bundled)
|
|
14
|
+
? Object.fromEntries([["THREENATIVE_CAPABILITIES_MANIFEST", bundled]])
|
|
15
|
+
: {};
|
|
16
16
|
|
|
17
17
|
const localServer = path.resolve(fileURLToPath(import.meta.url), "..", "engine-server.mjs");
|
|
18
18
|
if (existsSync(localServer)) {
|
|
19
19
|
const { runServer } = await import("./engine-server.mjs");
|
|
20
20
|
runServer(env.THREENATIVE_CAPABILITIES_MANIFEST);
|
|
21
21
|
} else {
|
|
22
|
+
// Say so. This branch fetches a package over the network, and when the network is slow, absent
|
|
23
|
+
// or the version unpublished it produces no output at all — the host simply waits. A CI run
|
|
24
|
+
// spent 30 s here twice and reported `threenative-engine initialize timed out after 30000ms`
|
|
25
|
+
// with empty stderr, which named neither the fallback nor the reason for it. The bundled server
|
|
26
|
+
// is missing here for a reason worth reporting even when the fetch then succeeds.
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
`TN_MCP_ENGINE_FALLBACK: no bundled capability server at ${localServer}; fetching ${MCP_PACKAGES.engine.name}@${MCP_PACKAGES.engine.version} over the network instead. A packaged @threenative/core should always carry the bundle.\n`,
|
|
29
|
+
);
|
|
22
30
|
await launchMcpServer({ ...MCP_PACKAGES.engine, env });
|
|
23
31
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Types for `install.mjs`, the same contract `servers.d.mts` provides for `servers.mjs`. That file
|
|
2
|
+
// is plain JavaScript by design — `@threenative/core`'s postinstall runs it before anything is
|
|
3
|
+
// built — so it can never be a `.ts`. Without this declaration every consumer reached for
|
|
4
|
+
// `@ts-expect-error` and then retyped the host table locally.
|
|
5
|
+
|
|
6
|
+
/** One project-scoped host config the installer writes. */
|
|
7
|
+
export interface IMcpHost {
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly format: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly seed?: Readonly<Record<string, unknown>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What a single config write did, or why it was left alone. */
|
|
16
|
+
export type McpConfigOutcome =
|
|
17
|
+
| "conflict"
|
|
18
|
+
| "created"
|
|
19
|
+
| "unchanged"
|
|
20
|
+
| "unreadable"
|
|
21
|
+
| "unwritable"
|
|
22
|
+
| "updated";
|
|
23
|
+
|
|
24
|
+
export declare const MCP_HOSTS: readonly IMcpHost[];
|
|
25
|
+
export declare function installTarget(
|
|
26
|
+
environment?: NodeJS.ProcessEnv,
|
|
27
|
+
cwd?: string,
|
|
28
|
+
): string | undefined;
|
|
29
|
+
export declare function ensureJsonMcpConfig(
|
|
30
|
+
target: string,
|
|
31
|
+
file: string,
|
|
32
|
+
format: string,
|
|
33
|
+
seed?: Readonly<Record<string, unknown>>,
|
|
34
|
+
): McpConfigOutcome;
|
|
35
|
+
export declare function ensureMcpConfig(target: string): McpConfigOutcome;
|
|
36
|
+
export declare function ensureCodexMcpConfig(target: string): McpConfigOutcome;
|
|
37
|
+
export declare function ensureHostMcpConfigs(target: string): Map<string, McpConfigOutcome>;
|
package/mcp/install.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { mergeMcpServers } from "./servers.mjs";
|
|
3
|
+
import { CODEX_MCP_SERVERS, mergeMcpServers } from "./servers.mjs";
|
|
4
4
|
|
|
5
5
|
/** The project being installed into, or undefined when this install has no project to write to —
|
|
6
6
|
* a nested dependency install, or core's own workspace build. */
|
|
@@ -9,19 +9,68 @@ export function installTarget(environment = process.env, cwd = process.cwd()) {
|
|
|
9
9
|
if (target.split(path.sep).includes("node_modules")) return undefined;
|
|
10
10
|
const manifestPath = path.join(target, "package.json");
|
|
11
11
|
if (!existsSync(manifestPath)) return undefined;
|
|
12
|
-
let
|
|
12
|
+
let manifest;
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
15
15
|
} catch {
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest))
|
|
19
|
+
return undefined;
|
|
20
|
+
if (manifest.name === "@threenative/core") return undefined;
|
|
21
|
+
const dependencyGroups = [
|
|
22
|
+
manifest.dependencies,
|
|
23
|
+
manifest.devDependencies,
|
|
24
|
+
manifest.optionalDependencies,
|
|
25
|
+
manifest.peerDependencies,
|
|
26
|
+
];
|
|
27
|
+
const declaresCore = dependencyGroups.some(
|
|
28
|
+
(dependencies) =>
|
|
29
|
+
typeof dependencies === "object" &&
|
|
30
|
+
dependencies !== null &&
|
|
31
|
+
Object.hasOwn(dependencies, "@threenative/core"),
|
|
32
|
+
);
|
|
33
|
+
return declaresCore ? target : undefined;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
/**
|
|
36
|
+
/** Every agent host that reads a **project-scoped** MCP config, and the file each one reads.
|
|
37
|
+
*
|
|
38
|
+
* Project-scoped is the whole admission rule. A host that only reads a machine-wide config
|
|
39
|
+
* (Windsurf, Cline, Amp, the JetBrains assistants) is deliberately absent: installing a library
|
|
40
|
+
* into one game must never edit a file that governs every other project on the machine. Those
|
|
41
|
+
* hosts are wired by hand, and `docs/architecture/` says so. */
|
|
42
|
+
export const MCP_HOSTS = Object.freeze([
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: "claude-code",
|
|
45
|
+
label: "Claude Code",
|
|
46
|
+
file: ".mcp.json",
|
|
47
|
+
format: "mcpServers",
|
|
48
|
+
}),
|
|
49
|
+
Object.freeze({ id: "codex", label: "Codex", file: ".codex/config.toml", format: "codex" }),
|
|
50
|
+
Object.freeze({ id: "cursor", label: "Cursor", file: ".cursor/mcp.json", format: "mcpServers" }),
|
|
51
|
+
Object.freeze({ id: "vscode", label: "VS Code", file: ".vscode/mcp.json", format: "vscode" }),
|
|
52
|
+
Object.freeze({
|
|
53
|
+
id: "gemini-cli",
|
|
54
|
+
label: "Gemini CLI",
|
|
55
|
+
file: ".gemini/settings.json",
|
|
56
|
+
format: "mcpServers",
|
|
57
|
+
}),
|
|
58
|
+
Object.freeze({
|
|
59
|
+
id: "opencode",
|
|
60
|
+
label: "opencode",
|
|
61
|
+
file: "opencode.json",
|
|
62
|
+
format: "opencode",
|
|
63
|
+
// Written only when this installer creates the file, so a second install still reports
|
|
64
|
+
// "unchanged" rather than rewriting a config the user has since edited.
|
|
65
|
+
seed: Object.freeze({ $schema: "https://opencode.ai/config.json" }),
|
|
66
|
+
}),
|
|
67
|
+
Object.freeze({ id: "zed", label: "Zed", file: ".zed/settings.json", format: "zed" }),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Adds the ThreeNative servers to one host's JSON config, creating it when absent. Returns what it
|
|
22
71
|
* did so the installer can say so once and stay quiet otherwise. */
|
|
23
|
-
export function
|
|
24
|
-
const configPath = path.join(target,
|
|
72
|
+
export function ensureJsonMcpConfig(target, file, format, seed = undefined) {
|
|
73
|
+
const configPath = path.join(target, file);
|
|
25
74
|
let existing;
|
|
26
75
|
if (existsSync(configPath)) {
|
|
27
76
|
// A config we cannot parse is still the user's. Rewriting it would drop servers we never wrote.
|
|
@@ -31,29 +80,28 @@ export function ensureMcpConfig(target) {
|
|
|
31
80
|
return "unreadable";
|
|
32
81
|
}
|
|
33
82
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
83
|
+
let merged;
|
|
84
|
+
try {
|
|
85
|
+
merged = mergeMcpServers(existing, format);
|
|
86
|
+
} catch {
|
|
87
|
+
return "unreadable";
|
|
88
|
+
}
|
|
89
|
+
const { changed, config, conflicts } = merged;
|
|
90
|
+
if (!changed) return conflicts.length > 0 ? "conflict" : "unchanged";
|
|
91
|
+
const written = existing === undefined ? { ...seed, ...config } : config;
|
|
92
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
93
|
+
writeFileSync(configPath, `${JSON.stringify(written, null, 2)}\n`);
|
|
94
|
+
return conflicts.length > 0 ? "conflict" : existing === undefined ? "created" : "updated";
|
|
38
95
|
}
|
|
39
96
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
body: `[mcp_servers.threenative-sculpt]\ncommand = "node"\nargs = ["./node_modules/@threenative/core/mcp/sculpt.mjs"]`,
|
|
47
|
-
name: "threenative-sculpt",
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
body: `[mcp_servers.threenative-engine]\ncommand = "node"\nargs = ["./node_modules/@threenative/core/mcp/engine.mjs"]`,
|
|
51
|
-
name: "threenative-engine",
|
|
52
|
-
},
|
|
53
|
-
];
|
|
97
|
+
/** Adds the ThreeNative servers to `<target>/.mcp.json`, the config Claude Code reads. */
|
|
98
|
+
export function ensureMcpConfig(target) {
|
|
99
|
+
return ensureJsonMcpConfig(target, ".mcp.json", "mcpServers");
|
|
100
|
+
}
|
|
54
101
|
|
|
55
102
|
/** Adds any missing ThreeNative servers to Codex's project-scoped MCP config without replacing
|
|
56
|
-
* user-authored settings or server definitions.
|
|
103
|
+
* user-authored settings or server definitions. TOML rather than JSON, so this one appends text
|
|
104
|
+
* instead of re-serialising: a config we cannot parse back is one we must not rewrite. */
|
|
57
105
|
export function ensureCodexMcpConfig(target) {
|
|
58
106
|
const directory = path.join(target, ".codex");
|
|
59
107
|
const configPath = path.join(directory, "config.toml");
|
|
@@ -75,3 +123,23 @@ export function ensureCodexMcpConfig(target) {
|
|
|
75
123
|
);
|
|
76
124
|
return existing.length === 0 ? "created" : "updated";
|
|
77
125
|
}
|
|
126
|
+
|
|
127
|
+
/** Wires every host in `MCP_HOSTS`, mapping host id to what the write did. One host's unreadable
|
|
128
|
+
* or unwritable config never stops the rest: a project with a hand-edited `.cursor/mcp.json`
|
|
129
|
+
* should still get its Codex and VS Code tools. */
|
|
130
|
+
export function ensureHostMcpConfigs(target) {
|
|
131
|
+
const outcomes = new Map();
|
|
132
|
+
for (const host of MCP_HOSTS) {
|
|
133
|
+
try {
|
|
134
|
+
outcomes.set(
|
|
135
|
+
host.id,
|
|
136
|
+
host.format === "codex"
|
|
137
|
+
? ensureCodexMcpConfig(target)
|
|
138
|
+
: ensureJsonMcpConfig(target, host.file, host.format, host.seed),
|
|
139
|
+
);
|
|
140
|
+
} catch {
|
|
141
|
+
outcomes.set(host.id, "unreadable");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return outcomes;
|
|
145
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Types for `servers.mjs`. That file is plain JavaScript by design — `@threenative/core`'s
|
|
2
|
+
// postinstall runs it before anything is built — so it can never be a `.ts`. Without this
|
|
3
|
+
// declaration every consumer reached for `@ts-expect-error` and then cast the result back into a
|
|
4
|
+
// shape of its own, which is three private copies of one contract.
|
|
5
|
+
|
|
6
|
+
export interface IMcpServerEntry {
|
|
7
|
+
readonly args: readonly string[];
|
|
8
|
+
readonly command: string;
|
|
9
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IMcpPackage {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly version: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One host's whole config with this format's server table merged in. The table's key is the
|
|
18
|
+
* host's own (`mcpServers`, `servers`, `mcp`, `context_servers`), so a caller reading a key it did
|
|
19
|
+
* not ask for gets `undefined` rather than a silently empty object. */
|
|
20
|
+
export interface IMcpConfigFormat {
|
|
21
|
+
readonly changed: boolean;
|
|
22
|
+
readonly config: Record<string, Record<string, Record<string, unknown>> | undefined>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ICodexMcpServer {
|
|
26
|
+
readonly body: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export declare const MCP_SERVERS: Readonly<Record<string, IMcpServerEntry>>;
|
|
31
|
+
export declare const MCP_PACKAGES: Readonly<Record<string, IMcpPackage>>;
|
|
32
|
+
export declare const SERVER_FORMAT_NAMES: readonly string[];
|
|
33
|
+
export declare const CODEX_MCP_SERVERS: readonly ICodexMcpServer[];
|
|
34
|
+
export declare function mergeMcpServers(existing: unknown, format?: string): IMcpConfigFormat;
|