@myvillage/cli 2.16.1 → 2.18.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/README.md CHANGED
@@ -19,6 +19,10 @@ myvillage login
19
19
  # Create a new game project
20
20
  myvillage create-game
21
21
 
22
+ # Or bring a web game you already have into MyVillage
23
+ # (shows what will change and asks first; --dry-run only shows)
24
+ myvillage init-game ./my-game
25
+
22
26
  # Deploy to the platform
23
27
  myvillage deploy
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.16.1",
3
+ "version": "2.18.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- // MyVillage Game Bridge — the "mvgame" protocol (v2).
1
+ // MyVillage Game Bridge — the "mvgame" protocol (v3).
2
2
  //
3
3
  // ONE contract for every MyVillage game, on every surface:
4
4
  // - muni mobile app (React Native WebView)
@@ -12,6 +12,13 @@
12
12
  // One the host ALWAYS provides, but you can trigger too:
13
13
  // MyVillageGame.exit() // leave the game
14
14
  //
15
+ // Play time is automatic. This file measures how long the player is actually
16
+ // playing (screen visible, touched/clicked/pressed something recently) and
17
+ // reports it to the host, which is how the platform tells a real play from
18
+ // someone opening the game and closing it. Don't add timers or fake input for
19
+ // it. Optionally, report the running score as the game goes:
20
+ // MyVillageGame.progress({ score })
21
+ //
15
22
  // Real-time multiplayer (optional) — see "Multiplayer" below:
16
23
  // const mp = await MyVillageGame.multiplayer({ arena, game: 'my-game' });
17
24
  // await mp.quickMatch(); // or mp.host() / mp.join(code)
@@ -25,7 +32,7 @@
25
32
 
26
33
  const PROTOCOL = 'mvgame';
27
34
  const HOST_PROTOCOL = 'mvgame-host';
28
- const VERSION = 2;
35
+ const VERSION = 3;
29
36
 
30
37
  const hasWindow = typeof window !== 'undefined';
31
38
  const inReactNative = hasWindow && !!window.ReactNativeWebView;
@@ -59,6 +66,73 @@ function post(message) {
59
66
  }
60
67
  }
61
68
 
69
+ // ── Play time ───────────────────────────────────────────────────────────────
70
+ // The platform counts a session as a play once the player has been actively
71
+ // playing for a while (the threshold is the platform's, not ours). Only the game
72
+ // page can tell a player who's playing from a game left open on a table, so the
73
+ // measuring happens here: time counts while the page is visible and the player
74
+ // has touched, clicked or pressed something in the last IDLE_AFTER_MS.
75
+ //
76
+ // The host forwards { type: 'progress', activeMs } to the platform. A message
77
+ // with version >= 3 also tells the host this game measures its own time, so it
78
+ // doesn't fall back to timing how long the game is on screen.
79
+
80
+ const IDLE_AFTER_MS = 20000;
81
+ const REPORT_EVERY_MS = 10000; // of active time
82
+ const TICK_MS = 1000;
83
+
84
+ let activeMs = 0;
85
+ let lastInputAt = -Infinity;
86
+ let lastTickAt = now();
87
+ let reportedMs = -1;
88
+ let lastReportAt = -Infinity;
89
+ let pendingUpdate = null; // from progress(), sent with the next report
90
+ let finished = false;
91
+
92
+ function pageVisible() {
93
+ return typeof document === 'undefined' || document.visibilityState !== 'hidden';
94
+ }
95
+
96
+ function playTick() {
97
+ const t = now();
98
+ // A throttled background tab can skip ticks; never credit more than a tick or
99
+ // two for one interval.
100
+ const dt = Math.min(t - lastTickAt, TICK_MS * 2);
101
+ lastTickAt = t;
102
+ if (pageVisible() && t - lastInputAt < IDLE_AFTER_MS) activeMs += dt;
103
+ if (!finished && activeMs - reportedMs >= REPORT_EVERY_MS) reportProgress();
104
+ }
105
+
106
+ function reportProgress() {
107
+ if (finished) return;
108
+ reportedMs = activeMs;
109
+ lastReportAt = now();
110
+ const update = pendingUpdate;
111
+ pendingUpdate = null;
112
+ post({ type: 'progress', ...update, activeMs: Math.round(activeMs) });
113
+ }
114
+
115
+ if (hasWindow) {
116
+ const onInput = () => {
117
+ lastInputAt = now();
118
+ };
119
+ for (const type of ['pointerdown', 'pointermove', 'touchstart', 'touchmove', 'keydown', 'wheel']) {
120
+ window.addEventListener(type, onInput, { capture: true, passive: true });
121
+ }
122
+ setInterval(playTick, TICK_MS);
123
+ // Leaving (app backgrounded, tab hidden, page closing): report what we have.
124
+ if (typeof document !== 'undefined') {
125
+ document.addEventListener('visibilitychange', () => {
126
+ if (!pageVisible() && activeMs > reportedMs) reportProgress();
127
+ });
128
+ }
129
+ window.addEventListener('pagehide', () => {
130
+ if (activeMs > reportedMs) reportProgress();
131
+ });
132
+ // Announce right away so the host knows this game reports its own play time.
133
+ setTimeout(() => reportProgress(), 0);
134
+ }
135
+
62
136
  // ── Host context ────────────────────────────────────────────────────────────
63
137
  // Hosts that support multiplayer hand the game a context:
64
138
  // { apiBase, game, playToken, platform, player: { displayName } }
@@ -451,7 +525,29 @@ export const MyVillageGame = {
451
525
  * `durationMs` is added automatically.
452
526
  */
453
527
  complete(result = {}) {
454
- post({ type: 'complete', durationMs: Math.round(now() - startedAt), ...result });
528
+ if (finished) return;
529
+ playTick();
530
+ finished = true;
531
+ post({
532
+ type: 'complete',
533
+ durationMs: Math.round(now() - startedAt),
534
+ activeMs: Math.round(activeMs),
535
+ ...result,
536
+ });
537
+ },
538
+
539
+ /**
540
+ * Optional: report how the game is going, e.g. the running score. Play time is
541
+ * reported automatically — you never need to call this for a play to count.
542
+ * { score?: number, custom?: object }
543
+ */
544
+ progress(update = {}) {
545
+ pendingUpdate = { ...pendingUpdate, ...update };
546
+ // Safe to call every frame: sent at most every couple of seconds.
547
+ if (now() - lastReportAt >= 2000) {
548
+ playTick();
549
+ reportProgress();
550
+ }
455
551
  },
456
552
 
457
553
  /** Request to leave the game. The host also always offers an Exit control. */
@@ -67,6 +67,7 @@ A match starts by itself once `minPlayers` are connected. Until then players can
67
67
  - Keep `MyVillageGame.ready()` in `src/main.js`.
68
68
  - **Never send positions, hits or scores yourself.** Only `net.controls`. Anything else is ignored by the server, and it's the server's results that award MVT.
69
69
  - **Don't call `MyVillageGame.complete()` when a match ends.** The platform records multiplayer results from the match server. Hosts treat `complete()` as "close the game", which would stop players from playing again.
70
+ - Plays count automatically: `src/myvillage-game.js` reports active play time to the host, and the platform counts a play after 30 seconds of it. Don't add anything for it.
70
71
  - Players are identified only by display name and an in-match id. Don't ask for, show or store anything more about them.
71
72
  - **Players only play with their own village.** The platform enforces it: codes only admit village-mates of the host, and quick match stays inside the player's villages. Don't build anything that suggests playing with strangers.
72
73
  - Don't add an Exit button; the host provides one (top-left). The in-game **Leave** button just returns to the lobby.
@@ -61,8 +61,8 @@ export async function createGameCommand(options = {}) {
61
61
  name: 'orientation',
62
62
  message: 'Where will this game play?',
63
63
  choices: [
64
- { name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
65
- { name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
64
+ { name: 'Portrait — muni mobile app + web', value: 'portrait' },
65
+ { name: 'Landscape — muni mobile app (turned sideways) + web; Unity: web only', value: 'landscape' },
66
66
  ],
67
67
  },
68
68
  ])
@@ -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, getGameDetail, updateGameMetadata } from '../utils/api.js';
11
11
  import { preflightBundles } from '../utils/preflight.js';
