@mpgd/cli 0.20.1 → 0.22.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.
Files changed (32) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +92 -8
  3. package/dist/microsoft-store-package-generation-contract.d.ts +5 -0
  4. package/dist/microsoft-store-package-generation-input.js +55 -0
  5. package/dist/microsoft-store-starter.d.ts +7 -0
  6. package/dist/microsoft-store-starter.js +176 -4
  7. package/dist/microsoft-store-submission.d.ts +24 -3
  8. package/dist/microsoft-store-submission.js +131 -4
  9. package/dist/offline-playtest.d.ts +31 -0
  10. package/dist/offline-playtest.js +5718 -0
  11. package/dist/production-target-readiness.d.ts +4 -0
  12. package/dist/production-target-readiness.js +40 -12
  13. package/package.json +15 -13
  14. package/templates/phaser-game/README.md +62 -22
  15. package/templates/phaser-game/apps/target-ait/apps-in-toss.config.ts +12 -0
  16. package/templates/phaser-game/apps/target-ait/package.json +5 -8
  17. package/templates/phaser-game/apps/target-ait/vite.config.ts +6 -15
  18. package/templates/phaser-game/apps/target-cloudflare-pages/package.json +1 -1
  19. package/templates/phaser-game/apps/target-devvit/README.md +2 -1
  20. package/templates/phaser-game/apps/target-devvit/package.json +1 -1
  21. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +1 -0
  22. package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +11 -0
  23. package/templates/phaser-game/mpgd.targets.json +8 -1
  24. package/templates/phaser-game/package.json +2 -5
  25. package/templates/phaser-game/pnpm-workspace.yaml +3 -2
  26. package/templates/phaser-game/src/entry.ts +10 -4
  27. package/templates/phaser-game/src/gameEntry.ts +7 -1
  28. package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +37 -0
  29. package/templates/phaser-game/src/platform/runtimeDetector.ts +1 -0
  30. package/templates/phaser-game/src/runtime/renderEntryFailure.ts +24 -0
  31. package/templates/phaser-game/vite.runtime-target-config.ts +2 -0
  32. package/templates/phaser-game/vite.shared.ts +6 -2
