@myvillage/cli 1.50.0 → 1.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,39 @@
1
1
  import { mkdirSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { randomUUID } from 'crypto';
4
+
5
+ // GameKit SDK version that scaffolded Unity projects pin to.
6
+ // Bump alongside CLI minor releases as the SDK contract evolves.
7
+ const GAMEKIT_SDK_VERSION = '1.1.0-alpha.4';
8
+ const GAMEKIT_SDK_URL =
9
+ `https://github.com/MyVillage-Project-Technologies/myvillage-gamekit.git#v${GAMEKIT_SDK_VERSION}`;
10
+
11
+ // Fallback Unity Editor version used only if we can't fetch the SDK's
12
+ // package.json at scaffold time. The SDK declares the recommended editor
13
+ // version via `unity` + `unityRelease` fields — that's the source of truth.
14
+ const UNITY_EDITOR_VERSION_FALLBACK = '6000.3.12f1';
15
+
16
+ const SDK_PACKAGE_JSON_RAW_URL =
17
+ `https://raw.githubusercontent.com/MyVillage-Project-Technologies/myvillage-gamekit/v${GAMEKIT_SDK_VERSION}/package.json`;
18
+
19
+ // Resolve the Unity Editor version from the SDK's package.json. Combines the
20
+ // UPM `unity` ("MAJOR.MINOR") and `unityRelease` ("PATCHfINCR") fields into a
21
+ // full version string like "6000.0.76f1". Falls back on any failure so
22
+ // offline scaffolding still works.
23
+ async function resolveUnityVersion() {
24
+ try {
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(), 5000);
27
+ const res = await fetch(SDK_PACKAGE_JSON_RAW_URL, { signal: controller.signal });
28
+ clearTimeout(timeout);
29
+ if (!res.ok) return UNITY_EDITOR_VERSION_FALLBACK;
30
+ const pkg = await res.json();
31
+ if (pkg.unity && pkg.unityRelease) return `${pkg.unity}.${pkg.unityRelease}`;
32
+ return UNITY_EDITOR_VERSION_FALLBACK;
33
+ } catch {
34
+ return UNITY_EDITOR_VERSION_FALLBACK;
35
+ }
36
+ }
3
37
 
4
38
  // MyVillage brand colors
5
39
  const BRAND = {
@@ -68,15 +102,25 @@ export function createGameProject(targetDir, options) {
68
102
 
69
103
  // ── Unity Game Project ─────────────────────────────────────────────
70
104
 
71
- export function createUnityGameProject(targetDir, options) {
72
- const { name, description, type, ageGroup, platform } = options;
105
+ export async function createUnityGameProject(targetDir, options) {
106
+ const { name, description, type, ageGroup, platform, unityVersion: explicitUnityVersion } = options;
73
107
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
74
108
  const className = name.replace(/[^a-zA-Z0-9]/g, '');
109
+ const unityVersion = explicitUnityVersion || await resolveUnityVersion();
110
+
111
+ // Scaffold an openable Unity project that pulls com.myvillage.gamekit via UPM.
112
+ // Layout matches what Unity Hub expects:
113
+ // Packages/manifest.json - dependency on the GameKit SDK + core modules
114
+ // ProjectSettings/ProjectVersion.txt - tells Unity which editor version
115
+ // Assets/Scripts/<Name>.cs - starter MissionBase subclass
116
+ // builds/{ios,android,webgl}/ - drop-zone for builds the CLI deploys
75
117
 
76
118
  const dirs = [
77
119
  '',
78
- 'Scripts',
79
- 'Scripts/MyVillageOS',
120
+ 'Assets',
121
+ 'Assets/Scripts',
122
+ 'Packages',
123
+ 'ProjectSettings',
80
124
  'builds',
81
125
  'builds/ios',
82
126
  'builds/android',
@@ -87,7 +131,7 @@ export function createUnityGameProject(targetDir, options) {
87
131
  mkdirSync(join(targetDir, dir), { recursive: true });
88
132
  }
89
133
 
90
- // Config file (CLI reads this instead of package.json for Unity projects)
134
+ // CLI metadata. gameId/lastDeployed are populated by deploy.
91
135
  writeFileSync(join(targetDir, 'myvillage.json'), JSON.stringify({
92
136
  name,
93
137
  slug,
@@ -96,575 +140,198 @@ export function createUnityGameProject(targetDir, options) {
96
140
  gameType: platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE',
97
141
  category: type.toUpperCase(),
98
142
  targetAge: ageGroup,
143
+ sdkVersion: GAMEKIT_SDK_VERSION,
144
+ unityVersion,
99
145
  gameId: null,
100
146
  lastDeployed: null,
101
147
  }, null, 2) + '\n');
102
148
 
103
- writeFileSync(join(targetDir, '.gitignore'), [
104
- 'builds/*/*.meta', 'Library/', 'Temp/', 'Logs/', 'obj/',
105
- 'UserSettings/', '.vs/', '*.csproj', '*.sln',
106
- ].join('\n') + '\n');
149
+ // Unity project files.
150
+ writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest());
151
+ writeFileSync(join(targetDir, 'ProjectSettings/ProjectVersion.txt'), generateUnityProjectVersion(unityVersion));
107
152
 
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());
153
+ // Starter mission. New devs subclass MissionBase here.
154
+ writeFileSync(join(targetDir, `Assets/Scripts/${className}.cs`), generateMissionScript(className, name, unityVersion));
155
+ writeFileSync(join(targetDir, `Assets/Scripts/${className}.cs.meta`), generateUnityScriptMeta());
114
156
 
115
- // Game-specific loader template
116
- writeFileSync(join(targetDir, `Scripts/${className}Loader.cs`), generateGameLoader(className, slug));
157
+ writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
158
+ writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform, unityVersion));
117
159
 
118
- // README
119
- writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform));
160
+ return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION };
120
161
  }
