@myvillage/cli 1.51.0 → 1.61.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 +2 -1
- package/src/commands/agent-local.js +28 -1
- package/src/commands/create-game.js +18 -12
- package/src/commands/deploy.js +166 -53
- package/src/index.js +2 -1
- package/src/utils/api.js +10 -0
- package/src/utils/preflight.js +172 -0
- package/src/utils/templates.js +189 -517
- package/tools/preflight/README.md +75 -0
- package/tools/preflight/preflight.py +291 -0
- package/tools/preflight/requirements.txt +1 -0
package/src/utils/templates.js
CHANGED
|
@@ -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
|
-
'
|
|
79
|
-
'Scripts
|
|
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
|
-
//
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
109
|
-
writeFileSync(join(targetDir,
|
|
110
|
-
writeFileSync(join(targetDir,
|
|
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
|
-
|
|
116
|
-
writeFileSync(join(targetDir,
|
|
157
|
+
writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
|
|
158
|
+
writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform, unityVersion));
|
|
117
159
|
|
|
118
|
-
|
|
119
|
-
writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform));
|
|
160
|
+
return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION };
|
|
120
161
|
}
|
|
121
162
|
|
|
122
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
184
|
-
return `
|
|
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
|
|
324
|
-
return `using
|
|
325
|
-
using UnityEngine
|
|
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
|
-
///
|
|
332
|
-
///
|
|
333
|
-
///
|
|
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
|
|
208
|
+
public sealed class ${className} : MissionBase
|
|
336
209
|
{
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
protected override void OnBegin()
|
|
426
218
|
{
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
223
|
+
private void Finish()
|
|
436
224
|
{
|
|
437
|
-
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
475
|
-
return
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
283
|
+
A MyVillage game scaffolded with the MyVillage CLI and the MyVillage GameKit SDK.
|
|
582
284
|
|
|
583
285
|
- **Slug:** \`${slug}\`
|
|
584
|
-
- **Game
|
|
585
|
-
- **
|
|
586
|
-
|
|
587
|
-
## Setup
|
|
286
|
+
- **Game type:** \`${gameType}\`
|
|
287
|
+
- **SDK:** \`com.myvillage.gamekit\` (pinned in \`Packages/manifest.json\`)
|
|
588
288
|
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
## Quickstart \u2014 play your mission in 3 steps
|
|
598
303
|
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
2. Output to \`builds/webgl/\`
|
|
620
|
-
3. Deploy: \`myvillage deploy --platform webgl\`
|
|
314
|
+
## Building and deploying
|
|
621
315
|
|
|
622
|
-
|
|
316
|
+
${buildHint}
|
|
623
317
|
|
|
624
|
-
|
|
318
|
+
Your game is auto-submitted for review on successful deploy. Check status:
|
|
625
319
|
|
|
626
|
-
\`\`\`
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
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',
|