@@ -41,6 +41,7 @@ export function runMicrosoftStoreSubmissionPreflight(input) {
41
41
  const artifactRoot = readCanonicalDirectoryInside(gameRoot, input.artifactRoot, 'Microsoft Store artifact root');
42
42
  const configFile = readCanonicalFileInside(gameRoot, input.configFile, 'Microsoft Store submission config');
43
43
  const config = parseMicrosoftStoreSubmissionConfig(readJson(configFile, 'submission config'));
44
+ const effectiveTarget = readMicrosoftStoreEffectiveTarget(artifactRoot, config.commerce);
44
45
  const manifestFile = readCanonicalFileInside(artifactRoot, path.join(artifactRoot, 'manifest.webmanifest'), 'Microsoft Store web app manifest');
45
46
  const manifestSnapshot = readJsonSnapshot(manifestFile, 'web app manifest', 1024 * 1024);
46
47
  const manifest = parseManifest(manifestSnapshot.value, artifactRoot);
@@ -48,6 +49,7 @@ export function runMicrosoftStoreSubmissionPreflight(input) {
48
49
  const protectedFiles = [
49
50
  { file: configFile, label: 'Microsoft Store submission config' },
50
51
  { file: manifestFile, label: 'Microsoft Store web app manifest' },
52
+ { file: effectiveTarget.file, label: 'Microsoft Store effective target config' },
51
53
  ...manifest.icons.map((icon) => ({
52
54
  file: icon.file,
53
55
  label: 'Microsoft Store web app manifest icon',
@@ -97,6 +99,10 @@ export function runMicrosoftStoreSubmissionPreflight(input) {
97
99
  height: icon.height,
98
100
  })),
99
101
  },
102
+ effectiveTarget: {
103
+ file: relativeOrAbsolute(gameRoot, effectiveTarget.file),
104
+ sha256: effectiveTarget.sha256,
105
+ },
100
106
  listing: {
101
107
  category: config.listing.category,
102
108
  supportUrl: config.listing.supportUrl,
@@ -117,6 +123,70 @@ export function runMicrosoftStoreSubmissionPreflight(input) {
117
123
  writeFileSync(markdownFile, renderMicrosoftStoreSubmissionMarkdown(evidence));
118
124
  return evidence;
119
125
  }
126
+ function readMicrosoftStoreEffectiveTarget(artifactRoot, commerce) {
127
+ const file = readCanonicalFileInside(artifactRoot, path.join(artifactRoot, 'mpgd-effective-target.json'), 'Microsoft Store effective target config');
128
+ const snapshot = readJsonSnapshot(file, 'Microsoft Store effective target config', 4 * 1024 * 1024);
129
+ assertMicrosoftStoreEffectiveTarget(snapshot.value, commerce);
130
+ return { file, sha256: hashBytes(snapshot.bytes) };
131
+ }
132
+ export function assertMicrosoftStoreEffectiveTarget(input, commerce) {
133
+ const root = requireRecord(input, 'Microsoft Store effective target config');
134
+ if (root.target !== 'microsoft-store' || root.runtime !== 'microsoft-store-pwa') {
135
+ throw new Error('Microsoft Store effective target config must target the microsoft-store-pwa runtime.');
136
+ }
137
+ const monetization = requireRecord(root.monetization, 'Microsoft Store effective target config monetization');
138
+ const rawProducts = requireArray(monetization.products, 'Microsoft Store effective target config products');
139
+ if (commerce.mode === 'disabled') {
140
+ if (monetization.iap !== false) {
141
+ throw new Error('Microsoft Store effective target config must disable IAP when commerce mode is disabled.');
142
+ }
143
+ for (const [index, rawProduct] of rawProducts.entries()) {
144
+ const product = requireRecord(rawProduct, `Microsoft Store effective target config products[${String(index)}]`);
145
+ if (product.enabled !== false) {
146
+ throw new Error(`Microsoft Store effective target config products[${String(index)}] must be disabled when commerce mode is disabled.`);
147
+ }
148
+ }
149
+ return;
150
+ }
151
+ if (monetization.iap !== true) {
152
+ throw new Error('Microsoft Store effective target config must enable IAP.');
153
+ }
154
+ const seenProductIds = new Set();
155
+ const seenPlatformProductIds = new Set();
156
+ const products = rawProducts.flatMap((rawProduct, index) => {
157
+ const product = requireRecord(rawProduct, `Microsoft Store effective target config products[${String(index)}]`);
158
+ if (product.enabled !== true) {
159
+ return [];
160
+ }
161
+ if (product.type !== 'consumable') {
162
+ throw new Error('Microsoft Store effective target config can only enable consumables.');
163
+ }
164
+ const id = requireProductionString(product.id, `Microsoft Store effective target config products[${String(index)}].id`);
165
+ const platformProductId = requireProductionString(product.platformProductId, `Microsoft Store effective target config products[${String(index)}].platformProductId`);
166
+ if (seenProductIds.has(id)) {
167
+ throw new Error(`Microsoft Store effective target config duplicates product ${id}.`);
168
+ }
169
+ if (seenPlatformProductIds.has(platformProductId)) {
170
+ throw new Error(`Microsoft Store effective target config duplicates platformProductId ${platformProductId}.`);
171
+ }
172
+ seenProductIds.add(id);
173
+ seenPlatformProductIds.add(platformProductId);
174
+ return [{ id, platformProductId }];
175
+ });
176
+ if (products.length !== commerce.products.length) {
177
+ throw new Error('commerce.products must exactly match enabled Microsoft Store consumables in the built artifact.');
178
+ }
179
+ const productsById = new Map(products.map((product) => [product.id, product]));
180
+ for (const product of commerce.products) {
181
+ const effectiveProduct = productsById.get(product.logicalProductId);
182
+ if (effectiveProduct === undefined) {
183
+ throw new Error(`commerce product ${product.logicalProductId} is not present in the built artifact effective target.`);
184
+ }
185
+ if (effectiveProduct.platformProductId !== product.inAppOfferToken) {
186
+ throw new Error(`commerce product ${product.logicalProductId} InAppOfferToken ${product.inAppOfferToken} does not match built artifact platformProductId ${effectiveProduct.platformProductId}.`);
187
+ }
188
+ }
189
+ }
120
190
  export function parseMicrosoftStoreSubmissionConfig(input) {
121
191
  const root = requireRecord(input, 'Microsoft Store submission config');
122
192
  if (root.schemaVersion !== microsoftStoreSubmissionSchemaVersion) {
@@ -141,9 +211,7 @@ export function parseMicrosoftStoreSubmissionConfig(input) {
141
211
  if (ageRating.questionnaireCompleted !== true) {
142
212
  throw new Error('ageRating.questionnaireCompleted must be true before submission.');
143
213
  }
144
- if (commerce.mode !== 'disabled') {
145
- throw new Error('commerce.mode must stay disabled until Microsoft Store commerce is backed by server-side ledger verification.');
146
- }
214
+ const parsedCommerce = parseMicrosoftStoreSubmissionCommerce(commerce);
147
215
  const iarcId = optionalProductionString(ageRating.iarcId, 'ageRating.iarcId');
148
216
  return {
149
217
  schemaVersion: microsoftStoreSubmissionSchemaVersion,
@@ -166,9 +234,56 @@ export function parseMicrosoftStoreSubmissionConfig(input) {
166
234
  questionnaireCompleted: true,
167
235
  ...(iarcId === undefined ? {} : { iarcId }),
168
236
  },
169
- commerce: { mode: 'disabled' },
237
+ commerce: parsedCommerce,
170
238
  };
171
239
  }
240
+ export function parseMicrosoftStoreSubmissionCommerce(input) {
241
+ if (input.mode === 'disabled') {
242
+ return { mode: 'disabled' };
243
+ }
244
+ if (input.mode !== 'microsoft-store') {
245
+ throw new Error('commerce.mode must be disabled or microsoft-store.');
246
+ }
247
+ if (input.productType !== 'developer-managed-consumable') {
248
+ throw new Error('commerce.productType must be developer-managed-consumable for Digital Goods purchases.');
249
+ }
250
+ if (input.fulfillment !== 'authoritative-server') {
251
+ throw new Error('commerce.fulfillment must be authoritative-server for server-side ledger verification.');
252
+ }
253
+ if (input.authoritativeGameServices !== true) {
254
+ throw new Error('commerce.authoritativeGameServices must be true.');
255
+ }
256
+ const rawProducts = requireArray(input.products, 'commerce.products');
257
+ if (rawProducts.length === 0) {
258
+ throw new Error('commerce.products must contain at least one product.');
259
+ }
260
+ const logicalProductIds = new Set();
261
+ const inAppOfferTokens = new Set();
262
+ const storeIds = new Set();
263
+ const products = rawProducts.map((rawProduct, index) => {
264
+ const product = requireRecord(rawProduct, `commerce.products[${String(index)}]`);
265
+ const logicalProductId = requireProductionString(product.logicalProductId, `commerce.products[${String(index)}].logicalProductId`);
266
+ const inAppOfferToken = requireProductionString(product.inAppOfferToken, `commerce.products[${String(index)}].inAppOfferToken`);
267
+ const storeId = requireProductionString(product.storeId, `commerce.products[${String(index)}].storeId`);
268
+ assertUniqueCommerceIdentifier(logicalProductIds, logicalProductId, 'logicalProductId');
269
+ assertUniqueCommerceIdentifier(inAppOfferTokens, inAppOfferToken, 'inAppOfferToken');
270
+ assertUniqueCommerceIdentifier(storeIds, storeId, 'storeId');
271
+ return { logicalProductId, inAppOfferToken, storeId };
272
+ });
273
+ return {
274
+ mode: 'microsoft-store',
275
+ productType: 'developer-managed-consumable',
276
+ fulfillment: 'authoritative-server',
277
+ authoritativeGameServices: true,
278
+ products,
279
+ };
280
+ }
281
+ function assertUniqueCommerceIdentifier(seen, value, field) {
282
+ if (seen.has(value)) {
283
+ throw new Error(`commerce.products must use unique ${field} values; duplicate value "${value}" found.`);
284
+ }
285
+ seen.add(value);
286
+ }
172
287
  export function renderMicrosoftStoreSubmissionMarkdown(evidence) {
173
288
  const lines = [
174
289
  '# Microsoft Store Submission Preflight',
@@ -178,11 +293,22 @@ export function renderMicrosoftStoreSubmissionMarkdown(evidence) {
178
293
  `- Publisher ID: ${escapeMarkdownInline(evidence.productIdentity.publisherId)}`,
179
294
  `- Reserved name: ${escapeMarkdownInline(evidence.productIdentity.reservedName)}`,
180
295
  `- Manifest: ${escapeMarkdownInline(evidence.manifest.file)} (${evidence.manifest.sha256})`,
296
+ `- Effective target: ${escapeMarkdownInline(evidence.effectiveTarget.file)} (${evidence.effectiveTarget.sha256})`,
181
297
  `- Manifest icons: ${evidence.manifest.iconCount}`,
182
298
  `- Commerce: ${evidence.commerce.mode}`,
183
299
  `- Personal data accessed or transmitted: ${String(evidence.listing.personalData.accessedOrTransmitted)}`,
184
300
  `- Privacy policy: ${escapeMarkdownInline(evidence.listing.personalData.privacyPolicyUrl ?? 'Not required')}`,
185
301
  '',
302
+ ...(evidence.commerce.mode === 'microsoft-store'
303
+ ? [
304
+ '## Commerce Products',
305
+ '',
306
+ '| Logical product | InAppOfferToken | Store ID |',
307
+ '| --- | --- | --- |',
308
+ ...evidence.commerce.products.map((product) => (`| ${escapeMarkdownTable(product.logicalProductId)} | ${escapeMarkdownTable(product.inAppOfferToken)} | ${escapeMarkdownTable(product.storeId)} |`)),
309
+ '',
310
+ ]
311
+ : []),
186
312
  '## Store Listings',
187
313
  '',
188
314
  '| Locale | Screenshots | Description |',
@@ -495,6 +621,7 @@ function requireProductionString(input, label) {
495
621
  const exactTemplateToken = /^(?:TODO|FIXME|PLACEHOLDER|DUMMY|(?:CHANGE|REPLACE)[-_ ]?ME|LOREM(?:_IPSUM)?)$/iu;
496
622
  const templateIdentityField = label.startsWith('productIdentity.')
497
623
  || label.startsWith('web app manifest')
624
+ || label.startsWith('commerce.products[')
498
625
  || label === 'ageRating.iarcId';
499
626
  if (exactTemplateToken.test(value)
500
627
  || (templateIdentityField
@@ -0,0 +1,31 @@
1
+ export declare const defaultOfflinePlaytestArtifactDir = "artifacts/web-preview";
2
+ export declare const defaultOfflinePlaytestOutputDir = "artifacts/offline-playtest";
3
+ export declare const defaultOfflinePlaytestMaximumBytes: number;
4
+ export declare const offlinePlaytestSchemaVersion = "1";
5
+ export interface RunOfflinePlaytestPackagingInput {
6
+ readonly gameRoot: string;
7
+ readonly artifactDir?: string;
8
+ readonly outputDir?: string;
9
+ readonly maximumBytes?: number;
10
+ }
11
+ export interface OfflinePlaytestEvidence {
12
+ readonly schemaVersion: typeof offlinePlaytestSchemaVersion;
13
+ readonly purpose: 'test-play-only';
14
+ readonly releaseTarget: false;
15
+ readonly sourceTarget: 'web-preview';
16
+ readonly sourceRuntime: 'web-preview';
17
+ readonly networkPolicy: 'deny-network';
18
+ readonly entryFile: 'index.html';
19
+ readonly bytes: number;
20
+ readonly sha256: string;
21
+ readonly inlinedAssetCount: number;
22
+ readonly limitations: readonly string[];
23
+ }
24
+ export interface RunOfflinePlaytestPackagingResult {
25
+ readonly outputDir: string;
26
+ readonly entryFile: string;
27
+ readonly readmeFile: string;
28
+ readonly evidenceFile: string;
29
+ readonly evidence: OfflinePlaytestEvidence;
30
+ }
31
+ export declare function runOfflinePlaytestPackaging(input: RunOfflinePlaytestPackagingInput): Promise<RunOfflinePlaytestPackagingResult>;