121
162
 
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
+ // ── Unity (GameKit SDK) ─────────────────────────────────────────────
163
164
 
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
- `;
165
+ function generateUnityPackagesManifest() {
166
+ return JSON.stringify({
167
+ dependencies: {
168
+ 'com.myvillage.gamekit': GAMEKIT_SDK_URL,
169
+ 'com.unity.feature.development': '1.0.2',
170
+ 'com.unity.render-pipelines.universal': '17.2.0',
171
+ 'com.unity.textmeshpro': '3.0.6',
172
+ 'com.unity.inputsystem': '1.14.2',
173
+ 'com.unity.ide.rider': '3.0.38',
174
+ 'com.unity.ide.visualstudio': '2.0.22',
175
+ },
176
+ }, null, 2) + '\n';
181
177
  }
182
178
 
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
- `;
179
+ function generateUnityProjectVersion(unityVersion) {
180
+ return `m_EditorVersion: ${unityVersion}\n`;
321
181
  }
322
182
 
323
- function generateMyVillageAuth() {
324
- return `using UnityEngine;
325
- using UnityEngine.Networking;
326
- using System;
327
- using System.Collections;
328
- using System.Text;
183
+ function generateMissionScript(className, displayName, unityVersion) {
184
+ return `using MyVillage.GameKit;
185
+ using UnityEngine;
329
186
 
330
187
  /// <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.
188
+ /// ${displayName} your MyVillage mission entry point.
189
+ ///
190
+ /// FIRST-TIME SETUP (after \`myvillage create-game\` scaffolds this project):
191
+ /// 1. Open this project in Unity ${unityVersion}.
192
+ /// 2. Click the menu MyVillage -> Create Starter Mission Scene.
193
+ /// Unity creates a scene with a Mission GameObject that already has
194
+ /// MockMissionHost attached and this ${className} component
195
+ /// auto-attached (if it's the only MissionBase in the project).
196
+ /// 3. Press Play. Your mission runs end-to-end in the editor — MockMissionHost
197
+ /// stands in for the real M-UNI app, logs every host call to the Console,
198
+ /// and pops up the ResultPanel when CompleteMission fires.
199
+ ///
200
+ /// AFTER YOU SHIP TO M-UNI: this same script runs unchanged. The real host
201
+ /// (M-UNI Universe) supplies a MissionHostAdapter that talks to the
202
+ /// ed-platform — sessions, MVT awards, the catalog return — all happen
203
+ /// automatically. You don't change a line.
204
+ ///
205
+ /// EDIT THIS SCRIPT: replace OnBegin with your game logic and call
206
+ /// CompleteMission(score) when the player finishes.
334
207
  /// </summary>
335
- public class MyVillageAuth : MonoBehaviour
208
+ public sealed class ${className} : MissionBase
336
209
  {
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
- }
210
+ [Header("Mission Settings")]
211
+ [Tooltip("Seconds before the mission auto-completes. Replace with your own end condition.")]
212
+ [SerializeField] private float durationSeconds = 30f;
391
213
 
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
- }
214
+ [Tooltip("Score reported when the mission finishes.")]
215
+ [SerializeField] private int finalScore = 100;
424
216
 
