@myvillage/cli 1.60.1 → 1.62.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.60.1",
3
+ "version": "1.62.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@ import { isAuthenticated, getAccessToken } from '../utils/auth.js';
10
10
  import { getConfig, setConfig } from '../utils/config.js';
11
11
  import {
12
12
  createAgent as apiCreateAgent,
13
+ deleteAgent as apiDeleteAgent,
13
14
  agentJoinCommunity as apiAgentJoinCommunity,
14
15
  listCommunities,
15
16
  getAgentActivity,
@@ -803,11 +804,16 @@ export async function agentDeleteLocalCommand(name) {
803
804
  return;
804
805
  }
805
806
 
807
+ const agentConfig = readAgentConfig(name);
808
+ const remoteAgentId = agentConfig?.man?.agent_id || null;
809
+
806
810
  try {
807
811
  const { confirm } = await inquirer.prompt([{
808
812
  type: 'confirm',
809
813
  name: 'confirm',
810
- message: `Delete local agent "${name}"? This removes all local files and cannot be undone.`,
814
+ message: remoteAgentId
815
+ ? `Delete agent "${name}"? This removes all local files AND deletes the agent on the server. This cannot be undone.`
816
+ : `Delete local agent "${name}"? This removes all local files and cannot be undone.`,
811
817
  default: false,
812
818
  }]);
813
819
 
@@ -833,6 +839,27 @@ export async function agentDeleteLocalCommand(name) {
833
839
  }
834
840
  }
835
841
 
842
+ // Delete the remote AgentProfile first so a server failure stops us
843
+ // before we wipe local files. If the remote is already gone (404),
844
+ // proceed with local cleanup.
845
+ if (remoteAgentId) {
846
+ const remoteSpinner = villageSpinner('Deleting agent on server...').start();
847
+ try {
848
+ await apiDeleteAgent(remoteAgentId);
849
+ remoteSpinner.succeed('Agent deleted on server.');
850
+ } catch (err) {
851
+ const status = err.response?.status;
852
+ const message = err.response?.data?.error || err.response?.data?.message || err.message;
853
+ if (status === 404) {
854
+ remoteSpinner.warn('Agent already removed on server.');
855
+ } else {
856
+ remoteSpinner.fail(`Failed to delete agent on server: ${message}`);
857
+ console.log(brand.teal(' Local files were not removed. Re-run once the server issue is resolved.\n'));
858
+ return;
859
+ }
860
+ }
861
+ }
862
+
836
863
  // Delete directory
837
864
  const { rmSync } = await import('fs');
838
865
  const agentDir = getAgentDir(name);
@@ -15,6 +15,21 @@ export async function createGameCommand(options = {}) {
15
15
  return;
16
16
  }
17
17
 
18
+ // Orientation first — it decides where the game can play.
19
+ // Portrait → muni mobile app + web (mvp-video-games)
20
+ // Landscape → web only (mvp-video-games)
21
+ const { orientation } = await inquirer.prompt([
22
+ {
23
+ type: 'list',
24
+ name: 'orientation',
25
+ message: 'Where will this game play?',
26
+ choices: [
27
+ { name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
28
+ { name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
29
+ ],
30
+ },
31
+ ]);
32
+
18
33
  // Engine choice
19
34
  const { engine } = await inquirer.prompt([
20
35
  {
@@ -22,14 +37,19 @@ export async function createGameCommand(options = {}) {
22
37
  name: 'engine',
23
38
  message: 'Which engine are you using?',
24
39
  choices: [
25
- { name: 'Three.js — Web-based 3D game (runs in browser)', value: 'threejs' },
26
- { name: 'Unity — Mobile or WebGL game (built in Unity Editor)', value: 'unity' },
40
+ { name: 'JS / Three.js — web build (runs in a browser / WebView)', value: 'threejs' },
41
+ {
42
+ name: orientation === 'landscape'
43
+ ? 'Unity — WebGL build (runs in browser)'
44
+ : 'Unity — native build for muni (can also build WebGL for web)',
45
+ value: 'unity',
46
+ },
27
47
  ],
28
48
  },
29
49
  ]);
30
50
 
31
51
  if (engine === 'unity') {
32
- return createUnityGame(options);
52
+ return createUnityGame({ ...options, orientation });
33
53
  }
34
54
 
35
55
  // ── Three.js path (existing flow) ──
@@ -91,6 +111,7 @@ export async function createGameCommand(options = {}) {
91
111
  description: answers.description,
92
112
  type: answers.type,
93
113
  ageGroup: answers.ageGroup,
114
+ orientation,
94
115
  });
95
116
 
96
117
  spinner.text = 'Installing dependencies...';
@@ -112,7 +133,8 @@ export async function createGameCommand(options = {}) {
112
133
  console.log(` ${brand.gold('npm run build')} ${brand.teal('- Build for production')}`);
113
134
  console.log(` ${brand.gold('myvillage deploy')} ${brand.teal('- Deploy to MyVillageOS')}`);
114
135
  console.log();
115
- console.log(brand.teal(` Game type: ${answers.type} | Age group: ${answers.ageGroup}`));
136
+ console.log(brand.teal(` Game type: ${answers.type} | Orientation: ${orientation} | Age group: ${answers.ageGroup}`));
137
+ console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
116
138
  console.log();
117
139
  } catch (err) {
118
140
  spinner.fail('Failed to create game project.');
@@ -123,6 +145,9 @@ export async function createGameCommand(options = {}) {
123
145
  // ── Unity path ──
124
146
 
125
147
  async function createUnityGame(options = {}) {
148
+ // orientation is decided in the top-level wizard; default portrait for Unity.
149
+ const orientation = options.orientation || 'portrait';
150
+
126
151
  const answers = await inquirer.prompt([
127
152
  {
128
153
  type: 'input',
@@ -152,15 +177,6 @@ async function createUnityGame(options = {}) {
152
177
  { name: 'Other', value: 'other' },
153
178
  ],
154
179
  },
155
- {
156
- type: 'list',
157
- name: 'platform',
158
- message: 'Target platform:',
159
- choices: [
160
- { name: 'Mobile (iOS / Android) — AssetBundle loaded by Izi Graham', value: 'mobile' },
161
- { name: 'WebGL — Runs in browser on M-UNI', value: 'webgl' },
162
- ],
163
- },
164
180
  {
165
181
  type: 'list',
166
182
  name: 'ageGroup',
@@ -190,7 +206,7 @@ async function createUnityGame(options = {}) {
190
206
  description: answers.description,
191
207
  type: answers.category,
192
208
  ageGroup: answers.ageGroup,
193
- platform: answers.platform,
209
+ orientation,
194
210
  unityVersion: options.unityVersion,
195
211
  });
196
212
 
@@ -200,6 +216,7 @@ async function createUnityGame(options = {}) {
200
216
  console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
201
217
  console.log(brand.teal(` Unity: ${scaffold.unityVersion}`));
202
218
  console.log(brand.teal(` GameKit SDK: ${scaffold.sdkVersion}`));
219
+ console.log(brand.teal(` Orientation: ${orientation} (${orientation === 'landscape' ? 'web only' : 'muni mobile + web'})`));
203
220
  console.log();
204
221
  console.log(brand.teal(' Next steps:'));
205
222
  console.log();
@@ -209,14 +226,16 @@ async function createUnityGame(options = {}) {
209
226
  console.log(` 4. ${brand.teal('Edit')} ${brand.gold(`Assets/Scripts/${answers.name.replace(/[^a-zA-Z0-9]/g, '')}.cs`)} ${brand.teal('and iterate (hit Play again)')}`);
210
227
  console.log();
211
228
  console.log(brand.teal(' Ready to ship:'));
212
- if (answers.platform === 'mobile') {
213
- console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')}`);
214
- console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform ios')} ${brand.teal('(or android)')}`);
215
- } else {
229
+ if (orientation === 'landscape') {
216
230
  console.log(` 5. ${brand.teal('Build WebGL via')} ${brand.gold('File \u2192 Build Settings \u2192 WebGL \u2192 Build')} ${brand.teal('to')} ${brand.gold('builds/webgl/')}`);
217
231
  console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
232
+ } else {
233
+ console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')} ${brand.teal('(into builds/ios or builds/android)')}`);
234
+ console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy')} ${brand.teal('\u2014 ships every platform found in builds/')}`);
235
+ console.log(` ${brand.teal('Optional: also build WebGL into builds/webgl/ to offer it on the web too.')}`);
218
236
  }
219
237
  console.log();
238
+ console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
220
239
  console.log(brand.teal(` See ${slug}/README.md for the full developer guide.`));
221
240
  console.log();
222
241
  } catch (err) {
@@ -9,6 +9,7 @@ import { villageSpinner, brand } from '../utils/brand.js';
9
9
  import { isAuthenticated } from '../utils/auth.js';
10
10
  import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview } from '../utils/api.js';
11
11
  import { preflightBundles } from '../utils/preflight.js';
12
+ import { selectGameVillages } from '../utils/village-resolver.js';
12
13
 
13
14
  function readPackageJson(dir) {
14
15
  const pkgPath = join(dir, 'package.json');
@@ -48,6 +49,34 @@ function saveGameIdToMyVillageJson(dir, gameId, slug) {
48
49
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
49
50
  }
50
51
 
52
+ // Persist the village selection so re-deploys pre-check the same villages.
53
+ function saveVillages(dir, isUnity, villageIds) {
54
+ if (!Array.isArray(villageIds)) return;
55
+ if (isUnity) {
56
+ const configPath = join(dir, 'myvillage.json');
57
+ if (!existsSync(configPath)) return;
58
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
59
+ config.villages = villageIds;
60
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
61
+ } else {
62
+ const pkgPath = join(dir, 'package.json');
63
+ if (!existsSync(pkgPath)) return;
64
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
65
+ pkg.myvillage = { ...pkg.myvillage, villages: villageIds };
66
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
67
+ }
68
+ }
69
+
70
+ // Gather the submission's village visibility once per deploy. Returns an array
71
+ // of village ids (possibly empty), or null if the developer aborted/failed —
72
+ // the caller should stop the deploy in that case.
73
+ async function gatherVillages(existingIds = []) {
74
+ console.log();
75
+ console.log(brand.teal(' Village visibility — who can see & play this game:'));
76
+ const villageIds = await selectGameVillages(existingIds);
77
+ return villageIds;
78
+ }
79
+
51
80
  // Recursively collect file paths from a directory
52
81
  function collectFilePaths(dir) {
53
82
  const paths = [];
@@ -122,6 +151,11 @@ async function autoSubmitForReview(gameId, deployResult) {
122
151
  // ── Three.js deploy (existing flow) ──
123
152
 
124
153
  async function deployThreeJsGame(projectDir, pkg) {
154
+ // Village visibility is part of the submission — gather it up front (before
155
+ // the build) so it travels in the deploy metadata.
156
+ const villageIds = await gatherVillages(pkg.myvillage.villages || []);
157
+ if (villageIds === null) return; // could not resolve villages — abort
158
+
125
159
  const spinner = villageSpinner('Building game...').start();
126
160
 
127
161
  try {
@@ -155,7 +189,9 @@ async function deployThreeJsGame(projectDir, pkg) {
155
189
  category: (pkg.myvillage.gameType || 'OTHER').toUpperCase(),
156
190
  gameType: 'THREE_JS',
157
191
  targetAge: pkg.myvillage.targetAge || 'all',
192
+ orientation: pkg.myvillage.orientation || 'landscape',
158
193
  entryFile: 'index.html',
194
+ villageIds,
159
195
  };
160
196
  formData.append('metadata', JSON.stringify(metadata));
161
197
 
@@ -191,9 +227,11 @@ async function deployThreeJsGame(projectDir, pkg) {
191
227
  if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
192
228
  if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
193
229
 
194
- // Prompt for thumbnail/banner images
230
+ saveVillages(projectDir, false, villageIds);
231
+
232
+ // Cover art is part of the submission: ship declared assets, require a thumbnail.
195
233
  if (finalGameId) {
196
- await promptForImages(finalGameId);
234
+ await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
197
235
  }
198
236
 
199
237
  console.log();
@@ -260,6 +298,11 @@ async function deployUnityGame(projectDir, mvConfig, options) {
260
298
  }
261
299
 
262
300
  console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
301
+
302
+ // Village visibility is part of the submission — gather once for the whole
303
+ // multi-platform deploy and thread it into every platform's metadata.
304
+ const villageIds = await gatherVillages(mvConfig.villages || []);
305
+ if (villageIds === null) return; // could not resolve villages — abort
263
306
  console.log();
264
307
 
265
308
  // Track final outcome across all platforms.
@@ -269,7 +312,7 @@ async function deployUnityGame(projectDir, mvConfig, options) {
269
312
  let finalTitle = null;
270
313
 
271
314
  for (const platform of platformsToDeploy) {
272
- const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId });
315
+ const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId, villageIds });
273
316
  if (!outcome.ok) return; // bail on first platform failure; nothing else makes sense to ship
274
317
  finalGameId = outcome.gameId || finalGameId;
275
318
  finalSlug = outcome.slug || finalSlug;
@@ -289,8 +332,11 @@ async function deployUnityGame(projectDir, mvConfig, options) {
289
332
  if (finalSlug) console.log(brand.teal(` Slug: ${finalSlug}`));
290
333
  if (finalGameId) console.log(brand.teal(` Game ID: ${finalGameId}`));
291
334
 
335
+ saveVillages(projectDir, true, villageIds);
336
+
337
+ // Cover art is part of the submission: ship declared assets, require a thumbnail.
292
338
  if (finalGameId) {
293
- await promptForImages(finalGameId);
339
+ await submitCoverArt(finalGameId, projectDir, mvConfig);
294
340
  }
295
341
 
296
342
  console.log();
@@ -302,7 +348,7 @@ async function deployUnityGame(projectDir, mvConfig, options) {
302
348
  * Deploy one specific platform. Returns { ok, gameId, slug, status, title } or
303
349
  * { ok: false } and writes a user-readable error to the console.
304
350
  */
305
- async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride }) {
351
+ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride, villageIds }) {
306
352
  const buildDir = join(projectDir, 'builds', platform);
307
353
  const filePaths = collectFilePaths(buildDir);
308
354
 
@@ -352,10 +398,12 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
352
398
  category: (mvConfig.category || 'OTHER').toUpperCase(),
353
399
  gameType,
354
400
  targetAge: mvConfig.targetAge || 'all',
401
+ orientation: mvConfig.orientation || (platform === 'webgl' ? 'landscape' : 'portrait'),
355
402
  buildPlatform: platform,
356
403
  };
357
404
  if (mvConfig.sdkVersion) metadata.sdkVersion = mvConfig.sdkVersion;
358
405
  if (platform === 'webgl') metadata.entryFile = 'index.html';
406
+ if (Array.isArray(villageIds)) metadata.villageIds = villageIds;
359
407
 
360
408
  formData.append('metadata', JSON.stringify(metadata));
361
409
 
@@ -465,49 +513,108 @@ async function uploadImageAsset(gameId, filePath, assetType) {
465
513
  }
466
514
  }
467
515
 
468
- async function promptForImages(gameId) {
516
+ // Read PNG pixel dimensions from the IHDR chunk. Returns {width,height} or null
517
+ // for non-PNG / unreadable files (we only sanity-check PNGs, the scaffold default).
518
+ function readPngSize(filePath) {
519
+ try {
520
+ const buf = readFileSync(filePath);
521
+ // PNG signature + IHDR: width is big-endian uint32 at offset 16, height at 20.
522
+ if (buf.length < 24 || buf[0] !== 0x89 || buf[1] !== 0x50) return null;
523
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+
529
+ // Warn (don't block) if a declared image's shape doesn't match the orientation.
530
+ function warnOnAspectMismatch(filePath, assetType, orientation) {
531
+ const size = readPngSize(filePath);
532
+ if (!size || !size.height) return;
533
+ const ratio = size.width / size.height;
534
+ if (assetType === 'BANNER') {
535
+ if (orientation === 'portrait' && ratio > 1) {
536
+ console.log(chalk.yellow(` ! banner looks landscape (${size.width}×${size.height}); portrait games want a tall 9:16 hero.`));
537
+ } else if (orientation === 'landscape' && ratio < 1) {
538
+ console.log(chalk.yellow(` ! banner looks portrait (${size.width}×${size.height}); landscape games want a wide 16:9 hero.`));
539
+ }
540
+ } else if (assetType === 'THUMBNAIL' && Math.abs(ratio - 1) > 0.2) {
541
+ console.log(chalk.yellow(` ! thumbnail isn't square (${size.width}×${size.height}); cards look best at 1:1.`));
542
+ }
543
+ }
544
+
545
+ // Cover art shipped with the submission. Uploads whatever's declared in
546
+ // myvillage.assets first; only prompts for what's missing. A thumbnail is
547
+ // required — without one the game card renders blank in muni and on the web.
548
+ async function submitCoverArt(gameId, projectDir, config) {
549
+ const orientation = config?.orientation || 'landscape';
550
+ const declared = config?.assets || {};
469
551
  console.log();
470
- const { addThumbnail } = await inquirer.prompt([{
471
- type: 'confirm',
472
- name: 'addThumbnail',
473
- message: 'Upload a thumbnail image? (shown on game cards)',
474
- default: false,
475
- }]);
476
552
 
477
- if (addThumbnail) {
478
- const { thumbnailPath } = await inquirer.prompt([{
479
- type: 'input',
480
- name: 'thumbnailPath',
481
- message: 'Path to thumbnail image:',
482
- validate: (input) => {
483
- if (!input.trim()) return 'Path is required.';
484
- if (!existsSync(resolve(input.trim()))) return 'File not found.';
485
- return true;
486
- },
487
- }]);
488
- await uploadImageAsset(gameId, thumbnailPath.trim(), 'THUMBNAIL');
553
+ // 1. Thumbnail — declared, else prompt (required).
554
+ let thumbUploaded = false;
555
+ if (declared.thumbnail) {
556
+ const p = resolve(projectDir, declared.thumbnail);
557
+ if (existsSync(p)) {
558
+ warnOnAspectMismatch(p, 'THUMBNAIL', orientation);
559
+ thumbUploaded = await uploadImageAsset(gameId, p, 'THUMBNAIL');
560
+ } else {
561
+ console.log(chalk.yellow(` ! Declared thumbnail not found: ${declared.thumbnail}`));
562
+ }
563
+ }
564
+ if (!thumbUploaded) {
565
+ thumbUploaded = await promptForImage(gameId, 'THUMBNAIL', orientation, { required: true });
489
566
  }
490
567
 
491
- const { addBanner } = await inquirer.prompt([{
568
+ // 2. Banner declared, else optional prompt.
569
+ let bannerHandled = false;
570
+ if (declared.banner) {
571
+ const p = resolve(projectDir, declared.banner);
572
+ if (existsSync(p)) {
573
+ warnOnAspectMismatch(p, 'BANNER', orientation);
574
+ await uploadImageAsset(gameId, p, 'BANNER');
575
+ bannerHandled = true;
576
+ } else {
577
+ console.log(chalk.yellow(` ! Declared banner not found: ${declared.banner}`));
578
+ }
579
+ }
580
+ if (!bannerHandled) {
581
+ await promptForImage(gameId, 'BANNER', orientation, { required: false });
582
+ }
583
+ }
584
+
585
+ // Prompt for a single image. `required` thumbnails warn loudly if skipped.
586
+ async function promptForImage(gameId, assetType, orientation, { required }) {
587
+ const label = assetType.toLowerCase();
588
+ const hint = assetType === 'THUMBNAIL'
589
+ ? 'square, shown on game cards'
590
+ : (orientation === 'portrait' ? '9:16 hero for muni' : '16:9 featured hero');
591
+ const { add } = await inquirer.prompt([{
492
592
  type: 'confirm',
493
- name: 'addBanner',
494
- message: 'Upload a banner image? (shown on featured displays)',
495
- default: false,
593
+ name: 'add',
594
+ message: `Upload a ${label} image? (${hint})`,
595
+ default: required,
496
596
  }]);
497
597
 
498
- if (addBanner) {
499
- const { bannerPath } = await inquirer.prompt([{
500
- type: 'input',
501
- name: 'bannerPath',
502
- message: 'Path to banner image:',
503
- validate: (input) => {
504
- if (!input.trim()) return 'Path is required.';
505
- if (!existsSync(resolve(input.trim()))) return 'File not found.';
506
- return true;
507
- },
508
- }]);
509
- await uploadImageAsset(gameId, bannerPath.trim(), 'BANNER');
598
+ if (!add) {
599
+ if (required) {
600
+ console.log(chalk.yellow(` ! No thumbnail — your game card will render blank until you add one with "myvillage game upload-thumbnail <file>".`));
601
+ }
602
+ return false;
510
603
  }
604
+
605
+ const { imgPath } = await inquirer.prompt([{
606
+ type: 'input',
607
+ name: 'imgPath',
608
+ message: `Path to ${label} image:`,
609
+ validate: (input) => {
610
+ if (!input.trim()) return 'Path is required.';
611
+ if (!existsSync(resolve(input.trim()))) return 'File not found.';
612
+ return true;
613
+ },
614
+ }]);
615
+ const p = resolve(imgPath.trim());
616
+ warnOnAspectMismatch(p, assetType, orientation);
617
+ return uploadImageAsset(gameId, p, assetType);
511
618
  }
512
619
 
513
620
  function formatStatus(status) {
@@ -48,7 +48,7 @@ const BRAND = {
48
48
  };
49
49
 
50
50
  export function createGameProject(targetDir, options) {
51
- const { name, description, type, ageGroup } = options;
51
+ const { name, description, type, ageGroup, orientation = 'landscape' } = options;
52
52
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
53
53
 
54
54
  // Create directory structure
@@ -59,6 +59,7 @@ export function createGameProject(targetDir, options) {
59
59
  'public/assets/models',
60
60
  'public/assets/textures',
61
61
  'public/assets/audio',
62
+ 'assets',
62
63
  'src',
63
64
  'src/scenes',
64
65
  'src/components',
@@ -70,13 +71,15 @@ export function createGameProject(targetDir, options) {
70
71
  }
71
72
 
72
73
  // Write common files
73
- writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type, ageGroup));
74
+ writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type, ageGroup, orientation));
74
75
  writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
75
76
  writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
76
77
  writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
77
- writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name));
78
+ writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name, orientation));
79
+ writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
78
80
 
79
81
  // Write base source files
82
+ writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
80
83
  writeFileSync(join(targetDir, 'src/main.js'), generateMainJs(type));
81
84
  writeFileSync(join(targetDir, 'src/scenes/MainScene.js'), generateMainScene(type));
82
85
  writeFileSync(join(targetDir, 'src/utils/InputManager.js'), generateInputManager());
@@ -103,10 +106,14 @@ export function createGameProject(targetDir, options) {
103
106
  // ── Unity Game Project ─────────────────────────────────────────────
104
107
 
105
108
  export async function createUnityGameProject(targetDir, options) {
106
- const { name, description, type, ageGroup, platform, unityVersion: explicitUnityVersion } = options;
109
+ const { name, description, type, ageGroup, orientation = 'portrait', unityVersion: explicitUnityVersion } = options;
107
110
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
108
111
  const className = name.replace(/[^a-zA-Z0-9]/g, '');
109
112
  const unityVersion = explicitUnityVersion || await resolveUnityVersion();
113
+ // Portrait Unity ships a native AssetBundle for the muni mobile app (and can
114
+ // also build WebGL for the web). Landscape Unity is WebGL-only (web).
115
+ const isLandscape = orientation === 'landscape';
116
+ const primaryGameType = isLandscape ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
110
117
 
111
118
  // Scaffold an openable Unity project that pulls com.myvillage.gamekit via UPM.
112
119
  // Layout matches what Unity Hub expects:
@@ -119,8 +126,10 @@ export async function createUnityGameProject(targetDir, options) {
119
126
  '',
120
127
  'Assets',
121
128
  'Assets/Scripts',
129
+ 'Assets/Editor',
122
130
  'Packages',
123
131
  'ProjectSettings',
132
+ 'assets',
124
133
  'builds',
125
134
  'builds/ios',
126
135
  'builds/android',
@@ -137,9 +146,19 @@ export async function createUnityGameProject(targetDir, options) {
137
146
  slug,
138
147
  description,
139
148
  engine: 'unity',
140
- gameType: platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE',
149
+ gameType: primaryGameType,
141
150
  category: type.toUpperCase(),
142
151
  targetAge: ageGroup,
152
+ // Portrait → muni mobile + web; landscape → web only. Hosts frame on this.
153
+ orientation,
154
+ aspectRatio: isLandscape ? '16:9' : '9:16',
155
+ // Cover art shipped with the submission. Drop files at these paths.
156
+ assets: {
157
+ thumbnail: 'assets/thumbnail.png',
158
+ banner: 'assets/banner.png',
159
+ },
160
+ // Villages allowed to see & play the game. Populated by `myvillage deploy`.
161
+ villages: [],
143
162
  sdkVersion: GAMEKIT_SDK_VERSION,
144
163
  unityVersion,
145
164
  gameId: null,
@@ -154,10 +173,56 @@ export async function createUnityGameProject(targetDir, options) {
154
173
  writeFileSync(join(targetDir, `Assets/Scripts/${className}.cs`), generateMissionScript(className, name, unityVersion));
155
174
  writeFileSync(join(targetDir, `Assets/Scripts/${className}.cs.meta`), generateUnityScriptMeta());
156
175
 
176
+ // Editor script that pins PlayerSettings orientation so the project starts
177
+ // correct (portrait for muni) instead of Unity's landscape default — no more
178
+ // hand-converting like the legacy izi-graham project had to.
179
+ writeFileSync(join(targetDir, 'Assets/Editor/MyVillageOrientation.cs'), generateOrientationEditorScript(orientation));
180
+ writeFileSync(join(targetDir, 'Assets/Editor/MyVillageOrientation.cs.meta'), generateUnityScriptMeta());
181
+
182
+ writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
157
183
  writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
158
- writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform, unityVersion));
184
+ writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, orientation, unityVersion));
159
185
 
160
- return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION };
186
+ return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION, gameType: primaryGameType };
187
+ }
188
+
189
+ // Editor script: applies orientation to PlayerSettings on first load so a
190
+ // freshly scaffolded project opens in the right orientation. Runs once per
191
+ // session (guarded by SessionState) and is safe to delete once set.
192
+ function generateOrientationEditorScript(orientation) {
193
+ const isPortrait = orientation !== 'landscape';
194
+ const defaultOrientation = isPortrait ? 'UIOrientation.Portrait' : 'UIOrientation.LandscapeLeft';
195
+ return `using UnityEditor;
196
+ using UnityEngine;
197
+
198
+ // Auto-applies this game's orientation (${orientation}) to PlayerSettings.
199
+ // Generated by the MyVillage CLI. Edit MyVillage > Apply Orientation to re-run.
200
+ [InitializeOnLoad]
201
+ public static class MyVillageOrientation
202
+ {
203
+ const string AppliedKey = "MyVillage.OrientationApplied";
204
+
205
+ static MyVillageOrientation()
206
+ {
207
+ if (!SessionState.GetBool(AppliedKey, false))
208
+ {
209
+ Apply();
210
+ SessionState.SetBool(AppliedKey, true);
211
+ }
212
+ }
213
+
214
+ [MenuItem("MyVillage/Apply Orientation (${orientation})")]
215
+ public static void Apply()
216
+ {
217
+ PlayerSettings.defaultInterfaceOrientation = ${defaultOrientation};
218
+ PlayerSettings.allowedAutorotateToPortrait = ${isPortrait ? 'true' : 'false'};
219
+ PlayerSettings.allowedAutorotateToPortraitUpsideDown = false;
220
+ PlayerSettings.allowedAutorotateToLandscapeLeft = ${isPortrait ? 'false' : 'true'};
221
+ PlayerSettings.allowedAutorotateToLandscapeRight = ${isPortrait ? 'false' : 'true'};
222
+ Debug.Log("[MyVillage] Applied ${orientation} orientation to PlayerSettings.");
223
+ }
224
+ }
225
+ `;
161
226
  }
162
227
 
163
228
  // ── Unity (GameKit SDK) ─────────────────────────────────────────────
@@ -204,6 +269,14 @@ using UnityEngine;
204
269
  ///
205
270
  /// EDIT THIS SCRIPT: replace OnBegin with your game logic and call
206
271
  /// CompleteMission(score) when the player finishes.
272
+ ///
273
+ /// THE GAME CONTRACT (consistent across every MyVillage game):
274
+ /// • DONE — call CompleteMission(score) (or FailMission(reason)) so the host
275
+ /// knows the game is over. This is the native equivalent of the web
276
+ /// games' MyVillageGame.complete().
277
+ /// • EXIT — the M-UNI host always shows an Exit control, so players can leave
278
+ /// at any time. You don't build an exit button; the host owns it and
279
+ /// calls OnEnd() for cleanup when the player exits.
207
280
  /// </summary>
208
281
  public sealed class ${className} : MissionBase
209
282
  {
@@ -272,11 +345,15 @@ function generateUnityGitignore() {
272
345
  ].join('\n') + '\n';
273
346
  }
274
347
 
275
- function generateUnityReadme(name, slug, className, platform, unityVersion) {
276
- const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
277
- const buildHint = platform === 'webgl'
348
+ function generateUnityReadme(name, slug, className, orientation, unityVersion) {
349
+ const isLandscape = orientation === 'landscape';
350
+ const gameType = isLandscape ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
351
+ const reach = isLandscape
352
+ ? 'web only (mvp-video-games)'
353
+ : 'the muni mobile app **and** web (mvp-video-games)';
354
+ const buildHint = isLandscape
278
355
  ? `4. Build to \`builds/webgl/\` via **File -> Build Settings -> WebGL**, then \`myvillage deploy --platform webgl\``
279
- : `4. Build AssetBundles to \`builds/ios/\` or \`builds/android/\` via **MyVillage -> Build Mission Bundle**, then \`myvillage deploy --platform ios\` (or \`android\`)`;
356
+ : `4. Build AssetBundles to \`builds/ios/\` or \`builds/android/\` via **MyVillage -> Build Mission Bundle**, then \`myvillage deploy --platform ios\` (or \`android\`). To also offer this portrait game on the web, build WebGL into \`builds/webgl/\` and it ships under the same game.`;
280
357
 
281
358
  return `# ${name}
282
359
 
@@ -284,6 +361,7 @@ A MyVillage game scaffolded with the MyVillage CLI and the MyVillage GameKit SDK
284
361
 
285
362
  - **Slug:** \`${slug}\`
286
363
  - **Game type:** \`${gameType}\`
364
+ - **Orientation:** \`${orientation}\` — plays on ${reach}
287
365
  - **SDK:** \`com.myvillage.gamekit\` (pinned in \`Packages/manifest.json\`)
288
366
 
289
367
  ## What's in this project
@@ -337,7 +415,7 @@ For configurable missions (quiz questions, level layouts, etc.), create a \`Miss
337
415
 
338
416
  // ── Three.js Templates ─────────────────────────────────────────────
339
417
 
340
- function generatePackageJson(name, description, slug, type, ageGroup) {
418
+ function generatePackageJson(name, description, slug, type, ageGroup, orientation = 'landscape') {
341
419
  return JSON.stringify({
342
420
  name: slug,
343
421
  version: '1.0.0',
@@ -360,11 +438,43 @@ function generatePackageJson(name, description, slug, type, ageGroup) {
360
438
  gameId: null,
361
439
  gameType: type,
362
440
  targetAge: ageGroup,
441
+ // Portrait games run in the muni mobile app AND on the web (mvp-video-games).
442
+ // Landscape games run on the web only. Hosts read this to frame the game.
443
+ orientation,
444
+ aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
445
+ // Declared up front so `myvillage deploy` ships them with the submission.
446
+ // Drop files at these paths (relative to the project root).
447
+ assets: {
448
+ thumbnail: 'assets/thumbnail.png',
449
+ banner: 'assets/banner.png',
450
+ },
451
+ // Villages allowed to see & play the game. Populated by `myvillage deploy`.
452
+ villages: [],
363
453
  lastDeployed: null,
364
454
  },
365
455
  }, null, 2) + '\n';
366
456
  }
367
457
 
458
+ // Drop-zone guidance for cover art that ships with the submission.
459
+ function generateAssetsReadme(orientation) {
460
+ const thumb = orientation === 'portrait'
461
+ ? 'Square (1:1, e.g. 512×512) — shown on game cards in muni and on the web.'
462
+ : 'Square (1:1, e.g. 512×512) — shown on game cards.';
463
+ const banner = orientation === 'portrait'
464
+ ? 'Portrait hero (9:16, e.g. 1080×1920) — the full-bleed muni catalog hero.'
465
+ : 'Landscape hero (16:9, e.g. 1920×1080) — featured banner on the web.';
466
+ return `# Submission art
467
+
468
+ These files ship with \`myvillage deploy\` (declared in the \`myvillage.assets\` block of package.json).
469
+
470
+ - **thumbnail.png** — ${thumb}
471
+ - **banner.png** — ${banner}
472
+
473
+ Replace the placeholders before deploying. The CLI checks aspect ratio against
474
+ this game's orientation (\`${orientation}\`) and warns on a mismatch.
475
+ `;
476
+ }
477
+
368
478
  function generateViteConfig() {
369
479
  // base: './' makes the built HTML use relative asset URLs (e.g. ./assets/main.js
370
480
  // instead of /assets/main.js). The MyVillage host serves games at
@@ -427,6 +537,7 @@ npm run preview
427
537
  \`\`\`
428
538
  src/
429
539
  main.js - Game entry point, sets up Three.js renderer
540
+ myvillage-game.js - MyVillage game bridge (completion + exit contract)
430
541
  scenes/
431
542
  MainScene.js - Main Three.js scene with lighting and camera
432
543
  components/
@@ -435,20 +546,47 @@ src/
435
546
  utils/
436
547
  InputManager.js - Keyboard, mouse, and touch input handling
437
548
  AudioManager.js - Sound effects and background music
438
- public/
439
- index.html - HTML entry point
440
- assets/ - Static assets (models, textures, audio)
549
+ index.html - HTML entry point
550
+ assets/ - Cover art that ships with your submission (thumbnail/banner)
441
551
  \`\`\`
442
552
 
553
+ ## The game contract (works the same in every MyVillage game)
554
+
555
+ Every MyVillage game — web or Unity — follows one contract so players have a
556
+ consistent experience:
557
+
558
+ - **Done / finished** — call \`MyVillageGame.complete({ score })\` when the game is
559
+ over. The host records the session and awards the player this game's MVT reward
560
+ (set by an admin when the game is published). The scaffold already calls this
561
+ from \`Game.endGame()\` in \`src/main.js\` — wire that to your real end condition.
562
+ - **Exit any time** — the host (the muni mobile app or the web player) always
563
+ shows an Exit control, so a player can leave at any moment. You don't build an
564
+ exit button. You *may* trigger \`MyVillageGame.exit()\` yourself (e.g. a "Quit"
565
+ menu item).
566
+ - **Ready** — \`MyVillageGame.ready()\` is called once your game is interactive
567
+ (already wired after asset loading).
568
+
569
+ \`\`\`js
570
+ import { MyVillageGame } from './myvillage-game.js';
571
+
572
+ // when the player wins / the round ends:
573
+ MyVillageGame.complete({ score: 1200, won: true });
574
+ \`\`\`
575
+
576
+ During \`npm run dev\` there's no host attached, so these calls just log to the
577
+ console — that's expected. They take effect once the game runs inside muni or the
578
+ web player.
579
+
443
580
  ## Deploying to MyVillageOS
444
581
 
445
- When your game is ready, deploy it to the MyVillageOS platform:
582
+ Drop your cover art in \`assets/\` (thumbnail.png + banner.png), then:
446
583
 
447
584
  \`\`\`bash
448
585
  myvillage deploy
449
586
  \`\`\`
450
587
 
451
- You'll earn MVT tokens for successful deployments!
588
+ Deploy walks you through village visibility (who can see & play it) and ships your
589
+ cover art with the submission. An admin sets the game's MVT reward at review time.
452
590
 
453
591
  ## Learn More
454
592
 
@@ -458,18 +596,31 @@ You'll earn MVT tokens for successful deployments!
458
596
  `;
459
597
  }
460
598
 
461
- function generateIndexHtml(name) {
599
+ function generateIndexHtml(name, orientation = 'landscape') {
600
+ const isPortrait = orientation === 'portrait';
601
+ // Portrait games are framed by a 9:16 stage so they look right both in the
602
+ // muni mobile shell (portrait WebView) and on the web. Landscape fills the
603
+ // viewport as before.
604
+ const containerStyle = isPortrait
605
+ ? `#game-container {
606
+ position: relative;
607
+ width: min(100vw, calc(100vh * 9 / 16));
608
+ height: min(100vh, calc(100vw * 16 / 9));
609
+ margin: 0 auto;
610
+ }`
611
+ : `#game-container { width: 100%; height: 100%; position: relative; }`;
462
612
  return `<!DOCTYPE html>
463
613
  <html lang="en">
464
614
  <head>
465
615
  <meta charset="UTF-8" />
466
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
616
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
617
+ <!-- orientation: ${orientation} -->
467
618
  <title>${name}</title>
468
619
  <style>
469
620
  * { margin: 0; padding: 0; box-sizing: border-box; }
470
621
  html, body { width: 100%; height: 100%; overflow: hidden; }
471
- body { background: ${BRAND.darkBrown}; }
472
- #game-container { width: 100%; height: 100%; position: relative; }
622
+ body { background: ${BRAND.darkBrown}; display: flex; align-items: center; justify-content: center; }
623
+ ${containerStyle}
473
624
  canvas { display: block; }
474
625
 
475
626
  /* Loading screen */
@@ -532,6 +683,7 @@ import { InputManager } from './utils/InputManager.js';
532
683
  import { AudioManager } from './utils/AudioManager.js';
533
684
  import { GameLogic } from './components/GameLogic.js';
534
685
  import { UI } from './components/UI.js';
686
+ import { MyVillageGame } from './myvillage-game.js';
535
687
 
536
688
  class Game {
537
689
  constructor() {
@@ -567,6 +719,8 @@ class Game {
567
719
  this.state = 'menu';
568
720
  this.hideLoadingScreen();
569
721
  this.ui.showMenu();
722
+ // Tell the MyVillage host the game is loaded and interactive.
723
+ MyVillageGame.ready();
570
724
  });
571
725
 
572
726
  // Start game loop
@@ -620,6 +774,12 @@ class Game {
620
774
  endGame(result) {
621
775
  this.state = 'gameover';
622
776
  this.ui.showGameOver(result);
777
+ // REQUIRED: tell the MyVillage host the game is finished. The host records
778
+ // the session, awards MVT, and returns the player to the catalog.
779
+ MyVillageGame.complete({
780
+ score: result?.score,
781
+ won: result?.won,
782
+ });
623
783
  }
624
784
 
625
785
  animate() {
@@ -644,6 +804,92 @@ window.game = game;
644
804
  `;
645
805
  }
646
806
 
807
+ // The universal MyVillage game contract (the "mvgame" protocol). Every
808
+ // scaffolded game ships this verbatim so completion + exit behave identically
809
+ // across the muni mobile app, the web (mvp-video-games), and standalone dev.
810
+ function generateMyVillageGameSdk() {
811
+ return `// MyVillage Game Bridge — the "mvgame" protocol (v1).
812
+ //
813
+ // ONE contract for every MyVillage game, on every surface:
814
+ // - muni mobile app (React Native WebView)
815
+ // - the web (mvp-video-games, in an iframe)
816
+ // - standalone dev (logs to the console)
817
+ //
818
+ // Two calls every game makes:
819
+ // MyVillageGame.ready() // once, when your game is interactive
820
+ // MyVillageGame.complete({ score }) // when the game is finished / won / over
821
+ //
822
+ // One the host ALWAYS provides, but you can trigger too:
823
+ // MyVillageGame.exit() // leave the game
824
+ //
825
+ // The host always renders an Exit control, so a player can leave ANY game at
826
+ // any time even if the game never calls exit() itself. Mirrors the native Unity
827
+ // contract (CompleteMission / abort) so there is a single mental model.
828
+
829
+ const PROTOCOL = 'mvgame';
830
+ const VERSION = 1;
831
+
832
+ function now() {
833
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
834
+ }
835
+
836
+ function post(message) {
837
+ const payload = { source: PROTOCOL, version: VERSION, ...message };
838
+ const json = JSON.stringify(payload);
839
+ try {
840
+ // React Native WebView (muni mobile app)
841
+ if (typeof window !== 'undefined' && window.ReactNativeWebView) {
842
+ window.ReactNativeWebView.postMessage(json);
843
+ }
844
+ // Iframe parent (mvp-video-games web host) — only if actually embedded
845
+ if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
846
+ window.parent.postMessage(payload, '*');
847
+ }
848
+ // Standalone dev: nothing is listening, so surface it for debugging.
849
+ if (typeof window !== 'undefined' && !window.ReactNativeWebView && window.parent === window) {
850
+ console.info('[mvgame] (no host attached) ' + json);
851
+ }
852
+ } catch (e) {
853
+ console.warn('[mvgame] postMessage failed', e);
854
+ }
855
+ }
856
+
857
+ let readySent = false;
858
+ let startedAt = now();
859
+
860
+ export const MyVillageGame = {
861
+ /** Call once when the game has loaded and is interactive. */
862
+ ready() {
863
+ if (readySent) return;
864
+ readySent = true;
865
+ startedAt = now();
866
+ post({ type: 'ready' });
867
+ },
868
+
869
+ /**
870
+ * Call when the game is finished. \`result\` is optional:
871
+ * { score?: number, rewards?: number, won?: boolean, custom?: object }
872
+ * \`durationMs\` is added automatically.
873
+ */
874
+ complete(result = {}) {
875
+ post({ type: 'complete', durationMs: Math.round(now() - startedAt), ...result });
876
+ },
877
+
878
+ /** Request to leave the game. The host also always offers an Exit control. */
879
+ exit() {
880
+ post({ type: 'exit' });
881
+ },
882
+ };
883
+
884
+ // Expose globally too, for non-module games and quick debugging.
885
+ if (typeof window !== 'undefined') {
886
+ window.MyVillageGame = MyVillageGame;
887
+ }
888
+
889
+ export default MyVillageGame;
890
+ `;
891
+ }
892
+
647
893
  function generateMainScene(type) {
648
894
  return `// Main Three.js Scene
649
895
  // Sets up camera, lighting, and the 3D environment.
@@ -9,6 +9,71 @@ import {
9
9
  joinCommunity,
10
10
  } from './api.js';
11
11
 
12
+ /**
13
+ * Resolve the authenticated developer's villager UUID from stored credentials,
14
+ * falling back to a lookup by villager_id. Returns the UUID or null.
15
+ */
16
+ async function resolveVillagerUuid(creds) {
17
+ let villagerUuid = creds.villager_uuid;
18
+ if (!villagerUuid && creds.villager_id) {
19
+ const result = await getVillagerByVillagerId(creds.villager_id);
20
+ villagerUuid = result?.data?.id || result?.id;
21
+ }
22
+ return villagerUuid || null;
23
+ }
24
+
25
+ /**
26
+ * Villages the authenticated developer has access to. Returns [] on any failure
27
+ * so callers can degrade gracefully.
28
+ */
29
+ export async function getMyVillages() {
30
+ const creds = loadCredentials();
31
+ if (!creds) return [];
32
+ const villagerUuid = await resolveVillagerUuid(creds);
33
+ if (!villagerUuid) return [];
34
+ const villagesResult = await getVillagerVillages(villagerUuid);
35
+ return villagesResult?.data || [];
36
+ }
37
+
38
+ /**
39
+ * Prompt the developer to pick which of their villages can see & play a game.
40
+ * Multi-select. `existingIds` pre-checks the previous selection on re-deploy.
41
+ *
42
+ * Returns an array of village ids, or null if villages could not be loaded
43
+ * (caller decides whether to proceed). An empty array means "no villages" — the
44
+ * developer isn't in any, so the game ships without village scoping.
45
+ */
46
+ export async function selectGameVillages(existingIds = []) {
47
+ const spinner = villageSpinner('Loading your villages...').start();
48
+ let villages;
49
+ try {
50
+ villages = await getMyVillages();
51
+ } catch (err) {
52
+ spinner.fail(`Could not load your villages: ${err.message}`);
53
+ return null;
54
+ }
55
+ spinner.stop();
56
+
57
+ if (!villages.length) {
58
+ console.log(chalk.yellow(' You are not a member of any villages — the game will be submitted without village scoping.'));
59
+ return [];
60
+ }
61
+
62
+ const { selected } = await inquirer.prompt([{
63
+ type: 'checkbox',
64
+ name: 'selected',
65
+ message: 'Which villages can see & play this game? (space to toggle, enter to confirm)',
66
+ choices: villages.map((v) => ({
67
+ name: `${v.name}${v.city ? ` (${v.city}${v.state ? ', ' + v.state : ''})` : ''}`,
68
+ value: v.id,
69
+ checked: existingIds.includes(v.id),
70
+ })),
71
+ validate: (ans) => (ans.length > 0 ? true : 'Select at least one village (space to toggle).'),
72
+ }]);
73
+
74
+ return selected;
75
+ }
76
+
12
77
  /**
13
78
  * Resolves a villager's village context: which village they're acting on behalf of
14
79
  * and the corresponding MAN community.