@myvillage/cli 1.8.5 → 1.10.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.
@@ -66,6 +66,610 @@ export function createGameProject(targetDir, options) {
66
66
  }
67
67
  }
68
68
 
69
+ // ── Unity Game Project ─────────────────────────────────────────────
70
+
71
+ export function createUnityGameProject(targetDir, options) {
72
+ const { name, description, type, ageGroup, platform } = options;
73
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
74
+ const className = name.replace(/[^a-zA-Z0-9]/g, '');
75
+
76
+ const dirs = [
77
+ '',
78
+ 'Scripts',
79
+ 'Scripts/MyVillageOS',
80
+ 'builds',
81
+ 'builds/ios',
82
+ 'builds/android',
83
+ 'builds/webgl',
84
+ ];
85
+
86
+ for (const dir of dirs) {
87
+ mkdirSync(join(targetDir, dir), { recursive: true });
88
+ }
89
+
90
+ // Config file (CLI reads this instead of package.json for Unity projects)
91
+ writeFileSync(join(targetDir, 'myvillage.json'), JSON.stringify({
92
+ name,
93
+ slug,
94
+ description,
95
+ engine: 'unity',
96
+ gameType: platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE',
97
+ category: type.toUpperCase(),
98
+ targetAge: ageGroup,
99
+ gameId: null,
100
+ lastDeployed: null,
101
+ }, null, 2) + '\n');
102
+
103
+ writeFileSync(join(targetDir, '.gitignore'), [
104
+ 'builds/*/*.meta', 'Library/', 'Temp/', 'Logs/', 'obj/',
105
+ 'UserSettings/', '.vs/', '*.csproj', '*.sln',
106
+ ].join('\n') + '\n');
107
+
108
+ // MyVillageOS integration scripts
109
+ writeFileSync(join(targetDir, 'Scripts/MyVillageOS/EdPlatformConfig.cs'), generateEdPlatformConfig());
110
+ writeFileSync(join(targetDir, 'Scripts/MyVillageOS/GameSessionReporter.cs'), generateGameSessionReporter());
111
+ writeFileSync(join(targetDir, 'Scripts/MyVillageOS/MyVillageAuth.cs'), generateMyVillageAuth());
112
+ writeFileSync(join(targetDir, 'Scripts/MyVillageOS/PKCEHelper.cs'), generatePKCEHelper());
113
+ writeFileSync(join(targetDir, 'Scripts/MyVillageOS/MyVillageEvents.cs'), generateMyVillageEvents());
114
+
115
+ // Game-specific loader template
116
+ writeFileSync(join(targetDir, `Scripts/${className}Loader.cs`), generateGameLoader(className, slug));
117
+
118
+ // README
119
+ writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform));
120
+ }
121
+
122
+ function generateEdPlatformConfig() {
123
+ return `using UnityEngine;
124
+ using UnityEngine.Networking;
125
+
126
+ /// <summary>
127
+ /// MyVillageOS ed-platform API configuration.
128
+ /// All endpoints use dynamic game slugs — no hardcoded game IDs.
129
+ /// </summary>
130
+ public static class EdPlatformConfig
131
+ {
132
+ private const string ProductionUrl = "https://portal.myvillageproject.ai";
133
+ private const string DevelopmentUrl = "http://localhost:3000";
134
+
135
+ public static string BaseUrl
136
+ {
137
+ get
138
+ {
139
+ #if UNITY_EDITOR || DEVELOPMENT_BUILD
140
+ return DevelopmentUrl;
141
+ #else
142
+ return ProductionUrl;
143
+ #endif
144
+ }
145
+ }
146
+
147
+ // Game catalog
148
+ public static string GamesListEndpoint => $"{BaseUrl}/api/v1/games";
149
+
150
+ // Per-game sessions
151
+ public static string SessionsEndpoint(string gameSlug) =>
152
+ $"{BaseUrl}/api/v1/games/{gameSlug}/sessions";
153
+
154
+ public static string SessionCompleteEndpoint(string gameSlug, string sessionId) =>
155
+ $"{SessionsEndpoint(gameSlug)}/{sessionId}/complete";
156
+
157
+ // Bundle URLs
158
+ public static string BundleUrl(string gameSlug, string bundleName)
159
+ {
160
+ string escaped = UnityWebRequest.EscapeURL(bundleName.ToLower());
161
+ return $"{BaseUrl}/api/v1/games/game-assets/games/{gameSlug}/bundles/{CurrentPlatform}/{escaped}";
162
+ }
163
+
164
+ public static string CurrentPlatform
165
+ {
166
+ get
167
+ {
168
+ #if UNITY_IOS
169
+ return "ios";
170
+ #elif UNITY_ANDROID
171
+ return "android";
172
+ #elif UNITY_WEBGL
173
+ return "webgl";
174
+ #else
175
+ return "desktop";
176
+ #endif
177
+ }
178
+ }
179
+ }
180
+ `;
181
+ }
182
+
183
+ function generateGameSessionReporter() {
184
+ return `using UnityEngine;
185
+ using UnityEngine.Networking;
186
+ using System;
187
+ using System.Collections;
188
+ using System.Text;
189
+
190
+ /// <summary>
191
+ /// Manages game session lifecycle with the MyVillageOS ed-platform.
192
+ /// Attach to a persistent GameObject. Call StartSession() when gameplay
193
+ /// begins and CompleteSession() when the player finishes.
194
+ /// </summary>
195
+ public class GameSessionReporter : MonoBehaviour
196
+ {
197
+ public static GameSessionReporter Instance { get; private set; }
198
+
199
+ [Header("Game Settings")]
200
+ [Tooltip("Your game's slug on the ed-platform (e.g. 'eternal-equations')")]
201
+ public string GameSlug;
202
+
203
+ private string currentSessionId;
204
+ private float sessionStartTime;
205
+
206
+ void Awake()
207
+ {
208
+ if (Instance != null && Instance != this) { Destroy(gameObject); return; }
209
+ Instance = this;
210
+ DontDestroyOnLoad(gameObject);
211
+ }
212
+
213
+ /// <summary>Start a new play session. Call when gameplay begins.</summary>
214
+ public void StartSession()
215
+ {
216
+ if (string.IsNullOrEmpty(GameSlug))
217
+ {
218
+ Debug.LogWarning("[MyVillageOS] GameSlug not set on GameSessionReporter");
219
+ return;
220
+ }
221
+ StartCoroutine(StartSessionCoroutine());
222
+ }
223
+
224
+ /// <summary>Complete the current session with a score. Call when the player finishes.</summary>
225
+ public void CompleteSession(int score, int correct = 0, int incorrect = 0, string difficulty = "medium")
226
+ {
227
+ if (string.IsNullOrEmpty(currentSessionId))
228
+ {
229
+ Debug.LogWarning("[MyVillageOS] No active session to complete");
230
+ return;
231
+ }
232
+ StartCoroutine(CompleteSessionCoroutine(score, correct, incorrect, difficulty));
233
+ }
234
+
235
+ private IEnumerator StartSessionCoroutine()
236
+ {
237
+ string url = EdPlatformConfig.SessionsEndpoint(GameSlug);
238
+ string json = JsonUtility.ToJson(new SessionStartRequest { platform = EdPlatformConfig.CurrentPlatform });
239
+
240
+ using (var req = new UnityWebRequest(url, "POST"))
241
+ {
242
+ req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
243
+ req.downloadHandler = new DownloadHandlerBuffer();
244
+ req.SetRequestHeader("Content-Type", "application/json");
245
+ AddAuthHeader(req);
246
+
247
+ yield return req.SendWebRequest();
248
+
249
+ if (req.result == UnityWebRequest.Result.Success)
250
+ {
251
+ var response = JsonUtility.FromJson<SessionStartResponse>(req.downloadHandler.text);
252
+ currentSessionId = response.session?.id;
253
+ sessionStartTime = Time.realtimeSinceStartup;
254
+ MyVillageEvents.OnSessionStarted?.Invoke(currentSessionId);
255
+ Debug.Log($"[MyVillageOS] Session started: {currentSessionId}");
256
+ }
257
+ else
258
+ {
259
+ Debug.LogWarning($"[MyVillageOS] Failed to start session: {req.error}");
260
+ }
261
+ }
262
+ }
263
+
264
+ private IEnumerator CompleteSessionCoroutine(int score, int correct, int incorrect, string difficulty)
265
+ {
266
+ string url = EdPlatformConfig.SessionCompleteEndpoint(GameSlug, currentSessionId);
267
+ int duration = Mathf.RoundToInt(Time.realtimeSinceStartup - sessionStartTime);
268
+
269
+ var body = new SessionCompleteRequest
270
+ {
271
+ score = score,
272
+ duration = duration,
273
+ sessionData = new SessionData { correct = correct, incorrect = incorrect, difficulty = difficulty }
274
+ };
275
+
276
+ string json = JsonUtility.ToJson(body);
277
+
278
+ using (var req = new UnityWebRequest(url, "POST"))
279
+ {
280
+ req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
281
+ req.downloadHandler = new DownloadHandlerBuffer();
282
+ req.SetRequestHeader("Content-Type", "application/json");
283
+ AddAuthHeader(req);
284
+
285
+ yield return req.SendWebRequest();
286
+
287
+ if (req.result == UnityWebRequest.Result.Success)
288
+ {
289
+ var response = JsonUtility.FromJson<SessionCompleteResponse>(req.downloadHandler.text);
290
+ int mvt = response.mvtAwarded;
291
+ MyVillageEvents.OnSessionCompleted?.Invoke(score, duration);
292
+ if (mvt > 0) MyVillageEvents.OnMVTAwarded?.Invoke(mvt);
293
+ Debug.Log($"[MyVillageOS] Session complete. Score: {score}, MVT: {mvt}");
294
+ }
295
+ else
296
+ {
297
+ Debug.LogWarning($"[MyVillageOS] Failed to complete session: {req.error}");
298
+ }
299
+ }
300
+
301
+ currentSessionId = null;
302
+ }
303
+
304
+ private void AddAuthHeader(UnityWebRequest req)
305
+ {
306
+ string token = PlayerPrefs.GetString("jwt_token", "");
307
+ if (!string.IsNullOrEmpty(token))
308
+ req.SetRequestHeader("Authorization", $"Bearer {token}");
309
+ }
310
+
311
+ // ── Request/Response DTOs ──
312
+
313
+ [Serializable] private class SessionStartRequest { public string platform; }
314
+ [Serializable] private class SessionStartResponse { public SessionInfo session; }
315
+ [Serializable] private class SessionInfo { public string id; }
316
+ [Serializable] private class SessionCompleteRequest { public int score; public int duration; public SessionData sessionData; }
317
+ [Serializable] private class SessionData { public int correct; public int incorrect; public string difficulty; }
318
+ [Serializable] private class SessionCompleteResponse { public int mvtAwarded; public bool rewardSkipped; }
319
+ }
320
+ `;
321
+ }
322
+
323
+ function generateMyVillageAuth() {
324
+ return `using UnityEngine;
325
+ using UnityEngine.Networking;
326
+ using System;
327
+ using System.Collections;
328
+ using System.Text;
329
+
330
+ /// <summary>
331
+ /// OAuth 2.0 + PKCE authentication with MyVillageOS.
332
+ /// Call SignIn() to open the browser login flow. The JWT token is
333
+ /// stored in PlayerPrefs and used automatically by GameSessionReporter.
334
+ /// </summary>
335
+ public class MyVillageAuth : MonoBehaviour
336
+ {
337
+ public static MyVillageAuth Instance { get; private set; }
338
+
339
+ private const string ClientId = "mvos_0L7avBZtpKSIyAT_kZT0Lg";
340
+ private const string Scopes = "openid profile email phone villager offline_access";
341
+
342
+ private string codeVerifier;
343
+ private string state;
344
+
345
+ public bool IsSignedIn => !string.IsNullOrEmpty(PlayerPrefs.GetString("jwt_token", ""));
346
+
347
+ void Awake()
348
+ {
349
+ if (Instance != null && Instance != this) { Destroy(gameObject); return; }
350
+ Instance = this;
351
+ DontDestroyOnLoad(gameObject);
352
+ }
353
+
354
+ /// <summary>Open the MyVillageOS login page in the system browser.</summary>
355
+ public void SignIn()
356
+ {
357
+ codeVerifier = PKCEHelper.GenerateCodeVerifier();
358
+ string codeChallenge = PKCEHelper.GenerateCodeChallenge(codeVerifier);
359
+ state = Guid.NewGuid().ToString("N");
360
+
361
+ string redirectUri = GetRedirectUri();
362
+ string url = $"{EdPlatformConfig.BaseUrl}/api/oauth/authorize" +
363
+ $"?response_type=code&client_id={ClientId}" +
364
+ $"&redirect_uri={UnityWebRequest.EscapeURL(redirectUri)}" +
365
+ $"&scope={UnityWebRequest.EscapeURL(Scopes)}" +
366
+ $"&code_challenge={codeChallenge}&code_challenge_method=S256" +
367
+ $"&state={state}";
368
+
369
+ Application.OpenURL(url);
370
+ }
371
+
372
+ /// <summary>Call this when you receive the auth callback with a code.</summary>
373
+ public void HandleCallback(string code, string returnedState)
374
+ {
375
+ if (returnedState != state)
376
+ {
377
+ Debug.LogError("[MyVillageOS] OAuth state mismatch — possible CSRF attack");
378
+ return;
379
+ }
380
+ StartCoroutine(ExchangeCodeForToken(code));
381
+ }
382
+
383
+ /// <summary>Clear the stored token.</summary>
384
+ public void SignOut()
385
+ {
386
+ PlayerPrefs.DeleteKey("jwt_token");
387
+ PlayerPrefs.DeleteKey("refresh_token");
388
+ PlayerPrefs.Save();
389
+ MyVillageEvents.OnSignedOut?.Invoke();
390
+ }
391
+
392
+ private IEnumerator ExchangeCodeForToken(string code)
393
+ {
394
+ string url = $"{EdPlatformConfig.BaseUrl}/api/oauth/token";
395
+ string body = $"grant_type=authorization_code&code={code}" +
396
+ $"&redirect_uri={UnityWebRequest.EscapeURL(GetRedirectUri())}" +
397
+ $"&client_id={ClientId}&code_verifier={codeVerifier}";
398
+
399
+ using (var req = new UnityWebRequest(url, "POST"))
400
+ {
401
+ req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
402
+ req.downloadHandler = new DownloadHandlerBuffer();
403
+ req.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");
404
+ req.timeout = 15;
405
+
406
+ yield return req.SendWebRequest();
407
+
408
+ if (req.result == UnityWebRequest.Result.Success)
409
+ {
410
+ var token = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text);
411
+ PlayerPrefs.SetString("jwt_token", token.access_token);
412
+ if (!string.IsNullOrEmpty(token.refresh_token))
413
+ PlayerPrefs.SetString("refresh_token", token.refresh_token);
414
+ PlayerPrefs.Save();
415
+ MyVillageEvents.OnSignedIn?.Invoke();
416
+ Debug.Log("[MyVillageOS] Signed in successfully");
417
+ }
418
+ else
419
+ {
420
+ Debug.LogError($"[MyVillageOS] Token exchange failed: {req.error}");
421
+ }
422
+ }
423
+ }
424
+
425
+ private string GetRedirectUri()
426
+ {
427
+ #if UNITY_EDITOR || UNITY_STANDALONE
428
+ return "http://localhost:8429/auth/callback";
429
+ #else
430
+ // Replace with your app's deep link scheme
431
+ return "myvillagegame://auth/callback";
432
+ #endif
433
+ }
434
+
435
+ [Serializable] private class TokenResponse
436
+ {
437
+ public string access_token;
438
+ public string refresh_token;
439
+ public int expires_in;
440
+ }
441
+ }
442
+ `;
443
+ }
444
+
445
+ function generatePKCEHelper() {
446
+ return `using System;
447
+ using System.Security.Cryptography;
448
+ using System.Text;
449
+
450
+ /// <summary>PKCE (Proof Key for Code Exchange) helper for OAuth 2.0.</summary>
451
+ public static class PKCEHelper
452
+ {
453
+ public static string GenerateCodeVerifier()
454
+ {
455
+ byte[] bytes = new byte[32];
456
+ using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(bytes); }
457
+ return Convert.ToBase64String(bytes)
458
+ .TrimEnd('=').Replace('+', '-').Replace('/', '_');
459
+ }
460
+
461
+ public static string GenerateCodeChallenge(string codeVerifier)
462
+ {
463
+ using (var sha256 = SHA256.Create())
464
+ {
465
+ byte[] hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(codeVerifier));
466
+ return Convert.ToBase64String(hash)
467
+ .TrimEnd('=').Replace('+', '-').Replace('/', '_');
468
+ }
469
+ }
470
+ }
471
+ `;
472
+ }
473
+
474
+ function generateMyVillageEvents() {
475
+ return `using System;
476
+
477
+ /// <summary>
478
+ /// Global events for MyVillageOS integration.
479
+ /// Subscribe to these in your game scripts to react to auth and session changes.
480
+ /// </summary>
481
+ public static class MyVillageEvents
482
+ {
483
+ // Auth
484
+ public static Action OnSignedIn;
485
+ public static Action OnSignedOut;
486
+
487
+ // Sessions
488
+ public static Action<string> OnSessionStarted; // sessionId
489
+ public static Action<int, int> OnSessionCompleted; // score, duration
490
+ public static Action<int> OnMVTAwarded; // amount
491
+ }
492
+ `;
493
+ }
494
+
495
+ function generateGameLoader(className, slug) {
496
+ return `using UnityEngine;
497
+ using UnityEngine.Networking;
498
+ using System.Collections;
499
+
500
+ /// <summary>
501
+ /// Downloads and loads the AssetBundle for this mini-game.
502
+ /// Attach to a GameObject in your loading scene.
503
+ /// Set AssetName in the Inspector to your bundle file name.
504
+ ///
505
+ /// Game slug: "${slug}"
506
+ /// </summary>
507
+ public class ${className}Loader : MonoBehaviour
508
+ {
509
+ [Header("Bundle Settings")]
510
+ [Tooltip("The AssetBundle file name (e.g. 'my-game')")]
511
+ public string AssetName;
512
+
513
+ [Tooltip("The scene name inside the AssetBundle to load")]
514
+ public string SceneName;
515
+
516
+ private const string GameSlug = "${slug}";
517
+
518
+ void Start()
519
+ {
520
+ if (!string.IsNullOrEmpty(AssetName))
521
+ StartCoroutine(LoadBundle());
522
+ }
523
+
524
+ private IEnumerator LoadBundle()
525
+ {
526
+ string url = EdPlatformConfig.BundleUrl(GameSlug, AssetName);
527
+ Debug.Log($"[${className}Loader] Downloading bundle from: {url}");
528
+
529
+ using (var req = UnityWebRequestAssetBundle.GetAssetBundle(url))
530
+ {
531
+ var op = req.SendWebRequest();
532
+
533
+ while (!op.isDone)
534
+ {
535
+ // You can update a progress bar here: req.downloadProgress
536
+ yield return null;
537
+ }
538
+
539
+ if (req.result != UnityWebRequest.Result.Success)
540
+ {
541
+ Debug.LogError($"[${className}Loader] Bundle download failed: {req.error}");
542
+ yield break;
543
+ }
544
+
545
+ AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(req);
546
+
547
+ // Start a game session
548
+ if (GameSessionReporter.Instance != null)
549
+ {
550
+ GameSessionReporter.Instance.GameSlug = GameSlug;
551
+ GameSessionReporter.Instance.StartSession();
552
+ }
553
+
554
+ // Load the scene from the bundle
555
+ if (!string.IsNullOrEmpty(SceneName))
556
+ {
557
+ string[] scenes = bundle.GetAllScenePaths();
558
+ if (scenes.Length > 0)
559
+ UnityEngine.SceneManagement.SceneManager.LoadScene(scenes[0]);
560
+ }
561
+ }
562
+ }
563
+
564
+ /// <summary>
565
+ /// Call this when the player finishes your game.
566
+ /// It reports the score to MyVillageOS and awards MVT tokens.
567
+ /// </summary>
568
+ public void OnGameComplete(int score, int correct = 0, int incorrect = 0)
569
+ {
570
+ if (GameSessionReporter.Instance != null)
571
+ GameSessionReporter.Instance.CompleteSession(score, correct, incorrect);
572
+ }
573
+ }
574
+ `;
575
+ }
576
+
577
+ function generateUnityReadme(name, slug, className, platform) {
578
+ const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
579
+ return `# ${name}
580
+
581
+ A MyVillageOS mini-game built with Unity.
582
+
583
+ - **Slug:** \`${slug}\`
584
+ - **Game Type:** \`${gameType}\`
585
+ - **Platform:** ${platform || 'ios / android'}
586
+
587
+ ## Setup
588
+
589
+ 1. Open your Unity project (or create a new one)
590
+ 2. Copy the \`Scripts/\` folder into your Unity project's \`Assets/\` directory
591
+ 3. Create a GameObject and attach \`GameSessionReporter\` — set GameSlug to \`${slug}\`
592
+ 4. Create a GameObject and attach \`MyVillageAuth\` (for player sign-in)
593
+ 5. Build your game scenes
594
+
595
+ ## MyVillageOS Integration
596
+
597
+ The generated scripts handle:
598
+
599
+ | Script | Purpose |
600
+ |--------|---------|
601
+ | \`EdPlatformConfig.cs\` | API URL construction (auto-detects platform) |
602
+ | \`GameSessionReporter.cs\` | Session start/complete + MVT rewards |
603
+ | \`MyVillageAuth.cs\` | OAuth 2.0 + PKCE sign-in via browser |
604
+ | \`PKCEHelper.cs\` | Crypto utilities for OAuth PKCE |
605
+ | \`MyVillageEvents.cs\` | Global events (OnSignedIn, OnSessionCompleted, OnMVTAwarded) |
606
+ | \`${className}Loader.cs\` | Downloads your AssetBundle from the ed-platform |
607
+
608
+ ## Building AssetBundles
609
+
610
+ ### For Mobile (iOS / Android)
611
+
612
+ 1. In Unity: **Assets → Build AssetBundles** (or use your build script)
613
+ 2. Output bundles to \`builds/ios/\` and/or \`builds/android/\`
614
+ 3. Deploy: \`myvillage deploy --platform ios\`
615
+
616
+ ### For Web (WebGL)
617
+
618
+ 1. In Unity: **File → Build Settings → WebGL → Build**
619
+ 2. Output to \`builds/webgl/\`
620
+ 3. Deploy: \`myvillage deploy --platform webgl\`
621
+
622
+ ## Reporting Scores
623
+
624
+ When the player finishes your game, call:
625
+
626
+ \`\`\`csharp
627
+ // From any script:
628
+ GameSessionReporter.Instance.CompleteSession(
629
+ score: 850,
630
+ correct: 8,
631
+ incorrect: 2,
632
+ difficulty: "medium"
633
+ );
634
+ \`\`\`
635
+
636
+ This reports the score to MyVillageOS and awards MVT tokens to the player.
637
+
638
+ ## Listening to Events
639
+
640
+ \`\`\`csharp
641
+ void OnEnable()
642
+ {
643
+ MyVillageEvents.OnMVTAwarded += ShowRewardPopup;
644
+ MyVillageEvents.OnSessionCompleted += OnGameDone;
645
+ }
646
+
647
+ void OnDisable()
648
+ {
649
+ MyVillageEvents.OnMVTAwarded -= ShowRewardPopup;
650
+ MyVillageEvents.OnSessionCompleted -= OnGameDone;
651
+ }
652
+
653
+ void ShowRewardPopup(int amount) => Debug.Log($"Earned {amount} MVT!");
654
+ void OnGameDone(int score, int duration) => Debug.Log($"Score: {score} in {duration}s");
655
+ \`\`\`
656
+
657
+ ## Deploy
658
+
659
+ \`\`\`bash
660
+ myvillage deploy --platform ios # Upload iOS bundles
661
+ myvillage deploy --platform android # Upload Android bundles
662
+ myvillage deploy --platform webgl # Upload WebGL build
663
+ myvillage status # Check review status
664
+ \`\`\`
665
+
666
+ After deploying, an admin will review your game, set MVT rewards, and publish it.
667
+ Published games appear automatically in the Izi Graham mobile app or M-UNI web platform.
668
+ `;
669
+ }
670
+
671
+ // ── Three.js Templates ─────────────────────────────────────────────
672
+
69
673
  function generatePackageJson(name, description, slug, type) {
70
674
  return JSON.stringify({
71
675
  name: slug,