12
+ import { refreshGameToolkit, gameCallsComplete } from '../utils/game-toolkit.js';
12
13
  import { selectGameVillages } from '../utils/village-resolver.js';
13
14
 
14
15
  function readPackageJson(dir) {
@@ -269,6 +270,21 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
269
270
  const villageIds = await gatherVillages(pkg.myvillage.villages || [], options);
270
271
  if (villageIds === null) return; // could not resolve villages — abort
271
272
 
273
+ // Ship the current platform toolkit (play-time reporting, host protocol),
274
+ // whatever CLI version the game was created with.
275
+ const toolkitUpdated = refreshGameToolkit(projectDir);
276
+ if (toolkitUpdated.length && !json) {
277
+ console.log(brand.teal(` Updated the MyVillage game toolkit (${toolkitUpdated.join(', ')})`));
278
+ }
279
+ // Multiplayer games never call complete() — the match server records results.
280
+ const warnings = [];
281
+ if (!multiplayer && !gameCallsComplete(projectDir)) {
282
+ warnings.push(
283
+ "This game never calls MyVillageGame.complete(). Plays still count, but finishing the game won't record a completion or pay the player's reward. Call it on every way the game can end (win, lose, game over).",
284
+ );
285
+ }
286
+ if (!json) for (const w of warnings) console.log(chalk.yellow(` ⚠ ${w}`));
287
+
272
288
  const spinner = json ? null : villageSpinner('Building game...').start();
273
289
 
274
290
  const failOut = (msg) => {
@@ -360,6 +376,8 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
360
376
  status: finalStatus,
361
377
  title: metadata.title,
362
378
  villageIds,
379
+ toolkitUpdated,
380
+ warnings,
363
381
  }),
364
382
  );
365
383
  return;
@@ -0,0 +1,200 @@
1
+ import { existsSync, statSync } from 'fs';
2
+ import { resolve, basename } from 'path';
3
+ import chalk from 'chalk';
4
+ import inquirer from 'inquirer';
5
+ import { brand } from '../utils/brand.js';
6
+ import {
7
+ inspectGameFolder,
8
+ convertToGame,
9
+ defaultCopyDir,
10
+ updatePlatformFiles,
11
+ GAME_CATEGORIES,
12
+ TARGET_AGES,
13
+ } from '../utils/game-init.js';
14
+
15
+ // `myvillage init-game [dir]` — bring an existing web game into MyVillage, or
16
+ // bring a MyVillage game's platform files up to this CLI's. Shows what will
17
+ // change and asks before writing anything (--yes skips the question, --dry-run
18
+ // only shows). MVP Studio runs the same code in-process (src/utils/game-init.js).
19
+ //
20
+ // A web game is converted into a new folder next to it (<name>-myvillage) so
21
+ // the original stays as it was; --into picks the folder, --in-place converts
22
+ // the original instead.
23
+
24
+ function out(json, payload) {
25
+ if (json) console.log(JSON.stringify(payload));
26
+ }
27
+
28
+ function fail(json, msg) {
29
+ if (json) console.log(JSON.stringify({ ok: false, error: msg }));
30
+ else console.log(chalk.red(`\n ✗ ${msg}`));
31
+ process.exitCode = 1;
32
+ }
33
+
34
+ function printChanges(changes) {
35
+ for (const c of changes) {
36
+ const verb = c.action === 'create' ? brand.green('+ add ') : brand.gold('~ update');
37
+ console.log(` ${verb} ${c.path} ${brand.teal(c.summary)}`);
38
+ }
39
+ }
40
+
41
+ function printNotes(notes) {
42
+ if (!notes.length) return;
43
+ console.log();
44
+ console.log(brand.gold(' Still needed (left for you):'));
45
+ for (const n of notes) console.log(` • ${n}`);
46
+ }
47
+
48
+ async function confirm(message) {
49
+ const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: true }]);
50
+ return ok;
51
+ }
52
+
53
+ export async function initGameCommand(dirArg, options = {}) {
54
+ const json = !!options.json;
55
+ const dryRun = !!options.dryRun;
56
+ // JSON means a program is driving: never prompt.
57
+ const ask = !json && !options.yes;
58
+ const dir = resolve(dirArg || options.dir || process.cwd());
59
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return fail(json, `No such folder: ${dir}`);
60
+
61
+ let info;
62
+ try {
63
+ info = inspectGameFolder(dir);
64
+ } catch (err) {
65
+ return fail(json, err.message);
66
+ }
67
+
68
+ if (info.kind === 'unsupported') {
69
+ if (!json) return fail(json, info.reason);
70
+ process.exitCode = 1;
71
+ return out(json, { ok: false, kind: info.kind, dir, error: info.reason });
72
+ }
73
+
74
+ if (info.kind === 'unity') {
75
+ if (json) return out(json, { ok: true, kind: 'unity', dir, dryRun, changes: [], notes: [] });
76
+ console.log(brand.green(`\n ✓ ${info.name} is a MyVillage Unity game. Nothing to set up here.`));
77
+ return;
78
+ }
79
+
80
+ // ── Already a MyVillage game: refresh its platform files ──
81
+ if (info.kind === 'myvillage') {
82
+ if (dryRun || !info.changes.length) {
83
+ if (json) return out(json, { ok: true, kind: 'myvillage', dir, name: info.name, dryRun: true, changes: info.changes, notes: [] });
84
+ if (!info.changes.length) {
85
+ console.log(brand.green(`\n ✓ ${info.name} already has the latest MyVillage platform files.`));
86
+ } else {
87
+ console.log(`\n ${info.name} has older MyVillage platform files:\n`);
88
+ printChanges(info.changes);
89
+ console.log(brand.teal('\n Run without --dry-run to update them.'));
90
+ }
91
+ return;
92
+ }
93
+ if (ask) {
94
+ console.log(`\n ${info.name} has older MyVillage platform files. This will:\n`);
95
+ printChanges(info.changes);
96
+ console.log();
97
+ if (!(await confirm('Update them?'))) return;
98
+ }
99
+ try {
100
+ const res = updatePlatformFiles(dir);
101
+ if (json) return out(json, { ok: true, kind: 'myvillage', dir, name: info.name, dryRun: false, changes: res.changes, notes: [] });
102
+ console.log(brand.green(`\n ✓ Updated ${res.changes.map((c) => c.path).join(', ')}`));
103
+ } catch (err) {
104
+ fail(json, err.message);
105
+ }
106
+ return;
107
+ }
108
+
109
+ // ── A web project: convert it ──
110
+ const given = {
111
+ name: options.name,
112
+ description: options.description,
113
+ category: options.category,
114
+ orientation: options.orientation,
115
+ targetAge: options.targetAge,
116
+ };
117
+ let details = { ...info.defaults };
118
+ for (const [k, v] of Object.entries(given)) if (v !== undefined) details[k] = String(v);
119
+ if (options.into && options.inPlace) return fail(json, 'Use --into or --in-place, not both.');
120
+ let into = options.inPlace ? undefined : resolve(options.into || defaultCopyDir(dir));
121
+
122
+ if (ask && !dryRun) {
123
+ console.log(`\n ${brand.gold(dir)} is a web project that isn't a MyVillage game yet.\n`);
124
+ const prompts = [];
125
+ if (given.name === undefined) prompts.push({ type: 'input', name: 'name', message: 'Game name:', default: details.name });
126
+ if (given.description === undefined) prompts.push({ type: 'input', name: 'description', message: 'Description:', default: details.description });
127
+ if (given.category === undefined)
128
+ prompts.push({ type: 'list', name: 'category', message: 'What kind of game is it?', choices: GAME_CATEGORIES, default: details.category });
129
+ if (given.orientation === undefined)
130
+ prompts.push({
131
+ type: 'list',
132
+ name: 'orientation',
133
+ message: 'Where will it play?',
134
+ choices: [
135
+ { name: 'Portrait — muni mobile app + web', value: 'portrait' },
136
+ { name: 'Landscape — muni mobile app (turned sideways) + web', value: 'landscape' },
137
+ ],
138
+ default: details.orientation,
139
+ });
140
+ if (given.targetAge === undefined)
141
+ prompts.push({ type: 'list', name: 'targetAge', message: 'Target age group:', choices: TARGET_AGES, default: details.targetAge });
142
+ if (!options.into && !options.inPlace)
143
+ prompts.push({
144
+ type: 'list',
145
+ name: 'where',
146
+ message: 'Where should the MyVillage version go?',
147
+ choices: [
148
+ { name: `A new folder: ${basename(into)} (your original stays as it is)`, value: 'copy' },
149
+ { name: 'This folder (change it in place)', value: 'in-place' },
150
+ ],
151
+ default: 'copy',
152
+ });
153
+ const { where, ...answers } = await inquirer.prompt(prompts);
154
+ details = { ...details, ...answers };
155
+ if (where === 'in-place') into = undefined;
156
+ }
157
+
158
+ let plan;
159
+ try {
160
+ plan = convertToGame(dir, details, { dryRun: true, into });
161
+ } catch (err) {
162
+ return fail(json, err.message);
163
+ }
164
+
165
+ if (dryRun) {
166
+ if (json) return out(json, { ok: true, kind: 'web', source: dir, name: details.name, defaults: info.defaults, dryRun: true, ...plan });
167
+ console.log(`\n Bringing ${details.name} into MyVillage would${into ? ` copy it to ${brand.gold(into)} and then` : ''}:\n`);
168
+ printChanges(plan.changes);
169
+ printNotes(plan.notes);
170
+ console.log(brand.teal('\n Run without --dry-run to make these changes.'));
171
+ return;
172
+ }
173
+
174
+ if (ask) {
175
+ console.log(into ? `\n This will copy the game to ${brand.gold(into)}, then in the copy:\n` : `\n This will:\n`);
176
+ printChanges(plan.changes);
177
+ printNotes(plan.notes);
178
+ console.log(brand.teal(into ? '\n The original folder isn\'t changed. node_modules, build output and git history aren\'t copied.' : "\n Your game's own code isn't changed."));
179
+ console.log();
180
+ if (!(await confirm('Make these changes?'))) return;
181
+ }
182
+
183
+ try {
184
+ const res = convertToGame(dir, details, { into });
185
+ if (json) return out(json, { ok: true, kind: 'web', source: dir, name: details.name, dryRun: false, ...res });
186
+ console.log(brand.green(`\n ✓ ${details.name} is set up as a MyVillage game${into ? ` in ${res.dir}` : ''}.`));
187
+ if (into) console.log(brand.teal(` cd ${res.dir} && npm install`));
188
+ printNotes(res.notes);
189
+ console.log();
190
+ console.log(brand.teal(' Next, connect the game itself (or let MVP Studio\'s AI do it: open the game → Design):'));
191
+ console.log(` 1. ${brand.gold("import { MyVillageGame } from './myvillage-game.js'")} where the game starts`);
192
+ console.log(` 2. ${brand.gold('MyVillageGame.ready()')} once it's playable`);
193
+ console.log(` 3. ${brand.gold('MyVillageGame.complete({ score, won })')} on every way it can end`);
194
+ console.log(` 4. ${brand.gold('npm run build')}, then ${brand.gold('myvillage deploy')}`);
195
+ console.log(brand.teal(' AGENTS.md has the full rules (phone layout, play time).'));
196
+ console.log();
197
+ } catch (err) {
198
+ fail(json, err.message);
199
+ }
200
+ }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  apiKeyShowScopesCommand,
17
17
  } from './commands/api-key.js';
