@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,4 +1,4 @@
|
|
|
1
|
-
// MyVillage Game Bridge — the "mvgame" protocol (
|
|
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 =
|
|
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
|
-
|
|
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.
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/templates.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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"
|