@myvillage/cli 2.16.1 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.16.1",
3
+ "version": "2.17.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.
@@ -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,74 @@
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } 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. Does nothing for a game without our bridge.
32
+ */
33
+ export function refreshGameToolkit(projectDir) {
34
+ const bridge = join(projectDir, 'src/myvillage-game.js');
35
+ if (!existsSync(bridge) || !TOOLKIT_FILES[0].isOurs(readFileSync(bridge, 'utf8'))) return [];
36
+
37
+ const changed = [];
38
+ for (const file of TOOLKIT_FILES) {
39
+ const target = join(projectDir, file.target);
40
+ const latest = readFileSync(file.source, 'utf8');
41
+ if (existsSync(target)) {
42
+ const current = readFileSync(target, 'utf8');
43
+ if (current === latest || !file.isOurs(current)) continue;
44
+ } else if (!file.createIfMissing) {
45
+ continue;
46
+ }
47
+ writeFileSync(target, latest);
48
+ changed.push(file.target);
49
+ }
50
+ return changed;
51
+ }
52
+
53
+ /**
54
+ * Whether the game's own code (not the toolkit) ever calls complete(). A game
55
+ * that never does can't record a finished game or pay its reward.
56
+ */
57
+ export function gameCallsComplete(projectDir) {
58
+ const src = join(projectDir, 'src');
59
+ if (!existsSync(src)) return true; // not a toolkit game; nothing to judge
60
+ const toolkit = new Set(TOOLKIT_FILES.map((f) => join(projectDir, f.target)));
61
+ const stack = [src];
62
+ while (stack.length) {
63
+ const dir = stack.pop();
64
+ for (const name of readdirSync(dir)) {
65
+ const path = join(dir, name);
66
+ if (statSync(path).isDirectory()) {
67
+ stack.push(path);
68
+ } else if (['.js', '.mjs', '.ts', '.jsx', '.tsx'].includes(extname(name)) && !toolkit.has(path)) {
69
+ if (/MyVillageGame\s*\.\s*complete\s*\(/.test(readFileSync(path, 'utf8'))) return true;
70
+ }
71
+ }
72
+ }
73
+ return false;
74
+ }
@@ -1129,7 +1129,8 @@ function generate2DGameAgents(name, gameType, orientation) {
1129
1129
  'The game talks to its host through `src/myvillage-game.js`:',
1130
1130
  '',
1131
1131
  '- `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.',
1132
+ '- `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).',
1133
+ '- **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
1134
  '',
1134
1135
  'Do **not** add an Exit button — the host always provides its own.',
1135
1136
  '',
@@ -1224,7 +1225,8 @@ function generate3DGameAgents(name, type, orientation) {
1224
1225
  '## The MyVillage game contract — DO NOT BREAK',
1225
1226
  '',
1226
1227
  '- `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.',
1228
+ '- `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.',
1229
+ '- **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
1230
  '',
1229
1231
  '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
1232
  '',
@@ -1290,6 +1292,7 @@ function generateUnityAgents(name, className, orientation) {
1290
1292
  '',
1291
1293
  '- The host drives your mission and runs `OnBegin()`. In the editor, `MockMissionHost` stands in and logs host calls to the Console.',
1292
1294
  '- **`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.**',
1295
+ '- 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
1296
  '- `FailMission(reason)` — the lose path. `Host.LogEvent("...")` — telemetry.',
1294
1297
  '- **Do not build an Exit button** — the host always shows its own and calls `OnEnd()` for cleanup when the player exits.',
1295
1298
  '',
@@ -1886,7 +1889,12 @@ consistent experience:
1886
1889
  - **Done / finished** — call \`MyVillageGame.complete({ score })\` when the game is
1887
1890
  over. The host records the session and awards the player this game's MVT reward
1888
1891
  (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.
1892
+ from \`Game.endGame()\` in \`src/main.js\` — wire that to **every** way the game
1893
+ can end (win, lose, out of time).
1894
+ - **Plays count automatically** — \`src/myvillage-game.js\` measures how long the
1895
+ player is actively playing and the platform counts a play after 30 seconds of
1896
+ it. You don't need to do anything for that. To pass along a running score, call
1897
+ \`MyVillageGame.progress({ score })\` (optional; safe to call every frame).
1890
1898
  - **Exit any time** — the host (the muni mobile app or the web player) always
1891
1899
  shows an Exit control, so a player can leave at any moment. You don't build an
1892
1900
  exit button. You *may* trigger \`MyVillageGame.exit()\` yourself (e.g. a "Quit"