@myvillage/cli 2.2.0 → 2.4.0
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/package.json +1 -1
- package/src/commands/dataset.js +43 -13
- package/src/commands/deploy.js +25 -2
- package/src/index.js +5 -1
- package/src/utils/dataset-workspace.js +85 -21
- package/src/utils/preflight.js +95 -9
- package/src/utils/templates.js +70 -4
- package/tools/preflight/README.md +1 -1
package/package.json
CHANGED
package/src/commands/dataset.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function datasetAppendCommand(options) {
|
|
|
103
103
|
rows = readRowsFromFile(options.file);
|
|
104
104
|
} else if (options.text !== undefined || options.features !== undefined) {
|
|
105
105
|
const row = { split: options.split || 'train' };
|
|
106
|
-
row.features = options.features !== undefined ? parseFeatures(options.features) : options.text;
|
|
106
|
+
row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality) : options.text;
|
|
107
107
|
if (options.label !== undefined) row.label = options.label;
|
|
108
108
|
if (options.target !== undefined) row.target = Number(options.target);
|
|
109
109
|
rows = [row];
|
|
@@ -111,15 +111,27 @@ export async function datasetAppendCommand(options) {
|
|
|
111
111
|
throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
if (options.replace && !options.file) {
|
|
115
|
+
throw new Error('--replace only works with --file (it swaps in the whole working set).');
|
|
116
|
+
}
|
|
117
|
+
const featureNames = options.featureNames !== undefined ? parseNameList(options.featureNames) : undefined;
|
|
118
|
+
|
|
114
119
|
const spinner = options.json ? null : villageSpinner('Appending rows...').start();
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
result = appendRows(slug, rows, { source: options.source || 'cli' }, {
|
|
123
|
+
replace: Boolean(options.replace),
|
|
124
|
+
featureNames,
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
if (spinner) spinner.stop();
|
|
128
|
+
}
|
|
117
129
|
|
|
118
130
|
if (options.json) {
|
|
119
131
|
console.log(JSON.stringify(result, null, 2));
|
|
120
132
|
return;
|
|
121
133
|
}
|
|
122
|
-
success(
|
|
134
|
+
success(`${result.replaced ? 'Replaced the working set with' : 'Appended'} ${result.appended} row(s) ${result.replaced ? 'in' : 'to'} ${slug}`);
|
|
123
135
|
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
124
136
|
} catch (e) {
|
|
125
137
|
error(e.message);
|
|
@@ -133,8 +145,13 @@ export async function datasetShowCommand(slug, options) {
|
|
|
133
145
|
try {
|
|
134
146
|
const manifest = readManifest(slug);
|
|
135
147
|
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
136
|
-
|
|
137
|
-
const
|
|
148
|
+
// --limit 0 returns every row (used by Studio's export).
|
|
149
|
+
const limit = options.limit !== undefined ? parseInt(options.limit, 10) : 5;
|
|
150
|
+
const version = options.frozen !== undefined ? parseInt(options.frozen, 10) : undefined;
|
|
151
|
+
if (version !== undefined && !manifest.versions.some((v) => v.version === version)) {
|
|
152
|
+
throw new Error(`Dataset "${slug}" has no frozen v${options.frozen}.`);
|
|
153
|
+
}
|
|
154
|
+
const sample = readRows(slug, { limit, version });
|
|
138
155
|
if (options.json) {
|
|
139
156
|
console.log(JSON.stringify({ manifest, sample }, null, 2));
|
|
140
157
|
return;
|
|
@@ -206,7 +223,9 @@ export async function datasetRowUpdateCommand(options) {
|
|
|
206
223
|
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
207
224
|
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
208
225
|
const updates = {};
|
|
209
|
-
if (options.features !== undefined)
|
|
226
|
+
if (options.features !== undefined) {
|
|
227
|
+
updates.features = parseFeatures(options.features, readManifest(slug)?.modality);
|
|
228
|
+
}
|
|
210
229
|
else if (options.text !== undefined) updates.features = options.text;
|
|
211
230
|
if (options.label !== undefined) updates.label = options.label;
|
|
212
231
|
if (options.target !== undefined) updates.target = Number(options.target);
|
|
@@ -244,8 +263,22 @@ function readRowsFromFile(path) {
|
|
|
244
263
|
return rows;
|
|
245
264
|
}
|
|
246
265
|
|
|
247
|
-
|
|
248
|
-
|
|
266
|
+
/** A JSON array of names, or a comma-separated list. */
|
|
267
|
+
function parseNameList(raw) {
|
|
268
|
+
const trimmed = String(raw).trim();
|
|
269
|
+
if (trimmed.startsWith('[')) {
|
|
270
|
+
const parsed = JSON.parse(trimmed);
|
|
271
|
+
if (!Array.isArray(parsed)) throw new Error('--feature-names must be a list.');
|
|
272
|
+
return parsed.map(String);
|
|
273
|
+
}
|
|
274
|
+
return trimmed.split(',').map((s) => s.trim());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseFeatures(raw, modality) {
|
|
278
|
+
// Tabular features are a numeric vector, passed as JSON ("[12, 45]") or a
|
|
279
|
+
// comma list ("12, 45" or a single "12"). Anything that isn't a number stays
|
|
280
|
+
// NaN so row validation rejects it rather than storing it as text.
|
|
281
|
+
if (modality !== 'tabular') return raw;
|
|
249
282
|
const trimmed = raw.trim();
|
|
250
283
|
if (trimmed.startsWith('[')) {
|
|
251
284
|
try {
|
|
@@ -254,10 +287,7 @@ function parseFeatures(raw) {
|
|
|
254
287
|
/* fall through */
|
|
255
288
|
}
|
|
256
289
|
}
|
|
257
|
-
|
|
258
|
-
return trimmed.split(',').map((n) => Number(n.trim()));
|
|
259
|
-
}
|
|
260
|
-
return raw;
|
|
290
|
+
return trimmed.split(',').map((n) => (n.trim() === '' ? NaN : Number(n.trim())));
|
|
261
291
|
}
|
|
262
292
|
|
|
263
293
|
function truncate(s, n) {
|
package/src/commands/deploy.js
CHANGED
|
@@ -463,6 +463,13 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
|
|
|
463
463
|
buildDir,
|
|
464
464
|
sdkVersion: mvConfig.sdkVersion,
|
|
465
465
|
platform,
|
|
466
|
+
onStatus: (text) => {
|
|
467
|
+
preflightSpinner.text = `[${platform}] ${text}`;
|
|
468
|
+
// Studio streams our stdout into its publish log and isn't a TTY, so
|
|
469
|
+
// the spinner's text changes never reach the developer there. Print a
|
|
470
|
+
// plain line too, otherwise the one-time UnityPy install looks like a hang.
|
|
471
|
+
if (!process.stderr.isTTY) console.log(brand.teal(` [${platform}] ${text}`));
|
|
472
|
+
},
|
|
466
473
|
});
|
|
467
474
|
if (preflightResult.skipped) {
|
|
468
475
|
if (preflightResult.fatal) {
|
|
@@ -471,7 +478,18 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
|
|
|
471
478
|
}
|
|
472
479
|
preflightSpinner.warn(`[${platform}] Preflight skipped: ${preflightResult.reason}`);
|
|
473
480
|
} else if (!preflightResult.ok) {
|
|
474
|
-
|
|
481
|
+
// "preflight_error" / "preflight_crash" mean the scanner itself failed
|
|
482
|
+
// (couldn't parse the bundle, script crashed). That's on us, not on the
|
|
483
|
+
// developer's scripts — so don't tell them to rewrite their game.
|
|
484
|
+
const toolingFailures = preflightResult.violations.filter(
|
|
485
|
+
(v) => v.kind === 'preflight_error' || v.kind === 'preflight_crash',
|
|
486
|
+
);
|
|
487
|
+
const codeViolations = preflightResult.violations.filter((v) => !toolingFailures.includes(v));
|
|
488
|
+
if (codeViolations.length > 0) {
|
|
489
|
+
preflightSpinner.fail(`[${platform}] Preflight found ${codeViolations.length} violation(s).`);
|
|
490
|
+
} else {
|
|
491
|
+
preflightSpinner.fail(`[${platform}] Preflight could not scan the bundle.`);
|
|
492
|
+
}
|
|
475
493
|
console.log();
|
|
476
494
|
for (const v of preflightResult.violations) {
|
|
477
495
|
console.log(chalk.red(` \u2717 ${v.kind}`));
|
|
@@ -481,7 +499,12 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
|
|
|
481
499
|
if (v.reason) console.log(` ${v.reason}`);
|
|
482
500
|
console.log();
|
|
483
501
|
}
|
|
484
|
-
|
|
502
|
+
if (codeViolations.length > 0) {
|
|
503
|
+
console.log(brand.teal(' Fix these scripts to extend MissionBase / use SDK templates, rebuild, and retry.'));
|
|
504
|
+
} else {
|
|
505
|
+
console.log(brand.teal(' This is a problem with the bundle scanner, not with your game.'));
|
|
506
|
+
console.log(brand.teal(' Rebuild the bundle in Unity and publish again. If it keeps happening, send the message above to the MyVillage team.'));
|
|
507
|
+
}
|
|
485
508
|
console.log();
|
|
486
509
|
return { ok: false };
|
|
487
510
|
} else {
|
package/src/index.js
CHANGED
|
@@ -1010,13 +1010,17 @@ export function run() {
|
|
|
1010
1010
|
.option('--label <label>', 'Class label (classification)')
|
|
1011
1011
|
.option('--target <number>', 'Numeric target (regression)')
|
|
1012
1012
|
.option('--split <split>', 'train | val', 'train')
|
|
1013
|
+
.option('--replace', 'With --file: replace the working set instead of adding to it')
|
|
1014
|
+
.option('--source <source>', 'Provenance source stamped on each row (e.g. import:csv)')
|
|
1015
|
+
.option('--feature-names <list>', 'Tabular column names (JSON array or comma-separated)')
|
|
1013
1016
|
.option('--json', 'Output raw JSON')
|
|
1014
1017
|
.action(datasetAppendCommand);
|
|
1015
1018
|
|
|
1016
1019
|
datasetCmd
|
|
1017
1020
|
.command('show <slug>')
|
|
1018
1021
|
.description('Show a dataset manifest and a sample of rows')
|
|
1019
|
-
.option('--limit <n>', 'Number of sample rows to show', '5')
|
|
1022
|
+
.option('--limit <n>', 'Number of sample rows to show (0 = all)', '5')
|
|
1023
|
+
.option('--frozen <n>', 'Read frozen version n instead of the working set')
|
|
1020
1024
|
.option('--json', 'Output raw JSON')
|
|
1021
1025
|
.action(datasetShowCommand);
|
|
1022
1026
|
|
|
@@ -202,23 +202,50 @@ export function createDataset({ name, modality, taskType = 'classification', lab
|
|
|
202
202
|
* Append rows to a dataset's working set, stamping provenance, validating
|
|
203
203
|
* against the dataset's modality/task, and updating manifest stats.
|
|
204
204
|
*
|
|
205
|
+
* Every row is validated before anything is written, so a bad row leaves the
|
|
206
|
+
* dataset untouched. With `replace`, the working set is overwritten instead of
|
|
207
|
+
* extended (frozen versions are never touched) and the label list is rebuilt
|
|
208
|
+
* from the new rows. `featureNames` names the columns of a tabular vector
|
|
209
|
+
* (e.g. from a spreadsheet header); once set, every tabular row must have
|
|
210
|
+
* exactly that many numbers.
|
|
211
|
+
*
|
|
205
212
|
* @param {string} slug
|
|
206
213
|
* @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
|
|
207
214
|
* @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
|
|
208
|
-
* @
|
|
215
|
+
* @param {{replace?:boolean, featureNames?:string[]}} opts
|
|
216
|
+
* @returns {{datasetId:string, appended:number, replaced:boolean, stats:object}}
|
|
209
217
|
*/
|
|
210
|
-
export function appendRows(slug, rows, provenance = {}) {
|
|
218
|
+
export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames } = {}) {
|
|
211
219
|
const manifest = readManifest(slug);
|
|
212
220
|
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
213
221
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
214
222
|
throw new Error('No rows to append.');
|
|
215
223
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
if (featureNames !== undefined) {
|
|
225
|
+
applyFeatureNames(manifest, featureNames, replace);
|
|
226
|
+
} else if (replace) {
|
|
227
|
+
delete manifest.featureNames;
|
|
228
|
+
}
|
|
219
229
|
|
|
220
|
-
|
|
221
|
-
|
|
230
|
+
const existing = replace ? [] : readRows(slug);
|
|
231
|
+
// Without named columns, hold tabular rows to the width already on disk
|
|
232
|
+
// (the trainer needs every vector the same length).
|
|
233
|
+
let width = manifest.featureNames?.length;
|
|
234
|
+
if (width == null && manifest.modality === 'tabular') {
|
|
235
|
+
const first = existing.find((r) => Array.isArray(r.features));
|
|
236
|
+
if (first) width = first.features.length;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const source = provenance.source || 'cli';
|
|
240
|
+
const newRows = [];
|
|
241
|
+
rows.forEach((raw, i) => {
|
|
242
|
+
let row;
|
|
243
|
+
try {
|
|
244
|
+
row = normalizeRow(raw, manifest, width);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
throw new Error(rows.length > 1 ? `Row ${i + 1}: ${e.message}` : e.message);
|
|
247
|
+
}
|
|
248
|
+
if (width == null && Array.isArray(row.features)) width = row.features.length;
|
|
222
249
|
row.id = randomUUID();
|
|
223
250
|
row.provenance = {
|
|
224
251
|
source,
|
|
@@ -227,24 +254,47 @@ export function appendRows(slug, rows, provenance = {}) {
|
|
|
227
254
|
...(provenance.villageId ? { villageId: provenance.villageId } : {}),
|
|
228
255
|
ts: nowISO(),
|
|
229
256
|
};
|
|
230
|
-
|
|
257
|
+
newRows.push(row);
|
|
258
|
+
});
|
|
231
259
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
manifest.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
260
|
+
if (replace) manifest.labels = [];
|
|
261
|
+
for (const row of newRows) {
|
|
262
|
+
if (row.label != null && !manifest.labels.includes(row.label)) manifest.labels.push(row.label);
|
|
263
|
+
}
|
|
264
|
+
if (replace) {
|
|
265
|
+
writeWorkingSet(slug, manifest, newRows);
|
|
266
|
+
} else {
|
|
267
|
+
appendFileSync(getCurrentDataFile(slug), newRows.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
268
|
+
manifest.stats = recomputeStats([...existing, ...newRows]);
|
|
269
|
+
writeManifest(slug, manifest);
|
|
240
270
|
}
|
|
271
|
+
return { datasetId: slug, appended: newRows.length, replaced: replace, stats: manifest.stats };
|
|
272
|
+
}
|
|
241
273
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return
|
|
274
|
+
/** Swap the whole working set for `rows` (e.g. a spreadsheet import). */
|
|
275
|
+
export function replaceRows(slug, rows, provenance = {}, { featureNames } = {}) {
|
|
276
|
+
return appendRows(slug, rows, provenance, { replace: true, featureNames });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Set a tabular dataset's column names, refusing a mismatch on append. */
|
|
280
|
+
function applyFeatureNames(manifest, featureNames, replace) {
|
|
281
|
+
if (manifest.modality !== 'tabular') {
|
|
282
|
+
throw new Error('Feature names only apply to tabular datasets.');
|
|
283
|
+
}
|
|
284
|
+
const names = featureNames.map((n) => String(n).trim());
|
|
285
|
+
if (names.length === 0 || names.some((n) => !n)) {
|
|
286
|
+
throw new Error('Feature names must be non-empty.');
|
|
287
|
+
}
|
|
288
|
+
const current = manifest.featureNames;
|
|
289
|
+
if (!replace && current?.length && current.join('\u0000') !== names.join('\u0000')) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`These columns don't match the dataset. It expects: ${current.join(', ')}.`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
manifest.featureNames = names;
|
|
245
295
|
}
|
|
246
296
|
|
|
247
|
-
function normalizeRow(raw, manifest) {
|
|
297
|
+
function normalizeRow(raw, manifest, width) {
|
|
248
298
|
if (raw == null || typeof raw !== 'object') {
|
|
249
299
|
throw new Error('Each row must be an object.');
|
|
250
300
|
}
|
|
@@ -266,6 +316,18 @@ function normalizeRow(raw, manifest) {
|
|
|
266
316
|
);
|
|
267
317
|
}
|
|
268
318
|
}
|
|
319
|
+
if (manifest.modality === 'tabular') {
|
|
320
|
+
const f = raw.features;
|
|
321
|
+
if (!Array.isArray(f) || f.length === 0 || f.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
|
|
322
|
+
throw new Error('Tabular rows need features that are all numbers (e.g. [12, 45, 8]).');
|
|
323
|
+
}
|
|
324
|
+
if (width != null && f.length !== width) {
|
|
325
|
+
throw new Error(`Expected ${width} number(s) in each row, got ${f.length}.`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (manifest.modality === 'text' && (typeof raw.features !== 'string' || !raw.features.trim())) {
|
|
329
|
+
throw new Error('Text rows need some text.');
|
|
330
|
+
}
|
|
269
331
|
const row = { features: raw.features, split };
|
|
270
332
|
if (manifest.taskType === 'classification') {
|
|
271
333
|
if (raw.label == null || String(raw.label).length === 0) {
|
|
@@ -273,7 +335,7 @@ function normalizeRow(raw, manifest) {
|
|
|
273
335
|
}
|
|
274
336
|
row.label = String(raw.label);
|
|
275
337
|
} else {
|
|
276
|
-
if (raw.target == null || Number.
|
|
338
|
+
if (raw.target == null || raw.target === '' || !Number.isFinite(Number(raw.target))) {
|
|
277
339
|
throw new Error('Regression rows require a numeric "target".');
|
|
278
340
|
}
|
|
279
341
|
row.target = Number(raw.target);
|
|
@@ -376,6 +438,7 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
376
438
|
if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
|
|
377
439
|
throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
|
|
378
440
|
}
|
|
441
|
+
const sibling = rows.find((r, i) => i !== idx && Array.isArray(r.features));
|
|
379
442
|
const merged = normalizeRow(
|
|
380
443
|
{
|
|
381
444
|
features: updates.features !== undefined ? updates.features : existing.features,
|
|
@@ -384,6 +447,7 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
384
447
|
target: updates.target !== undefined ? updates.target : existing.target,
|
|
385
448
|
},
|
|
386
449
|
manifest,
|
|
450
|
+
manifest.featureNames?.length ?? sibling?.features.length,
|
|
387
451
|
);
|
|
388
452
|
merged.id = existing.id;
|
|
389
453
|
merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
|
package/src/utils/preflight.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import { existsSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs';
|
|
3
|
-
import { tmpdir } from 'os';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs';
|
|
3
|
+
import { homedir, tmpdir } from 'os';
|
|
4
4
|
import { dirname, join, resolve } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { getGameKitAllowlist } from './api.js';
|
|
@@ -14,6 +14,91 @@ const PREFLIGHT_SCRIPT = resolve(__dirname, '../../tools/preflight/preflight.py'
|
|
|
14
14
|
|
|
15
15
|
const PYTHON_CANDIDATES = ['python3', 'python'];
|
|
16
16
|
|
|
17
|
+
// The bundle scanner needs UnityPy. Rather than asking every game developer to
|
|
18
|
+
// pip-install it by hand (the #1 reason deploys failed with "preflight_error"),
|
|
19
|
+
// we install it once into a private venv under ~/.myvillage and reuse it.
|
|
20
|
+
const UNITYPY_SPEC = 'UnityPy==1.25.0';
|
|
21
|
+
const PREFLIGHT_VENV_DIR = join(homedir(), '.myvillage', 'tools', 'preflight-venv');
|
|
22
|
+
|
|
23
|
+
function venvPython(dir) {
|
|
24
|
+
return process.platform === 'win32' ? join(dir, 'Scripts', 'python.exe') : join(dir, 'bin', 'python');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Run a command, capture output, never throw. */
|
|
28
|
+
function runCapture(cmd, args) {
|
|
29
|
+
return new Promise((resolveRun) => {
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
let proc;
|
|
33
|
+
try {
|
|
34
|
+
proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
resolveRun({ code: -1, stdout, stderr: err.message });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
proc.stdout.on('data', (b) => { stdout += b.toString(); });
|
|
40
|
+
proc.stderr.on('data', (b) => { stderr += b.toString(); });
|
|
41
|
+
proc.on('error', (err) => resolveRun({ code: -1, stdout, stderr: err.message }));
|
|
42
|
+
proc.on('exit', (code) => resolveRun({ code, stdout, stderr }));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function hasUnityPy(python) {
|
|
47
|
+
const r = await runCapture(python, ['-c', 'import UnityPy']);
|
|
48
|
+
return r.code === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lastLine(text) {
|
|
52
|
+
const lines = (text || '').trim().split(/\r?\n/).filter(Boolean);
|
|
53
|
+
return lines.length ? lines[lines.length - 1] : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find a Python that can import UnityPy, installing it if needed.
|
|
58
|
+
*
|
|
59
|
+
* Order: our managed venv → a system Python that already has UnityPy →
|
|
60
|
+
* create the managed venv with the system Python and pip-install UnityPy.
|
|
61
|
+
* `onStatus(text)` is called with progress copy for the caller's spinner.
|
|
62
|
+
*
|
|
63
|
+
* Returns { python } on success, or { python: null, reason } when there's no
|
|
64
|
+
* usable interpreter — the caller decides whether that skips or blocks.
|
|
65
|
+
*/
|
|
66
|
+
export async function ensurePreflightPython({ onStatus } = {}) {
|
|
67
|
+
const managed = venvPython(PREFLIGHT_VENV_DIR);
|
|
68
|
+
if (existsSync(managed) && await hasUnityPy(managed)) return { python: managed };
|
|
69
|
+
|
|
70
|
+
const system = await findPython();
|
|
71
|
+
if (!system) {
|
|
72
|
+
return {
|
|
73
|
+
python: null,
|
|
74
|
+
reason: 'Python 3 was not found on your PATH. Install Python 3.9+ from python.org (on Windows, tick "Add to PATH"), then publish again.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (await hasUnityPy(system)) return { python: system };
|
|
78
|
+
|
|
79
|
+
const manualHint = `${system} -m pip install "${UNITYPY_SPEC}"`;
|
|
80
|
+
|
|
81
|
+
onStatus?.('First publish from this computer: setting up the bundle scanner (one-time, about a minute)...');
|
|
82
|
+
mkdirSync(PREFLIGHT_VENV_DIR, { recursive: true });
|
|
83
|
+
const venv = await runCapture(system, ['-m', 'venv', PREFLIGHT_VENV_DIR]);
|
|
84
|
+
if (venv.code !== 0 || !existsSync(managed)) {
|
|
85
|
+
return {
|
|
86
|
+
python: null,
|
|
87
|
+
reason: `Could not create a Python environment at ${PREFLIGHT_VENV_DIR} (${lastLine(venv.stderr) || `exit ${venv.code}`}). Repair your Python install or run: ${manualHint}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onStatus?.('Installing the bundle scanner (UnityPy)...');
|
|
92
|
+
const pip = await runCapture(managed, ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', UNITYPY_SPEC]);
|
|
93
|
+
if (pip.code !== 0 || !(await hasUnityPy(managed))) {
|
|
94
|
+
return {
|
|
95
|
+
python: null,
|
|
96
|
+
reason: `Could not install ${UNITYPY_SPEC} (${lastLine(pip.stderr) || `exit ${pip.code}`}). Check your network connection and retry, or run: ${manualHint}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { python: managed };
|
|
100
|
+
}
|
|
101
|
+
|
|
17
102
|
/**
|
|
18
103
|
* Find the first usable Python interpreter on PATH.
|
|
19
104
|
* Returns the command name (e.g. "python3") or null if none works.
|
|
@@ -94,11 +179,11 @@ async function runPreflight(python, bundlePath, allowlistPath) {
|
|
|
94
179
|
* Run preflight against all AssetBundles in a build directory.
|
|
95
180
|
*
|
|
96
181
|
* Returns:
|
|
97
|
-
* { skipped: true, reason } — bundles couldn't be checked (no SDK, no Python, etc.)
|
|
182
|
+
* { skipped: true, reason } — bundles couldn't be checked (no SDK, no usable Python, etc.)
|
|
98
183
|
* { ok: true, bundlesChecked } — all bundles passed
|
|
99
184
|
* { ok: false, violations, bundle } — at least one violation found
|
|
100
185
|
*/
|
|
101
|
-
export async function preflightBundles({ buildDir, sdkVersion, platform }) {
|
|
186
|
+
export async function preflightBundles({ buildDir, sdkVersion, platform, onStatus }) {
|
|
102
187
|
if (!sdkVersion) {
|
|
103
188
|
return { skipped: true, reason: 'No sdkVersion in myvillage.json — preflight only runs for SDK-based games.' };
|
|
104
189
|
}
|
|
@@ -109,12 +194,13 @@ export async function preflightBundles({ buildDir, sdkVersion, platform }) {
|
|
|
109
194
|
return { skipped: true, reason: `preflight.py not found at ${PREFLIGHT_SCRIPT} — CLI install may be incomplete.` };
|
|
110
195
|
}
|
|
111
196
|
|
|
112
|
-
|
|
197
|
+
// No usable Python (or UnityPy couldn't be installed) is a *tooling* gap on
|
|
198
|
+
// the developer's machine, never a problem with their game. Skip with a clear
|
|
199
|
+
// warning instead of blocking — the same outcome for "no Python at all" and
|
|
200
|
+
// "Python but no UnityPy", which used to diverge (one skipped, one failed).
|
|
201
|
+
const { python, reason: pythonReason } = await ensurePreflightPython({ onStatus });
|
|
113
202
|
if (!python) {
|
|
114
|
-
return {
|
|
115
|
-
skipped: true,
|
|
116
|
-
reason: 'Python 3 not found on PATH. Install Python 3.9+ and then run: pip install UnityPy==1.25.0',
|
|
117
|
-
};
|
|
203
|
+
return { skipped: true, reason: `${pythonReason} (Bundle scan skipped; the platform re-checks on upload.)` };
|
|
118
204
|
}
|
|
119
205
|
|
|
120
206
|
let allowlistResponse;
|
package/src/utils/templates.js
CHANGED
|
@@ -12,7 +12,7 @@ const GAME_ART_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets
|
|
|
12
12
|
|
|
13
13
|
// GameKit SDK version that scaffolded Unity projects pin to.
|
|
14
14
|
// Bump alongside CLI minor releases as the SDK contract evolves.
|
|
15
|
-
const GAMEKIT_SDK_VERSION = '1.1.0-alpha.
|
|
15
|
+
const GAMEKIT_SDK_VERSION = '1.1.0-alpha.5';
|
|
16
16
|
const GAMEKIT_SDK_URL =
|
|
17
17
|
`https://github.com/MyVillage-Project-Technologies/myvillage-gamekit.git#v${GAMEKIT_SDK_VERSION}`;
|
|
18
18
|
|
|
@@ -1302,7 +1302,7 @@ export async function createUnityGameProject(targetDir, options) {
|
|
|
1302
1302
|
// Unity project files. When the SDK is embedded it must NOT also be listed as
|
|
1303
1303
|
// a manifest dependency (Unity would try to re-resolve it); when it isn't, the
|
|
1304
1304
|
// git-URL entry is the fallback resolution path.
|
|
1305
|
-
writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest(sdkEmbedded));
|
|
1305
|
+
writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest(sdkEmbedded, unityVersion));
|
|
1306
1306
|
writeFileSync(join(targetDir, 'ProjectSettings/ProjectVersion.txt'), generateUnityProjectVersion(unityVersion));
|
|
1307
1307
|
|
|
1308
1308
|
// Starter mission. New devs subclass MissionBase here.
|
|
@@ -1367,7 +1367,59 @@ public static class MyVillageOrientation
|
|
|
1367
1367
|
|
|
1368
1368
|
// ── Unity (GameKit SDK) ─────────────────────────────────────────────
|
|
1369
1369
|
|
|
1370
|
-
|
|
1370
|
+
// Unity's built-in engine modules. A project created through Unity Hub lists
|
|
1371
|
+
// every one of these in Packages/manifest.json; a manifest that omits them
|
|
1372
|
+
// DISABLES them. An earlier version of this scaffold wrote only the registry
|
|
1373
|
+
// packages, so fresh projects had no Particle System, Terrain, Animation,
|
|
1374
|
+
// Tilemap, Physics2D, Video, ... and developers had to hand-restore them.
|
|
1375
|
+
const UNITY_BUILTIN_MODULES = [
|
|
1376
|
+
'accessibility',
|
|
1377
|
+
'ai',
|
|
1378
|
+
'androidjni',
|
|
1379
|
+
'animation',
|
|
1380
|
+
'assetbundle',
|
|
1381
|
+
'audio',
|
|
1382
|
+
'cloth',
|
|
1383
|
+
'director',
|
|
1384
|
+
'imageconversion',
|
|
1385
|
+
'imgui',
|
|
1386
|
+
'jsonserialize',
|
|
1387
|
+
'particlesystem',
|
|
1388
|
+
'physics',
|
|
1389
|
+
'physics2d',
|
|
1390
|
+
'screencapture',
|
|
1391
|
+
'terrain',
|
|
1392
|
+
'terrainphysics',
|
|
1393
|
+
'tilemap',
|
|
1394
|
+
'ui',
|
|
1395
|
+
'uielements',
|
|
1396
|
+
'umbra',
|
|
1397
|
+
'unityanalytics',
|
|
1398
|
+
'unitywebrequest',
|
|
1399
|
+
'unitywebrequestassetbundle',
|
|
1400
|
+
'unitywebrequestaudio',
|
|
1401
|
+
'unitywebrequesttexture',
|
|
1402
|
+
'unitywebrequestwww',
|
|
1403
|
+
'vehicles',
|
|
1404
|
+
'video',
|
|
1405
|
+
'vr',
|
|
1406
|
+
'wind',
|
|
1407
|
+
'xr',
|
|
1408
|
+
];
|
|
1409
|
+
|
|
1410
|
+
// Visual Scripting version pinned in the scaffold manifest. Keep in sync with
|
|
1411
|
+
// the `com.unity.visualscripting` dependency in the GameKit's package.json —
|
|
1412
|
+
// GameKit's MyVillage.GameKit.VisualScripting assembly references it, and an
|
|
1413
|
+
// embedded package's own dependency isn't enough on its own for Unity to keep
|
|
1414
|
+
// it resolved, so the manifest lists it explicitly.
|
|
1415
|
+
const UNITY_VISUAL_SCRIPTING_VERSION = '1.9.4';
|
|
1416
|
+
|
|
1417
|
+
function unityMajor(unityVersion) {
|
|
1418
|
+
const m = /^(\d+)\./.exec(unityVersion || '');
|
|
1419
|
+
return m ? Number(m[1]) : 6000;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function generateUnityPackagesManifest(sdkEmbedded = true, unityVersion = UNITY_EDITOR_VERSION_FALLBACK) {
|
|
1371
1423
|
const dependencies = {};
|
|
1372
1424
|
// Only declare the SDK as a git dependency when it could NOT be embedded
|
|
1373
1425
|
// locally. When embedded under Packages/com.myvillage.gamekit/, Unity
|
|
@@ -1378,11 +1430,25 @@ function generateUnityPackagesManifest(sdkEmbedded = true) {
|
|
|
1378
1430
|
Object.assign(dependencies, {
|
|
1379
1431
|
'com.unity.feature.development': '1.0.2',
|
|
1380
1432
|
'com.unity.render-pipelines.universal': '17.2.0',
|
|
1381
|
-
'com.unity.textmeshpro': '3.0.6',
|
|
1382
1433
|
'com.unity.inputsystem': '1.14.2',
|
|
1434
|
+
// GameKit ships Visual Scripting units (CompleteMission, ReadConfig, ...).
|
|
1435
|
+
// Without this package the SDK's VisualScripting assembly can't compile.
|
|
1436
|
+
'com.unity.visualscripting': UNITY_VISUAL_SCRIPTING_VERSION,
|
|
1383
1437
|
'com.unity.ide.rider': '3.0.38',
|
|
1384
1438
|
'com.unity.ide.visualstudio': '2.0.22',
|
|
1385
1439
|
});
|
|
1440
|
+
// Unity 6 folded TextMesh Pro into com.unity.ugui 2.x; declaring the old
|
|
1441
|
+
// standalone com.unity.textmeshpro there triggers a deprecation conflict.
|
|
1442
|
+
// Older editors still need the standalone package.
|
|
1443
|
+
if (unityMajor(unityVersion) >= 6000) {
|
|
1444
|
+
dependencies['com.unity.ugui'] = '2.0.0';
|
|
1445
|
+
} else {
|
|
1446
|
+
dependencies['com.unity.ugui'] = '1.0.0';
|
|
1447
|
+
dependencies['com.unity.textmeshpro'] = '3.0.6';
|
|
1448
|
+
}
|
|
1449
|
+
for (const mod of UNITY_BUILTIN_MODULES) {
|
|
1450
|
+
dependencies[`com.unity.modules.${mod}`] = '1.0.0';
|
|
1451
|
+
}
|
|
1386
1452
|
return JSON.stringify({ dependencies }, null, 2) + '\n';
|
|
1387
1453
|
}
|
|
1388
1454
|
|
|
@@ -16,7 +16,7 @@ Requires **Python 3.9+** and **UnityPy 1.25.0** (specifically — newer versions
|
|
|
16
16
|
pip install -r requirements.txt
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
The CLI
|
|
19
|
+
The CLI finds `python3`/`python` on PATH and, if UnityPy isn't importable, installs it once into a private venv at `~/.myvillage/tools/preflight-venv/` (see `ensurePreflightPython` in `src/utils/preflight.js`). If Python is missing or the install fails, preflight is skipped with a warning and the upload proceeds; the ed-platform re-checks on upload.
|
|
20
20
|
|
|
21
21
|
## Run directly (for debugging)
|
|
22
22
|
|