425
- private string GetRedirectUri()
217
+ protected override void OnBegin()
426
218
  {
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
219
+ Host.LogEvent("mission.begin");
220
+ Invoke(nameof(Finish), durationSeconds);
433
221
  }
434
222
 
435
- [Serializable] private class TokenResponse
223
+ private void Finish()
436
224
  {
437
- public string access_token;
438
- public string refresh_token;
439
- public int expires_in;
225
+ CompleteMission(finalScore: finalScore);
440
226
  }
441
227
  }
442
228
  `;
443
229
  }
444
230
 
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
- }
231
+ function generateUnityScriptMeta() {
232
+ const guid = randomUUID().replace(/-/g, '');
233
+ return `fileFormatVersion: 2
234
+ guid: ${guid}
235
+ MonoImporter:
236
+ externalObjects: {}
237
+ serializedVersion: 2
238
+ defaultReferences: []
239
+ executionOrder: 0
240
+ icon: {instanceID: 0}
241
+ userData:
242
+ assetBundleName:
243
+ assetBundleVariant:
471
244
  `;
472
245
  }
473
246
 
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
- `;
247
+ function generateUnityGitignore() {
248
+ return [
249
+ '# Unity',
250
+ 'Library/',
251
+ 'Temp/',
252
+ 'Obj/',
253
+ 'Logs/',
254
+ 'MemoryCaptures/',
255
+ 'UserSettings/',
256
+ 'Build/',
257
+ '',
258
+ '# Editor / IDE',
259
+ '.vs/',
260
+ '.idea/',
261
+ '*.csproj',
262
+ '*.sln',
263
+ '*.suo',
264
+ '*.user',
265
+ '',
266
+ '# OS',
267
+ '.DS_Store',
268
+ 'Thumbs.db',
269
+ '',
270
+ '# Built bundles are checked into the deploy artifact, not the repo',
271
+ 'builds/*/*.meta',
272
+ ].join('\n') + '\n';
575
273
  }
576
274
 