18
18
  import { createGameCommand } from './commands/create-game.js';
19
+ import { initGameCommand } from './commands/init-game.js';
19
20
  import { createCommand } from './commands/create-app.js';
20
21
  import { deployCommand } from './commands/deploy.js';
21
22
  import { publishBookCommand } from './commands/publish-book.js';
@@ -229,6 +230,22 @@ export function run() {
229
230
  .option('--json', 'Output the result as JSON (implies non-interactive)')
230
231
  .action(createGameCommand);
231
232
 
233
+ program
234
+ .command('init-game [dir]')
235
+ .description('Bring an existing web game into MyVillage, or update a game\'s MyVillage platform files')
236
+ .option('--name <name>', 'Game name (defaults to package.json)')
237
+ .option('--description <text>', 'Game description')
238
+ .option('--category <category>', 'education | action | adventure | puzzle | quiz | exploration | narrative | other')
239
+ .option('--orientation <orientation>', 'portrait | landscape')
240
+ .option('--target-age <age>', 'under-5 | 6-12 | 13-and-over')
241
+ .option('--dir <path>', 'Project folder (defaults to cwd)')
242
+ .option('--into <path>', 'Convert into this new folder (default: <folder>-myvillage next to it)')
243
+ .option('--in-place', 'Convert the folder itself instead of a copy')
244
+ .option('--dry-run', 'Show what would change and write nothing')
245
+ .option('-y, --yes', "Don't ask: use the defaults and make the changes")
246
+ .option('--json', 'Output the result as JSON (implies --yes)')
247
+ .action(initGameCommand);
248
+
232
249
  program
233
250
  .command('create')
234
251
  .description('Create a new application or game')
@@ -0,0 +1,379 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, readdirSync, rmSync } from 'fs';
2
+ import { join, basename, dirname, resolve, relative, isAbsolute, sep } from 'path';
3
+ import { installGameToolkit, refreshGameToolkit } from './game-toolkit.js';
4
+ import { generateGameAgents, generateMinimalViteConfig, starterThumbnailPath } from './templates.js';
5
+
6
+ // Bringing a web game that wasn't made with MyVillage into the platform
7
+ // (`myvillage init-game`), and keeping a MyVillage game's platform files current.
8
+ // MVP Studio imports this module in-process, so it must stay free of chalk,
9
+ // inquirer and anything else that prints or prompts.
10
+ //
11
+ // Every entry point takes `dryRun`: the same code computes what would change
12
+ // and makes the change, so what Studio shows before asking is exactly what
13
+ // gets written.
14
+
15
+ export const GAME_CATEGORIES = ['education', 'action', 'adventure', 'puzzle', 'quiz', 'exploration', 'narrative', 'other'];
16
+ export const TARGET_AGES = ['under-5', '6-12', '13-and-over'];
17
+ export const ORIENTATIONS = ['portrait', 'landscape'];
18
+
19
+ const VITE_CONFIGS = ['vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cjs', 'vite.config.cts'];
20
+ const OUR_AGENTS = /^# AGENTS\.md — working on this MyVillage/;
21
+ const AGENTS_START = '<!-- myvillage:agents:start -->';
22
+ const AGENTS_END = '<!-- myvillage:agents:end -->';
23
+
24
+ function readJson(path) {
25
+ try {
26
+ return JSON.parse(readFileSync(path, 'utf8'));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function writeJson(path, data) {
33
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
34
+ }
35
+
36
+ function deps(pkg) {
37
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
38
+ }
39
+
40
+ function titleFromPackageName(raw) {
41
+ const bare = String(raw || '').replace(/^@[^/]+\//, '');
42
+ return bare
43
+ .split(/[-_.\s]+/)
44
+ .filter(Boolean)
45
+ .map((w) => w[0].toUpperCase() + w.slice(1))
46
+ .join(' ');
47
+ }
48
+
49
+ /**
50
+ * What kind of folder this is, and what `init-game` would do to it.
51
+ *
52
+ * kind 'web' a package.json project with no `myvillage` block: `changes`
53
+ * is the conversion plan, `defaults` the prefilled details
54
+ * kind 'myvillage' already a MyVillage JS game: `changes` lists platform
55
+ * files that are older than this CLI's
56
+ * kind 'unity' a MyVillage Unity game (nothing to update here)
57
+ * kind 'unsupported' anything else; `reason` says what is supported
58
+ */
59
+ export function inspectGameFolder(dir) {
60
+ const unity = readJson(join(dir, 'myvillage.json'));
61
+ if (unity && unity.engine === 'unity') {
62
+ return { kind: 'unity', dir, name: unity.name || basename(dir), changes: [], notes: [] };
63
+ }
64
+
65
+ const pkgPath = join(dir, 'package.json');
66
+ const pkg = readJson(pkgPath);
67
+ if (!pkg) {
68
+ const looksUnity = existsSync(join(dir, 'Assets')) && existsSync(join(dir, 'ProjectSettings'));
69
+ const reason = existsSync(pkgPath)
70
+ ? "package.json isn't valid JSON, so this folder can't be read as a web game. Fix it and try again."
71
+ : looksUnity
72
+ ? 'This looks like a Unity project that wasn\'t made with MyVillage. Unity games start from New game with Engine set to Unity; bring your scenes and scripts into that project.'
73
+ : 'This folder isn\'t a game Studio can bring in. Studio supports web games with a package.json (built with Vite, or any tool whose `npm run build` writes a dist/ folder), and Unity games made with MyVillage.';
74
+ return { kind: 'unsupported', dir, reason, changes: [], notes: [] };
75
+ }
76
+
77
+ if (pkg.myvillage && typeof pkg.myvillage === 'object') {
78
+ const { changes } = updatePlatformFiles(dir, { dryRun: true });
79
+ return { kind: 'myvillage', dir, name: pkg.myvillage.name || pkg.name || basename(dir), changes, notes: [] };
80
+ }
81
+
82
+ const defaults = conversionDefaults(dir, pkg);
83
+ const { changes, notes } = convertToGame(dir, defaults, { dryRun: true });
84
+ return { kind: 'web', dir, name: defaults.name, defaults, changes, notes, copyTo: defaultCopyDir(dir) };
85
+ }
86
+
87
+ // ── Converting into a copy ──────────────────────────────────────────────────
88
+ // By default a game is converted into a new folder next to the original, so the
89
+ // original stays exactly as it was. Installed packages and build output aren't
90
+ // copied (npm install / npm run build make them again), and neither is git
91
+ // history: the copy is a new project.
92
+
93
+ const SKIP_ANYWHERE = new Set(['node_modules', '.git', '.DS_Store']);
94
+ const SKIP_AT_TOP = new Set(['dist', 'build', '.vite', '.cache', '.parcel-cache', '.turbo', '.next', 'coverage']);
95
+
96
+ /** A free sibling folder for the converted copy: `<name>-myvillage`, then -2, -3… */
97
+ export function defaultCopyDir(dir) {
98
+ const base = join(dirname(resolve(dir)), `${basename(resolve(dir))}-myvillage`);
99
+ if (!existsSync(base)) return base;
100
+ for (let i = 2; ; i++) if (!existsSync(`${base}-${i}`)) return `${base}-${i}`;
101
+ }
102
+
103
+ function isInside(child, parent) {
104
+ const rel = relative(parent, child);
105
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
106
+ }
107
+
108
+ /** Copy a project into `to` (which must not exist yet, or be empty). */
109
+ export function copyGameProject(from, to) {
110
+ const src = resolve(from);
111
+ const dest = resolve(to);
112
+ if (isInside(dest, src)) throw new Error('The new folder can\'t be inside the game\'s own folder.');
113
+ if (existsSync(dest) && readdirSync(dest).length) {
114
+ throw new Error(`${dest} already exists and isn't empty. Pick a new folder.`);
115
+ }
116
+ cpSync(src, dest, {
117
+ recursive: true,
118
+ filter: (path) => {
119
+ const rel = relative(src, path);
120
+ if (!rel) return true;
121
+ const parts = rel.split(sep);
122
+ if (parts.some((p) => SKIP_ANYWHERE.has(p))) return false;
123
+ return !SKIP_AT_TOP.has(parts[0]);
124
+ },
125
+ });
126
+ }
127
+
128
+ /** The game details to prefill, from what package.json already says. */
129
+ export function conversionDefaults(dir, pkg = readJson(join(dir, 'package.json')) || {}) {
130
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.map((k) => String(k).toLowerCase()) : [];
131
+ return {
132
+ name: String(pkg.productName || '').trim() || titleFromPackageName(pkg.name) || titleFromPackageName(basename(dir)) || 'My Game',
133
+ description: typeof pkg.description === 'string' ? pkg.description : '',
134
+ category: GAME_CATEGORIES.find((c) => keywords.includes(c)) || 'education',
135
+ // Portrait plays everywhere (the muni app and the web).
136
+ orientation: 'portrait',
137
+ targetAge: '6-12',
138
+ };
139
+ }
140
+
141
+ function normalizeDetails(details) {
142
+ const name = String(details.name || '').trim();
143
+ if (!name) throw new Error('The game needs a name.');
144
+ const category = String(details.category || 'education').toLowerCase();
145
+ if (!GAME_CATEGORIES.includes(category)) throw new Error(`Category must be one of: ${GAME_CATEGORIES.join(', ')}`);
146
+ const orientation = String(details.orientation || 'portrait').toLowerCase();
147
+ if (!ORIENTATIONS.includes(orientation)) throw new Error('Orientation must be portrait or landscape.');
148
+ const targetAge = String(details.targetAge || '6-12').toLowerCase();
149
+ if (!TARGET_AGES.includes(targetAge)) throw new Error(`Target age must be one of: ${TARGET_AGES.join(', ')}`);
150
+ const description = typeof details.description === 'string' ? details.description.trim() : '';
151
+ return { name, category, orientation, targetAge, description };
152
+ }
153
+
154
+ /**
155
+ * Turn a package.json web project into a MyVillage game: the `myvillage`
156
+ * block, the toolkit, AGENTS.md, a relative Vite base + build script, and a
157
+ * starter thumbnail. Never touches the game's own code, and never rewrites a
158
+ * build it doesn't understand (a note says what's needed instead).
159
+ *
160
+ * With `into`, the project is first copied there (see copyGameProject) and the
161
+ * copy is converted; the original folder isn't touched. Without it, the folder
162
+ * itself is converted.
163
+ *
164
+ * Returns { dir, changes: [{ path, action: 'create'|'update', summary }], notes },
165
+ * where `dir` is the folder that was (or would be) converted.
166
+ */
167
+ export function convertToGame(dir, details, { dryRun = false, into } = {}) {
168
+ if (!into) return { dir, ...convertInPlace(dir, details, { dryRun }) };
169
+ // The copy would get exactly the original's plan (it's the same files).
170
+ if (dryRun) return { dir: resolve(into), ...convertInPlace(dir, details, { dryRun: true }) };
171
+ // Check the details before copying anything.
172
+ normalizeDetails(details);
173
+ const dest = resolve(into);
174
+ const existed = existsSync(dest);
175
+ copyGameProject(dir, dest);
176
+ try {
177
+ return { dir: dest, ...convertInPlace(dest, details) };
178
+ } catch (err) {
179
+ // Don't leave a half-converted copy behind.
180
+ if (!existed) rmSync(dest, { recursive: true, force: true });
181
+ throw err;
182
+ }
183
+ }
184
+
185
+ function convertInPlace(dir, details, { dryRun = false } = {}) {
186
+ const pkgPath = join(dir, 'package.json');
187
+ const pkg = readJson(pkgPath);
188
+ if (!pkg) throw new Error('No readable package.json in this folder.');
189
+ if (pkg.myvillage) throw new Error('This folder is already a MyVillage game.');
190
+ const game = normalizeDetails(details);
191
+
192
+ const changes = [];
193
+ const notes = [];
194
+ const change = (path, action, summary) => changes.push({ path, action, summary });
195
+
196
+ const isVite = !!deps(pkg).vite || VITE_CONFIGS.some((f) => existsSync(join(dir, f)));
197
+
198
+ // ── package.json ──
199
+ const pkgSummary = ['add the MyVillage details (name, kind, orientation, ages)'];
200
+ const scripts = { ...(pkg.scripts || {}) };
201
+ if (isVite) {
202
+ if (!scripts.build) {
203
+ scripts.build = 'vite build';
204
+ pkgSummary.push('add a `build` script (vite build)');
205
+ } else if (!/\bvite\b/.test(scripts.build)) {
206
+ notes.push(`The \`build\` script ("${scripts.build}") doesn't run Vite. Deploy runs \`npm run build\` and uploads the dist/ folder it makes, so check that it still does.`);
207
+ }
208
+ if (!scripts.dev) {
209
+ scripts.dev = 'vite';
210
+ pkgSummary.push('add a `dev` script for the preview');
211
+ }
212
+ } else if (!scripts.build) {
213
+ notes.push("This project doesn't use Vite and has no `build` script. Deploy runs `npm run build` and uploads the dist/ folder it makes (with an index.html and relative asset paths), so add a build that does that. Vite is the easiest way.");
214
+ } else {
215
+ notes.push(`This project doesn't use Vite, so its build was left alone. Deploy runs \`npm run build\` ("${scripts.build}") and uploads dist/: make sure it writes there, with index.html at the top and asset paths that don't start with "/".`);
216
+ }
217
+ if (!isVite && !scripts.dev) {
218
+ notes.push('There is no `dev` script, so Studio can\'t show a live preview until one is added.');
219
+ }
220
+
221
+ const banner = ['assets/banner.png', 'assets/banner.jpg'].find((p) => existsSync(join(dir, p)));
222
+ const myvillage = {
223
+ name: game.name,
224
+ gameId: null,
225
+ gameType: 'custom',
226
+ category: game.category,
227
+ engine: deps(pkg).three ? 'three' : 'web',
228
+ // Brought in with init-game (not scaffolded): picks the AGENTS.md guide.
229
+ converted: true,
230
+ targetAge: game.targetAge,
231
+ orientation: game.orientation,
232
+ aspectRatio: game.orientation === 'portrait' ? '9:16' : '16:9',
233
+ assets: { thumbnail: 'assets/thumbnail.png', ...(banner && { banner }) },
234
+ villages: [],
235
+ lastDeployed: null,
236
+ };
237
+ const nextPkg = { ...pkg };
238
+ if (game.description && game.description !== pkg.description) nextPkg.description = game.description;
239
+ if (Object.keys(scripts).length) nextPkg.scripts = scripts;
240
+ nextPkg.myvillage = myvillage;
241
+ change('package.json', 'update', pkgSummary.join('; '));
242
+
243
+ // ── Vite config ──
244
+ if (isVite) {
245
+ const configFile = VITE_CONFIGS.find((f) => existsSync(join(dir, f)));
246
+ if (!configFile) {
247
+ const file = pkg.type === 'module' ? 'vite.config.js' : 'vite.config.mjs';
248
+ change(file, 'create', "use relative asset paths (base: './') so the game loads from MyVillage");
249
+ if (!dryRun) writeFileSync(join(dir, file), generateMinimalViteConfig());
250
+ } else {
251
+ const fixed = withRelativeBase(readFileSync(join(dir, configFile), 'utf8'));
252
+ if (fixed.text) {
253
+ change(configFile, 'update', fixed.summary);
254
+ if (!dryRun) writeFileSync(join(dir, configFile), fixed.text);
255
+ } else if (fixed.note) {
256
+ notes.push(`${configFile}: ${fixed.note}`);
257
+ }
258
+ const outDir = /\boutDir\s*:\s*(['"`])([^'"`]+)\1/.exec(readFileSync(join(dir, configFile), 'utf8'))?.[2];
259
+ if (outDir && !['dist', './dist', 'dist/', './dist/'].includes(outDir)) {
260
+ notes.push(`${configFile} builds into "${outDir}", but deploy uploads dist/. Change build.outDir to 'dist'.`);
261
+ }
262
+ }
263
+ }
264
+
265
+ // ── Toolkit ──
266
+ const hadToolkit = new Set(['src/myvillage-game.js', 'src/myvillage-realtime.js'].filter((p) => existsSync(join(dir, p))));
267
+ const toolkit = installGameToolkit(dir, { dryRun });
268
+ for (const path of toolkit.changed) {
269
+ change(path, hadToolkit.has(path) ? 'update' : 'create',
270
+ path.endsWith('myvillage-game.js')
271
+ ? 'the MyVillage toolkit: ready(), complete() and automatic play time'
272
+ : 'multiplayer support the toolkit loads when a game asks for it');
273
+ }
274
+ for (const path of toolkit.conflicts) {
275
+ notes.push(`${path} already exists and isn't the MyVillage toolkit, so it was left alone. Rename it, then run this again.`);
276
+ }
277
+
278
+ // ── AGENTS.md ──
279
+ const agents = agentsMdChange(dir, myvillage, { slug: pkg.name, description: nextPkg.description, dryRun });
280
+ if (agents) change('AGENTS.md', agents.action, agents.summary);
281
+
282
+ // ── Cover art ──
283
+ if (!existsSync(join(dir, 'assets', 'thumbnail.png'))) {
284
+ change('assets/thumbnail.png', 'create', 'starter cover art (swap it for your own any time)');
285
+ if (!dryRun) {
286
+ mkdirSync(join(dir, 'assets'), { recursive: true });
287
+ copyFileSync(starterThumbnailPath(), join(dir, 'assets', 'thumbnail.png'));
288
+ }
289
+ }
290
+
291
+ // package.json last, so a failure above leaves the folder still unconverted.
292
+ if (!dryRun) writeJson(pkgPath, nextPkg);
293
+ return { changes, notes };
294
+ }
295
+
296
+ /**
297
+ * Bring a MyVillage JS game's platform files (the toolkit and AGENTS.md) up to
298
+ * this CLI's. `deploy` already refreshes the toolkit; AGENTS.md only changes here.
299
+ */
300
+ export function updatePlatformFiles(dir, { dryRun = false } = {}) {
301
+ const pkg = readJson(join(dir, 'package.json'));
302
+ if (!pkg || !pkg.myvillage) throw new Error('Not a MyVillage web game (no `myvillage` block in package.json).');
303
+
304
+ const changes = [];
305
+ for (const path of refreshGameToolkit(dir, { dryRun })) {
306
+ changes.push({ path, action: 'update', summary: 'the latest MyVillage toolkit' });
307
+ }
308
+ const agents = agentsMdChange(dir, pkg.myvillage, { slug: pkg.name, description: pkg.description, dryRun });
309
+ if (agents) changes.push({ path: 'AGENTS.md', ...agents });
310
+ return { changes, notes: [] };
311
+ }
312
+
313
+ /**
314
+ * Write (or plan) the AGENTS.md this CLI generates for the game. A file that
315
+ * is ours is replaced; someone else's AGENTS.md keeps its text and gets ours as
316
+ * a marked section at the end. Returns null when it's already current.
317
+ */
318
+ function agentsMdChange(dir, myvillage, { slug, description, dryRun }) {
319
+ const path = join(dir, 'AGENTS.md');
320
+ const latest = generateGameAgents(myvillage, { slug, description });
321
+ const section = `${AGENTS_START}\n${latest.trimEnd()}\n${AGENTS_END}\n`;
322
+
323
+ let next;
324
+ let action;
325
+ let summary;
326
+ if (!existsSync(path)) {
327
+ next = latest;
328
+ action = 'create';
329
+ summary = 'a guide for AI helpers: the game contract and phone layout rules';
330
+ } else {
331
+ const current = readFileSync(path, 'utf8');
332
+ const start = current.indexOf(AGENTS_START);
333
+ const end = current.indexOf(AGENTS_END);
334
+ if (OUR_AGENTS.test(current)) {
335
+ if (current === latest) return null;
336
+ next = latest;
337
+ summary = 'replace with the latest guide for AI helpers';
338
+ } else if (start >= 0 && end > start) {
339
+ const tail = current.slice(end + AGENTS_END.length).replace(/^\n/, '');
340
+ next = current.slice(0, start) + section + tail;
341
+ if (next === current) return null;
342
+ summary = 'refresh the MyVillage section (your own text is kept)';
343
+ } else {
344
+ next = current.trimEnd() + '\n\n' + section;
345
+ summary = 'add a MyVillage section at the end (your own text is kept)';
346
+ }
347
+ action = 'update';
348
+ }
349
+ if (!dryRun) writeFileSync(path, next);
350
+ return { action, summary };
351
+ }
352
+
353
+ /**
354
+ * Make a Vite config use base: './'. Returns { text, summary } with the new
355
+ * config, { note } when it can't be changed safely (a computed base, a config
356
+ * shape we don't recognise), or {} when it's already right.
357
+ */
358
+ export function withRelativeBase(config) {
359
+ const literal = /\bbase\s*:\s*(['"`])([^'"`]*)\1/.exec(config);
360
+ if (literal) {
361
+ if (literal[2] === './' || literal[2] === '') return {};
362
+ return {
363
+ text: config.slice(0, literal.index) + "base: './'" + config.slice(literal.index + literal[0].length),
364
+ summary: `use relative asset paths: base '${literal[2]}' becomes './'`,
365
+ };
366
+ }
367
+ if (/\bbase\s*:/.test(config)) {
368
+ return { note: "`base` is computed, so it was left alone. Make sure the build uses base: './' (relative asset paths); MyVillage serves the game from its own folder." };
369
+ }
370
+ const open = /(defineConfig\(\s*(?:\([^)]*\)\s*=>\s*\(\s*)?\{|export\s+default\s+\{|module\.exports\s*=\s*\{)/.exec(config);
371
+ if (!open) {
372
+ return { note: "couldn't find where to add base: './'. Add it to the config so asset paths are relative; MyVillage serves the game from its own folder." };
373
+ }
374
+ const at = open.index + open[0].length;
375
+ return {
376
+ text: config.slice(0, at) + "\n base: './'," + config.slice(at),
377
+ summary: "use relative asset paths (base: './') so the game loads from MyVillage",
378
+ };
379
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs';
2
+ import { join, dirname, extname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ // The platform files every JS game ships (see src/assets/game-toolkit). They're
6
+ // copied into a game at scaffold time, so without a refresh a game keeps the
7
+ // toolkit it was created with forever — and platform behaviour that lives in
8
+ // the toolkit (like reporting play time) never reaches it. `deploy` refreshes
9
+ // them, so every deploy from the CLI or MVP Studio ships the current toolkit.
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const TOOLKIT_FILES = [
13
+ {
14
+ target: 'src/myvillage-game.js',
15
+ source: join(HERE, '..', 'assets', 'game-toolkit', 'myvillage-game.js'),
16
+ // Only replace a file that is still ours; a hand-rolled bridge is left alone.
17
+ isOurs: (text) => text.startsWith('// MyVillage Game Bridge'),
18
+ },
19
+ {
20
+ target: 'src/myvillage-realtime.js',
21
+ source: join(HERE, '..', '..', 'vendor', 'realtime', 'myvillage-realtime-client.js'),
22
+ isOurs: (text) => text.startsWith('// myvillage-realtime client'),
23
+ // The bridge imports it lazily, so it must exist next to the bridge for the
24
+ // build to resolve, even in games scaffolded before multiplayer existed.
25
+ createIfMissing: true,
26
+ },
27
+ ];
28
+
29
+ /**
30
+ * Bring a game's platform files up to the toolkit bundled with this CLI.
31
+ * Returns the paths it changed (or, with `dryRun`, would change). Does nothing
32
+ * for a game without our bridge.
33
+ */
34
+ export function refreshGameToolkit(projectDir, { dryRun = false } = {}) {
35
+ const bridge = join(projectDir, 'src/myvillage-game.js');
36
+ if (!existsSync(bridge) || !TOOLKIT_FILES[0].isOurs(readFileSync(bridge, 'utf8'))) return [];
37
+
38
+ const changed = [];
39
+ for (const file of TOOLKIT_FILES) {
40
+ const target = join(projectDir, file.target);
41
+ const latest = readFileSync(file.source, 'utf8');
42
+ if (existsSync(target)) {
43
+ const current = readFileSync(target, 'utf8');
44
+ if (current === latest || !file.isOurs(current)) continue;
45
+ } else if (!file.createIfMissing) {
46
+ continue;
47
+ }
48
+ if (!dryRun) writeFileSync(target, latest);
49
+ changed.push(file.target);
50
+ }
51
+ return changed;
52
+ }
53
+
54
+ /**
55
+ * Add the toolkit to a game that doesn't have it yet (`init-game`). Never
56
+ * replaces a file of the same name that isn't ours; those come back in
57
+ * `conflicts` so the caller can say so.
58
+ */
59
+ export function installGameToolkit(projectDir, { dryRun = false } = {}) {
60
+ const changed = [];
61
+ const conflicts = [];
62
+ for (const file of TOOLKIT_FILES) {
63
+ const target = join(projectDir, file.target);
64
+ if (existsSync(target)) {
65
+ if (!file.isOurs(readFileSync(target, 'utf8'))) conflicts.push(file.target);
66
+ continue;
67
+ }
68
+ if (!dryRun) {
69
+ mkdirSync(dirname(target), { recursive: true });
70
+ writeFileSync(target, readFileSync(file.source, 'utf8'));
71
+ }
72
+ changed.push(file.target);
73
+ }
74
+ // A bridge that was already there may be an older copy of ours.
75
+ if (!conflicts.includes(TOOLKIT_FILES[0].target)) {
76
+ changed.push(...refreshGameToolkit(projectDir, { dryRun }).filter((p) => !changed.includes(p)));
77
+ }
78
+ return { changed, conflicts };
79
+ }
80
+
81
+ /**
82
+ * Whether the game's own code (not the toolkit) ever calls complete(). A game
83
+ * that never does can't record a finished game or pay its reward.
84
+ */
85
+ export function gameCallsComplete(projectDir) {
86
+ const src = join(projectDir, 'src');
87
+ if (!existsSync(src)) return true; // not a toolkit game; nothing to judge
88
+ const toolkit = new Set(TOOLKIT_FILES.map((f) => join(projectDir, f.target)));
89
+ const stack = [src];
90
+ while (stack.length) {
91
+ const dir = stack.pop();
92
+ for (const name of readdirSync(dir)) {
93
+ const path = join(dir, name);
94
+ if (statSync(path).isDirectory()) {
95
+ stack.push(path);
96
+ } else if (['.js', '.mjs', '.ts', '.jsx', '.tsx'].includes(extname(name)) && !toolkit.has(path)) {
97
+ if (/MyVillageGame\s*\.\s*complete\s*\(/.test(readFileSync(path, 'utf8'))) return true;
98
+ }
99
+ }
100
+ }
101
+ return false;
102
+ }
@@ -700,20 +700,7 @@ function createNarrativeProject(targetDir, options) {
700
700
  function createArenaProject(targetDir, options) {
701
701
  const { name, description, ageGroup, orientation = 'landscape' } = options;
702
702
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
703
- const src = join(ASSETS_DIR, 'templates', 'arena');
704
- const vars = {
705
- NAME: name,
706
- SLUG: slug,
707
- DESCRIPTION: description || 'A real-time multiplayer arena game.',
708
- ORIENTATION: orientation,
709
- GOLD: BRAND.gold,
710
- DARK: BRAND.darkBrown,
711
- CONTAINER_CSS: orientation === 'portrait'
712
- ? `#game-container { position: relative; width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; }
713
- @media (min-aspect-ratio: 3/4) { #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; } }`
714
- : '#game-container { position: relative; width: 100%; height: 100%; overflow: hidden; }',
715
- };
716
- const fill = (file) => readFileSync(join(src, file), 'utf8').replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (k in vars ? vars[k] : m));
703
+ const fill = arenaTemplateFiller(name, slug, description, orientation);
717
704
 
718
705
  for (const dir of ['', 'src', 'assets', 'public']) {
719
706
  mkdirSync(join(targetDir, dir), { recursive: true });
@@ -731,6 +718,24 @@ function createArenaProject(targetDir, options) {
731
718
  copyTemplateArt(targetDir, 'arena', orientation);
732
719
  }
733
720
 
721
+ // Reads one arena template file with its {{VARS}} filled in.
722
+ function arenaTemplateFiller(name, slug, description, orientation) {
723
+ const src = join(ASSETS_DIR, 'templates', 'arena');
724
+ const vars = {
725
+ NAME: name,
726
+ SLUG: slug,
727
+ DESCRIPTION: description || 'A real-time multiplayer arena game.',
728
+ ORIENTATION: orientation,
729
+ GOLD: BRAND.gold,
730
+ DARK: BRAND.darkBrown,
731
+ CONTAINER_CSS: orientation === 'portrait'
732
+ ? `#game-container { position: relative; width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; }
733
+ @media (min-aspect-ratio: 3/4) { #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; } }`
734
+ : '#game-container { position: relative; width: 100%; height: 100%; overflow: hidden; }',
735
+ };
736
+ return (file) => readFileSync(join(src, file), 'utf8').replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (k in vars ? vars[k] : m));
737
+ }
738
+
734
739
  function generateArenaPackageJson(name, description, slug, ageGroup, orientation) {
735
740
  const pkg = JSON.parse(generatePackageJson(name, description || 'A real-time multiplayer arena game.', slug, 'arena', ageGroup, orientation));
736
741
  pkg.myvillage.category = 'action';
@@ -1129,7 +1134,8 @@ function generate2DGameAgents(name, gameType, orientation) {
1129
1134
  'The game talks to its host through `src/myvillage-game.js`:',
1130
1135
  '',
1131
1136
  '- `MyVillageGame.ready()` — called on load. Leave it.',
1132
- '- `MyVillageGame.complete({ score, won })` — **required.** Called when the game ends (in `' + endFn + '`). It is how the host records the session and awards the player\'s reward. If you change the end flow, keep this call wired to the real end condition.',
1137
+ '- `MyVillageGame.complete({ score, won })` — **required.** Called when the game ends (in `' + endFn + '`). It records a completion and awards the player\'s reward. If you change the end flow, keep it wired to **every** way the game can end (win, lose, out of time).',
1138
+ '- **Plays count automatically.** `src/myvillage-game.js` reports how long the player is actively playing; the platform counts a play after 30 seconds of it. Do not add timers, fake input, or early `complete()` calls to make plays count.',
1133
1139
  '',
1134
1140
  'Do **not** add an Exit button — the host always provides its own.',
1135
1141
  '',
@@ -1153,7 +1159,7 @@ function generate2DGameAgents(name, gameType, orientation) {
1153
1159
  '',
1154
1160
  '## Conventions',
1155
1161
  '',
1156
- '- Orientation is **' + orientation + '** (set in `package.json` -> `myvillage.orientation`). Portrait runs on muni mobile **and** web; landscape is web-only.',
1162
+ '- Orientation is **' + orientation + '** (set in `package.json` -> `myvillage.orientation`). Both play in the muni mobile app (it turns the phone to match) and on the web.',
1157
1163
  '- Vanilla JS, no framework — keep it dependency-light.',
1158
1164
  '',
1159
1165
  ].join('\n');
@@ -1224,7 +1230,8 @@ function generate3DGameAgents(name, type, orientation) {
1224
1230
  '## The MyVillage game contract — DO NOT BREAK',
1225
1231
  '',
1226
1232
  '- `MyVillageGame.ready()` — called after assets load in `src/main.js`. Leave it.',
1227
- '- `this.game.endGame({ won, score })` — call at your win/lose condition. It calls `MyVillageGame.complete(...)`, which is **required**: the host records the session and awards the player\'s reward.',
1233
+ '- `this.game.endGame({ won, score })` — call at **every** win/lose condition. It calls `MyVillageGame.complete(...)`, which is **required**: it records a completion and awards the player\'s reward. An exploration game needs a real end (everything found, the exit reached) that calls it.',
1234
+ '- **Plays count automatically.** `src/myvillage-game.js` reports how long the player is actively playing; the platform counts a play after 30 seconds of it. Do not add timers, fake input, or early `complete()` calls to make plays count.',
1228
1235
  '',
1229
1236
  'Do **not** add an Exit button — the host always provides its own. During `npm run dev` there is no host, so host calls just log to the console (expected).',
1230
1237
  '',
@@ -1246,12 +1253,108 @@ function generate3DGameAgents(name, type, orientation) {
1246
1253
  '',
1247
1254
  '## Conventions',
1248
1255
  '',
1249
- '- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`). Portrait runs on muni mobile **and** web; landscape is web-only.',
1256
+ '- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`). Both play in the muni mobile app (it turns the phone to match) and on the web.',
1250
1257
  '- The follow-camera and game loop already exist — extend them, do not rebuild.',
1251
1258
  '',
1252
1259
  ].join('\n');
1253
1260
  }
1254
1261
 
1262
+ // For a web game that started outside MyVillage and was brought in with
1263
+ // `myvillage init-game`. We don't know its files, so instead of a file map it
1264
+ // says how to connect any game to the host.
1265
+ function generateConvertedGameAgents(name, orientation) {
1266
+ const banner = orientation === 'portrait'
1267
+ ? '9:16 (e.g. 1080x1920) for this portrait game'
1268
+ : '16:9 (e.g. 1920x1080) for this landscape game';
1269
+ return [
1270
+ '# AGENTS.md — working on this MyVillage game with an AI agent',
1271
+ '',
1272
+ '**' + name + '** is a web game that was brought into MyVillage. It is built with `npm run build` and runs inside the MyVillage host (the muni mobile app or the web player) and standalone in a browser.',
1273
+ '',
1274
+ 'When editing: make the requested change, keep the **MyVillage game contract** intact (see below), and keep `npm run build` working.',
1275
+ '',
1276
+ '## Platform files',
1277
+ '',
1278
+ '| File | Purpose |',
1279
+ '|------|---------|',
1280
+ '| `src/myvillage-game.js` | Host bridge — **do not edit.** Import it from the game\'s code: `import { MyVillageGame } from \'./myvillage-game.js\'` (path relative to the importing file). |',
1281
+ '| `src/myvillage-realtime.js` | Multiplayer netcode (unused unless the game calls `MyVillageGame.multiplayer()`) — **do not edit.** |',
1282
+ '| `assets/` | Cover art shipped on deploy. |',
1283
+ '| `package.json` | The `myvillage` block holds metadata `myvillage deploy` reads. |',
1284
+ '',
1285
+ 'Everything else is the game\'s own code. Read it before changing it, and keep its structure.',
1286
+ '',
1287
+ '## The MyVillage game contract — DO NOT BREAK',
1288
+ '',
1289
+ '- `MyVillageGame.ready()` — call once, when the game is interactive (after loading).',
1290
+ '- `MyVillageGame.complete({ score, won })` — **required.** Call it on **every** way the game can end (win, lose, out of time, last level done). It records a completion and awards the player\'s reward. A game with no ending (endless, sandbox) needs a real one that fits it: a goal, a last level, a time limit.',
1291
+ '- **Plays count automatically.** `src/myvillage-game.js` reports how long the player is actively playing; the platform counts a play after 30 seconds of it. Do not add timers, fake input, or early `complete()` calls to make plays count.',
1292
+ '- Optional: `MyVillageGame.progress({ score })` reports the running score during play. It is throttled, so calling it every frame is fine.',
1293
+ '',
1294
+ 'Do **not** add an Exit button — the host always provides its own. During `npm run dev` there is no host, so host calls just log to the console (expected).',
1295
+ '',
1296
+ '## Screen layout (phones)',
1297
+ '',
1298
+ '- The game fills the whole screen edge to edge, including behind the status bar and home indicator. The viewport meta needs `viewport-fit=cover`. Never letterbox it or add bars.',
1299
+ '- Define the safe-area variables in `:root`: `--safe-top`, `--safe-bottom`, `--safe-left`, `--safe-right` as `max(env(safe-area-inset-*, 0px), var(--mv-safe-*, 0px))`, plus `--hud-top: calc(var(--safe-top) + 12px)` and `--exit-clear: 64px`. Keep HUD inside them: top row at `var(--hud-top)`, others offset by `var(--safe-bottom)` / `var(--safe-left)` / `var(--safe-right)`.',
1300
+ '- Leave the **top-left corner** (`var(--exit-clear)` wide, below `var(--safe-top)`) empty: the host app draws its Exit button there. Put Pause and other top buttons on the right.',
1301
+ '',
1302
+ '## Run / build / deploy',
1303
+ '',
1304
+ ' npm install',
1305
+ ' npm run dev # local dev server, live reload',
1306
+ ' npm run build # production build -> dist/ (must pass before deploy)',
1307
+ ' myvillage deploy # build, then upload + submit to MyVillage',
1308
+ '',
1309
+ 'Keep asset URLs relative (Vite `base: \'./\'`). The host serves from a sub-path, so absolute `/...` URLs break.',
1310
+ '',
1311
+ '## Cover art (`assets/`)',
1312
+ '',
1313
+ '- `thumbnail.png` — square 1:1 (e.g. 512x512). The game card; required.',
1314
+ '- `banner.png` — ' + banner + '. Featured hero; optional.',
1315
+ '',
1316
+ '## Conventions',
1317
+ '',
1318
+ '- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`). Both play in the muni mobile app (it turns the phone to match) and on the web.',
1319
+ '- Don\'t add npm dependencies unless the change really needs one.',
1320
+ '',
1321
+ ].join('\n');
1322
+ }
1323
+
1324
+ /**
1325
+ * The AGENTS.md this CLI would write for a JS game, from its package.json
1326
+ * `myvillage` block. Lets `init-game` bring an older game's guide up to date.
1327
+ */
1328
+ export function generateGameAgents(myvillage, { slug, description } = {}) {
1329
+ const name = myvillage.name || slug || 'My Game';
1330
+ const orientation = myvillage.orientation === 'portrait' ? 'portrait' : 'landscape';
1331
+ if (myvillage.converted) return generateConvertedGameAgents(name, orientation);
1332
+ const type = String(myvillage.gameType || 'custom');
1333
+ if (type === 'quiz' || type === 'narrative') return generate2DGameAgents(name, type, orientation);
1334
+ if (type === 'arena' || myvillage.multiplayer) {
1335
+ const gameSlug = slug || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
1336
+ return arenaTemplateFiller(name, gameSlug, description, orientation)('AGENTS.md');
1337
+ }
1338
+ return generate3DGameAgents(name, type, orientation);
1339
+ }
1340
+
1341
+ /** Vite config for a converted game that had none: just the relative base. */
1342
+ export function generateMinimalViteConfig() {
1343
+ return `import { defineConfig } from 'vite';
1344
+
1345
+ // base: './' keeps asset URLs relative. MyVillage serves each game from its own
1346
+ // folder (/api/v1/games/game-files/<slug>/), so absolute /assets/... URLs break.
1347
+ export default defineConfig({
1348
+ base: './',
1349
+ });
1350
+ `;
1351
+ }
1352
+
1353
+ /** Bundled starter thumbnail (square) for a game that has no cover art yet. */
1354
+ export function starterThumbnailPath() {
1355
+ return join(GAME_ART_DIR, 'custom-thumbnail.png');
1356
+ }
1357
+
1255
1358
  function generateUnityAgents(name, className, orientation) {
1256
1359
  const isLandscape = orientation === 'landscape';
1257
1360
  const reach = isLandscape
@@ -1290,6 +1393,7 @@ function generateUnityAgents(name, className, orientation) {
1290
1393
  '',
1291
1394
  '- The host drives your mission and runs `OnBegin()`. In the editor, `MockMissionHost` stands in and logs host calls to the Console.',
1292
1395
  '- **`CompleteMission(finalScore: n)`** — call when the player finishes. This is the native equivalent of the web games\' `MyVillageGame.complete()`; the host records the session and awards the player\'s reward. **Required to end the game properly.**',
1396
+ '- Plays count automatically: the host times how long the mission is on screen and the platform counts a play after 30 seconds. Call `CompleteMission` on every way the mission can end so finished games are recorded too.',
1293
1397
  '- `FailMission(reason)` — the lose path. `Host.LogEvent("...")` — telemetry.',
1294
1398
  '- **Do not build an Exit button** — the host always shows its own and calls `OnEnd()` for cleanup when the player exits.',
1295
1399
  '',
@@ -1737,8 +1841,8 @@ function generatePackageJson(name, description, slug, type, ageGroup, orientatio
1737
1841
  gameId: null,
1738
1842
  gameType: type,
1739
1843
  targetAge: ageGroup,
1740
- // Portrait games run in the muni mobile app AND on the web (mvp-video-games).
1741
- // Landscape games run on the web only. Hosts read this to frame the game.
1844
+ // Both orientations run in the muni mobile app (it rotates to match) and on
1845
+ // the web (mvp-video-games). Hosts read this to frame the game.
1742
1846
  orientation,
1743
1847
  aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
1744
1848
  // Declared up front so `myvillage deploy` ships them with the submission.
@@ -1886,7 +1990,12 @@ consistent experience:
1886
1990
  - **Done / finished** — call \`MyVillageGame.complete({ score })\` when the game is
1887
1991
  over. The host records the session and awards the player this game's MVT reward
1888
1992
  (set by an admin when the game is published). The scaffold already calls this
1889
- from \`Game.endGame()\` in \`src/main.js\` — wire that to your real end condition.
1993
+ from \`Game.endGame()\` in \`src/main.js\` — wire that to **every** way the game
1994
+ can end (win, lose, out of time).
1995
+ - **Plays count automatically** — \`src/myvillage-game.js\` measures how long the
1996
+ player is actively playing and the platform counts a play after 30 seconds of
1997
+ it. You don't need to do anything for that. To pass along a running score, call
1998
+ \`MyVillageGame.progress({ score })\` (optional; safe to call every frame).
1890
1999
  - **Exit any time** — the host (the muni mobile app or the web player) always
1891
2000
  shows an Exit control, so a player can leave at any moment. You don't build an
1892
2001
  exit button. You *may* trigger \`MyVillageGame.exit()\` yourself (e.g. a "Quit"