577
- function generateUnityReadme(name, slug, className, platform) {
275
+ function generateUnityReadme(name, slug, className, platform, unityVersion) {
578
276
  const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
277
+ const buildHint = platform === 'webgl'
278
+ ? `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\`)`;
280
+
579
281
  return `# ${name}
580
282
 
581
- A MyVillageOS mini-game built with Unity.
283
+ A MyVillage game scaffolded with the MyVillage CLI and the MyVillage GameKit SDK.
582
284
 
583
285
  - **Slug:** \`${slug}\`
584
- - **Game Type:** \`${gameType}\`
585
- - **Platform:** ${platform || 'ios / android'}
586
-
587
- ## Setup
286
+ - **Game type:** \`${gameType}\`
287
+ - **SDK:** \`com.myvillage.gamekit\` (pinned in \`Packages/manifest.json\`)
588
288
 
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
289
+ ## What's in this project
594
290
 
595
- ## MyVillageOS Integration
291
+ \`\`\`
292
+ Assets/
293
+ \u2514\u2500\u2500 Scripts/
294
+ \u2514\u2500\u2500 ${className}.cs \u2190 your mission, subclasses MissionBase
295
+ Packages/
296
+ \u2514\u2500\u2500 manifest.json \u2190 fetches the GameKit SDK on first open
297
+ ProjectSettings/
298
+ \u2514\u2500\u2500 ProjectVersion.txt
299
+ myvillage.json \u2190 CLI metadata (auto-updated by deploy)
300
+ \`\`\`
596
301
 
597
- The generated scripts handle:
302
+ ## Quickstart \u2014 play your mission in 3 steps
598
303
 
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 |
304
+ 1. **Install Unity Hub** and Unity **${unityVersion}** if you don't have it. Open this folder in Unity Hub (Add \u2192 Add project from disk). Unity fetches the GameKit SDK automatically (check Package Manager \u2192 In Project).
607
305
 
608
- ## Building AssetBundles
306
+ 2. **Click the menu \`MyVillage \u2192 Create Starter Mission Scene\`.** This creates a new scene with a "Mission" GameObject that already has \`MockMissionHost\` attached (the editor stand-in for the real M-UNI host) and your \`${className}\` script auto-attached. The scene is added to Build Settings and opened for you.
609
307
 
610
- ### For Mobile (iOS / Android)
308
+ 3. **Press Play.** Your mission runs end-to-end in the editor. \`MockMissionHost\` logs every host call to the Console and pops up the \`ResultPanel\` when \`CompleteMission\` fires.
611
309
 
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\`
310
+ Edit \`Assets/Scripts/${className}.cs\`, hit Play again, iterate.
615
311
 
616
- ### For Web (WebGL)
312
+ > **What's MockMissionHost?** It's a stand-in for the real M-UNI app. The mission calls \`Host.CompleteMission(score)\` \u2014 in production that goes to the M-UNI app, which records the session and awards MVT. \`MockMissionHost\` is a fake host for editor previews: it logs the call and shows the result UI, but doesn't touch the network. Without it, "Press Play" wouldn't work at all because nothing would call \`Initialize\` on your mission. It's safe to leave attached when you deploy \u2014 the real host overrides it.
617
313
 
618
- 1. In Unity: **File → Build Settings → WebGL → Build**
619
- 2. Output to \`builds/webgl/\`
620
- 3. Deploy: \`myvillage deploy --platform webgl\`
314
+ ## Building and deploying
621
315
 
622
- ## Reporting Scores
316
+ ${buildHint}
623
317
 
624
- When the player finishes your game, call:
318
+ Your game is auto-submitted for review on successful deploy. Check status:
625
319
 
626
- \`\`\`csharp
627
- // From any script:
628
- GameSessionReporter.Instance.CompleteSession(
629
- score: 850,
630
- correct: 8,
631
- incorrect: 2,
632
- difficulty: "medium"
633
- );
320
+ \`\`\`
321
+ myvillage status
634
322
  \`\`\`
635
323
 
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
- }
324
+ ## Editing your mission
652
325
 
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
- \`\`\`
326
+ Replace the body of \`OnBegin()\` with your actual gameplay. Call \`CompleteMission(score)\` (or \`FailMission(reason)\`) when the player finishes. The host handles the rest \u2014 session tracking, MVT rewards, returning to the M-UNI home screen.
656
327
 
657
- ## Deploy
328
+ For configurable missions (quiz questions, level layouts, etc.), create a \`MissionConfig\` subclass and ship it as an asset alongside your scene.
658
329
 
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
- \`\`\`
330
+ ## Reference
665
331
 
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.
332
+ - SDK source: https://github.com/MyVillage-Project-Technologies/myvillage-gamekit
333
+ - Developer docs: https://myvillageproject.ai/developers
334
+ - CLI commands: \`myvillage --help\`
668
335
  `;
669
336
  }
670
337
 
@@ -699,11 +366,16 @@ function generatePackageJson(name, description, slug, type, ageGroup) {
699
366
  }
700
367
 
701
368
  function generateViteConfig() {
369
+ // base: './' makes the built HTML use relative asset URLs (e.g. ./assets/main.js
370
+ // instead of /assets/main.js). The MyVillage host serves games at
371
+ // /api/v1/games/game-files/<slug>/, and relative URLs let the browser resolve
372
+ // bundled assets correctly under that path.
702
373
  return `import { defineConfig } from 'vite';
703
374
 
704
375
  export default defineConfig({
705
376
  root: '.',
706
377
  publicDir: 'public',
378
+ base: './',
707
379
  build: {
708
380
  outDir: 'dist',
709
381
  assetsDir: 'assets',