@mpgd/cli 0.10.1 → 0.12.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/dist/browser-gameplay-e2e.d.ts +40 -0
- package/dist/browser-gameplay-e2e.js +54 -0
- package/dist/evidence-io.d.ts +13 -0
- package/dist/evidence-io.js +45 -0
- package/dist/game-acceptance.js +8 -42
- package/dist/gameplay-e2e.js +9 -22
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -2
- package/dist/microsoft-store-pwa-e2e.d.ts +68 -0
- package/dist/microsoft-store-pwa-e2e.js +257 -0
- package/package.json +14 -13
- package/templates/phaser-game/README.md +24 -5
- package/templates/phaser-game/agent/acceptance.md +14 -0
- package/templates/phaser-game/agent/brief.md +1 -1
- package/templates/phaser-game/agent/game-manifest.json +1 -1
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +71 -14
- package/templates/phaser-game/mpgd.targets.json +7 -0
- package/templates/phaser-game/package.json +3 -0
- package/templates/phaser-game/src/platform/buildGateways/verse8.ts +25 -0
- package/templates/phaser-game/src/platform/runtimeDetector.ts +1 -0
- package/templates/phaser-game/vite.shared.ts +2 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { GameplayE2EDriver, GameplayE2EObservation, GameplayE2EState } from './gameplay-e2e.js';
|
|
2
|
+
export interface BrowserGameplayE2EViewport {
|
|
3
|
+
readonly width: number;
|
|
4
|
+
readonly height: number;
|
|
5
|
+
}
|
|
6
|
+
export interface BrowserGameplayE2EScreenshotOptions {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly type: 'png';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The minimal Playwright-compatible page surface used by the browser driver.
|
|
12
|
+
* Consumers keep Playwright and browser credentials in their own test harness.
|
|
13
|
+
*/
|
|
14
|
+
export interface BrowserGameplayE2EPage {
|
|
15
|
+
viewportSize(): BrowserGameplayE2EViewport | null;
|
|
16
|
+
readonly mouse: {
|
|
17
|
+
click(x: number, y: number): Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
readonly keyboard: {
|
|
20
|
+
press(key: string): Promise<unknown>;
|
|
21
|
+
};
|
|
22
|
+
waitForTimeout(durationMs: number): Promise<unknown>;
|
|
23
|
+
screenshot(options: BrowserGameplayE2EScreenshotOptions): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface BrowserGameplayE2EInspectInput<TPage extends BrowserGameplayE2EPage = BrowserGameplayE2EPage> {
|
|
26
|
+
readonly page: TPage;
|
|
27
|
+
readonly state: GameplayE2EState;
|
|
28
|
+
readonly phase: 'after' | 'before' | 'resumed';
|
|
29
|
+
}
|
|
30
|
+
export interface CreateBrowserGameplayE2EDriverInput<TPage extends BrowserGameplayE2EPage = BrowserGameplayE2EPage> {
|
|
31
|
+
readonly page: TPage;
|
|
32
|
+
readonly pause: (page: TPage) => Promise<void>;
|
|
33
|
+
readonly resume: (page: TPage) => Promise<void>;
|
|
34
|
+
readonly inspect: (input: BrowserGameplayE2EInspectInput<TPage>) => Promise<GameplayE2EObservation>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates a browser driver for generic input and screenshot operations while
|
|
38
|
+
* leaving lifecycle orchestration and game-state inspection with the game.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createBrowserGameplayE2EDriver<TPage extends BrowserGameplayE2EPage>(input: CreateBrowserGameplayE2EDriverInput<TPage>): GameplayE2EDriver;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a browser driver for generic input and screenshot operations while
|
|
3
|
+
* leaving lifecycle orchestration and game-state inspection with the game.
|
|
4
|
+
*/
|
|
5
|
+
export function createBrowserGameplayE2EDriver(input) {
|
|
6
|
+
return {
|
|
7
|
+
perform: async (action) => {
|
|
8
|
+
switch (action.type) {
|
|
9
|
+
case 'tap': {
|
|
10
|
+
const viewport = readBrowserGameplayE2EViewport(input.page);
|
|
11
|
+
const x = resolveBrowserGameplayE2ETapCoordinate(action.x, viewport.width, 'x');
|
|
12
|
+
const y = resolveBrowserGameplayE2ETapCoordinate(action.y, viewport.height, 'y');
|
|
13
|
+
await input.page.mouse.click(x, y);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
case 'key':
|
|
17
|
+
await input.page.keyboard.press(action.key);
|
|
18
|
+
return;
|
|
19
|
+
case 'wait':
|
|
20
|
+
await input.page.waitForTimeout(action.durationMs);
|
|
21
|
+
return;
|
|
22
|
+
default: {
|
|
23
|
+
const unsupportedAction = action;
|
|
24
|
+
throw new Error(`Unsupported browser Gameplay E2E action: ${String(unsupportedAction)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
pause: () => input.pause(input.page),
|
|
29
|
+
resume: () => input.resume(input.page),
|
|
30
|
+
inspect: ({ state, phase }) => input.inspect({ page: input.page, state, phase }),
|
|
31
|
+
captureScreenshot: async ({ file }) => {
|
|
32
|
+
await input.page.screenshot({ path: file, type: 'png' });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readBrowserGameplayE2EViewport(page) {
|
|
37
|
+
const viewport = page.viewportSize();
|
|
38
|
+
if (viewport === null) {
|
|
39
|
+
throw new Error('Browser Gameplay E2E taps require a configured page viewport.');
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isSafeInteger(viewport.width)
|
|
42
|
+
|| viewport.width <= 0
|
|
43
|
+
|| !Number.isSafeInteger(viewport.height)
|
|
44
|
+
|| viewport.height <= 0) {
|
|
45
|
+
throw new Error('Browser Gameplay E2E viewport dimensions must be positive safe integers.');
|
|
46
|
+
}
|
|
47
|
+
return viewport;
|
|
48
|
+
}
|
|
49
|
+
function resolveBrowserGameplayE2ETapCoordinate(coordinate, viewportSize, axis) {
|
|
50
|
+
if (!Number.isFinite(coordinate) || coordinate < 0 || coordinate > 1) {
|
|
51
|
+
throw new Error(`Browser Gameplay E2E ${axis} tap coordinates must be between 0 and 1.`);
|
|
52
|
+
}
|
|
53
|
+
return Math.min(viewportSize - 1, Math.floor(coordinate * viewportSize));
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface EvidenceReportFilesInput {
|
|
2
|
+
readonly jsonFile: string;
|
|
3
|
+
readonly markdownFile: string;
|
|
4
|
+
readonly report: unknown;
|
|
5
|
+
readonly markdown: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeEvidenceReportFiles(input: EvidenceReportFilesInput): void;
|
|
8
|
+
export declare function readBoundedUtf8File(file: string, maximumBytes: number): string | null;
|
|
9
|
+
export declare function relativeOrAbsolute(root: string, file: string): string;
|
|
10
|
+
export declare function escapeMarkdownTable(value: string): string;
|
|
11
|
+
export declare function escapeMarkdownInline(value: string): string;
|
|
12
|
+
export declare function formatDuration(durationMs: number): string;
|
|
13
|
+
export declare function formatError(error: unknown): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function writeEvidenceReportFiles(input) {
|
|
4
|
+
writeFileSync(input.jsonFile, `${JSON.stringify(input.report, null, 2)}\n`);
|
|
5
|
+
writeFileSync(input.markdownFile, input.markdown);
|
|
6
|
+
}
|
|
7
|
+
export function readBoundedUtf8File(file, maximumBytes) {
|
|
8
|
+
const buffer = Buffer.allocUnsafe(maximumBytes + 1);
|
|
9
|
+
const fileDescriptor = openSync(file, 'r');
|
|
10
|
+
let offset = 0;
|
|
11
|
+
try {
|
|
12
|
+
while (offset < buffer.length) {
|
|
13
|
+
const bytesRead = readSync(fileDescriptor, buffer, offset, buffer.length - offset, null);
|
|
14
|
+
if (bytesRead === 0) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
offset += bytesRead;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
closeSync(fileDescriptor);
|
|
22
|
+
}
|
|
23
|
+
return offset > maximumBytes ? null : buffer.subarray(0, offset).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
export function relativeOrAbsolute(root, file) {
|
|
26
|
+
const relative = path.relative(root, file);
|
|
27
|
+
return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
|
|
28
|
+
}
|
|
29
|
+
export function escapeMarkdownTable(value) {
|
|
30
|
+
return escapeMarkdownInline(value).replaceAll('|', '\\|');
|
|
31
|
+
}
|
|
32
|
+
export function escapeMarkdownInline(value) {
|
|
33
|
+
return value
|
|
34
|
+
.replaceAll('&', '&')
|
|
35
|
+
.replaceAll('<', '<')
|
|
36
|
+
.replaceAll('>', '>')
|
|
37
|
+
.replace(/([\\`*_\[\]])/gu, '\\$1')
|
|
38
|
+
.replaceAll('\n', ' ');
|
|
39
|
+
}
|
|
40
|
+
export function formatDuration(durationMs) {
|
|
41
|
+
return durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
42
|
+
}
|
|
43
|
+
export function formatError(error) {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
package/dist/game-acceptance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError, readBoundedUtf8File, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
4
5
|
import { collectGameplayE2EPathEvidence, maximumGameplayE2EStates, readGameplayE2EPlan, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
|
|
5
6
|
export const defaultGameAcceptanceCommandTimeoutMs = 30 * 60 * 1000;
|
|
6
7
|
const defaultGameAcceptanceReleaseManifestFile = 'artifacts/release-manifest.json';
|
|
@@ -120,8 +121,12 @@ export function runGameAcceptance(input) {
|
|
|
120
121
|
const jsonFile = path.join(reportDir, 'acceptance-report.json');
|
|
121
122
|
const markdownFile = path.join(reportDir, 'acceptance-report.md');
|
|
122
123
|
mkdirSync(reportDir, { recursive: true });
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
writeEvidenceReportFiles({
|
|
125
|
+
jsonFile,
|
|
126
|
+
markdownFile,
|
|
127
|
+
report,
|
|
128
|
+
markdown: renderGameAcceptanceMarkdown(report),
|
|
129
|
+
});
|
|
125
130
|
return { report, jsonFile, markdownFile };
|
|
126
131
|
}
|
|
127
132
|
export function renderGameAcceptanceMarkdown(report) {
|
|
@@ -505,28 +510,6 @@ function validateCurrentPathEvidence(evidence, gameRoot, label) {
|
|
|
505
510
|
? null
|
|
506
511
|
: `Gameplay E2E ${label} hash does not match its current contents.`;
|
|
507
512
|
}
|
|
508
|
-
function relativeOrAbsolute(root, file) {
|
|
509
|
-
const relative = path.relative(root, file);
|
|
510
|
-
return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
|
|
511
|
-
}
|
|
512
|
-
function readBoundedUtf8File(file, maximumBytes) {
|
|
513
|
-
const buffer = Buffer.allocUnsafe(maximumBytes + 1);
|
|
514
|
-
const fileDescriptor = openSync(file, 'r');
|
|
515
|
-
let offset = 0;
|
|
516
|
-
try {
|
|
517
|
-
while (offset < buffer.length) {
|
|
518
|
-
const bytesRead = readSync(fileDescriptor, buffer, offset, buffer.length - offset, null);
|
|
519
|
-
if (bytesRead === 0) {
|
|
520
|
-
break;
|
|
521
|
-
}
|
|
522
|
-
offset += bytesRead;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
finally {
|
|
526
|
-
closeSync(fileDescriptor);
|
|
527
|
-
}
|
|
528
|
-
return offset > maximumBytes ? null : buffer.subarray(0, offset).toString('utf8');
|
|
529
|
-
}
|
|
530
513
|
function resolveRunnableStep(step) {
|
|
531
514
|
if (step.command === undefined || step.command.length === 0) {
|
|
532
515
|
return { ok: false, detail: `Acceptance step ${step.id} is missing command.` };
|
|
@@ -536,23 +519,6 @@ function resolveRunnableStep(step) {
|
|
|
536
519
|
}
|
|
537
520
|
return { ok: true, command: step.command, cwd: step.cwd };
|
|
538
521
|
}
|
|
539
|
-
function escapeMarkdownTable(value) {
|
|
540
|
-
return escapeMarkdownInline(value).replaceAll('|', '\\|');
|
|
541
|
-
}
|
|
542
|
-
function escapeMarkdownInline(value) {
|
|
543
|
-
return value
|
|
544
|
-
.replaceAll('&', '&')
|
|
545
|
-
.replaceAll('<', '<')
|
|
546
|
-
.replaceAll('>', '>')
|
|
547
|
-
.replace(/([\\`*_[\]])/gu, '\\$1')
|
|
548
|
-
.replaceAll('\n', ' ');
|
|
549
|
-
}
|
|
550
|
-
function formatDuration(durationMs) {
|
|
551
|
-
return durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
552
|
-
}
|
|
553
|
-
function formatError(error) {
|
|
554
|
-
return error instanceof Error ? error.message : String(error);
|
|
555
|
-
}
|
|
556
522
|
function isTimeoutError(error) {
|
|
557
523
|
return 'code' in error && error.code === 'ETIMEDOUT';
|
|
558
524
|
}
|
package/dist/gameplay-e2e.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, readlinkSync, readSync, rmSync,
|
|
2
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, readlinkSync, readSync, rmSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError as formatEvidenceError, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
4
5
|
export const defaultGameplayE2EReportFile = 'artifacts/gameplay-e2e/gameplay-e2e-report.json';
|
|
5
6
|
const gameplayStateIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
6
7
|
const gameplayKeyControlCharacterPattern = /[\u0000-\u001f\u007f]/u;
|
|
@@ -140,8 +141,12 @@ export async function runGameplayE2E(input) {
|
|
|
140
141
|
// Driver callbacks are untrusted and may have changed output paths while the run was active.
|
|
141
142
|
const verifiedJsonFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, jsonFile, 'Gameplay E2E report');
|
|
142
143
|
const verifiedMarkdownFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, markdownFile, 'Gameplay E2E Markdown report');
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
writeEvidenceReportFiles({
|
|
145
|
+
jsonFile: verifiedJsonFile,
|
|
146
|
+
markdownFile: verifiedMarkdownFile,
|
|
147
|
+
report,
|
|
148
|
+
markdown: renderGameplayE2EMarkdown(report),
|
|
149
|
+
});
|
|
145
150
|
return { report, jsonFile: verifiedJsonFile, markdownFile: verifiedMarkdownFile };
|
|
146
151
|
}
|
|
147
152
|
export function renderGameplayE2EMarkdown(report) {
|
|
@@ -647,10 +652,6 @@ function readBoundedInteger(value, source, minimum, maximum) {
|
|
|
647
652
|
}
|
|
648
653
|
return result;
|
|
649
654
|
}
|
|
650
|
-
function relativeOrAbsolute(root, file) {
|
|
651
|
-
const relative = path.relative(root, file);
|
|
652
|
-
return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
|
|
653
|
-
}
|
|
654
655
|
function replaceFileExtension(file, extension) {
|
|
655
656
|
const currentExtension = path.extname(file);
|
|
656
657
|
return currentExtension.length === 0
|
|
@@ -662,23 +663,9 @@ function assertGameplayE2EJsonReportFile(file) {
|
|
|
662
663
|
throw new Error('Gameplay E2E JSON report file must not use a Markdown extension.');
|
|
663
664
|
}
|
|
664
665
|
}
|
|
665
|
-
function escapeMarkdownTable(value) {
|
|
666
|
-
return escapeMarkdownInline(value).replaceAll('|', '\\|');
|
|
667
|
-
}
|
|
668
|
-
function escapeMarkdownInline(value) {
|
|
669
|
-
return value
|
|
670
|
-
.replaceAll('&', '&')
|
|
671
|
-
.replaceAll('<', '<')
|
|
672
|
-
.replaceAll('>', '>')
|
|
673
|
-
.replace(/([\\`*_[\]])/gu, '\\$1')
|
|
674
|
-
.replaceAll('\n', ' ');
|
|
675
|
-
}
|
|
676
|
-
function formatDuration(durationMs) {
|
|
677
|
-
return durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
678
|
-
}
|
|
679
666
|
function formatError(error) {
|
|
680
667
|
if (error instanceof GameplayE2EAggregatedError) {
|
|
681
668
|
return `${error.message} ${error.errors.map(formatError).join(' ')}`;
|
|
682
669
|
}
|
|
683
|
-
return
|
|
670
|
+
return formatEvidenceError(error);
|
|
684
671
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, type GameAcceptanceCommandResult, type GameAcceptanceCommandRunner, type GameAcceptanceReport, type GameAcceptanceStatus, type GameAcceptanceStep, type GameAcceptanceStepResult, type GameAcceptanceStepStatus, type RunGameAcceptanceInput, type RunGameAcceptanceResult, } from './game-acceptance.js';
|
|
2
|
+
export { createBrowserGameplayE2EDriver, type BrowserGameplayE2EInspectInput, type BrowserGameplayE2EPage, type BrowserGameplayE2EScreenshotOptions, type BrowserGameplayE2EViewport, type CreateBrowserGameplayE2EDriverInput, } from './browser-gameplay-e2e.js';
|
|
2
3
|
export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, type GameplayE2EAction, type GameplayE2EActionResult, type GameplayE2EDriver, type GameplayE2EHashLimits, type GameplayE2EInputAction, type GameplayE2EObservation, type GameplayE2EPauseResumeAction, type GameplayE2EPathEvidence, type GameplayE2EPlan, type GameplayE2EReport, type GameplayE2EResultStatus, type GameplayE2EState, type GameplayE2EStateResult, type GameplayE2EStatus, type RunGameplayE2EInput, type RunGameplayE2EResult, } from './gameplay-e2e.js';
|
|
4
|
+
export { assertMicrosoftStorePwaCacheTransition, assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, inspectMicrosoftStorePwaBrowserCacheTransition, microsoftStorePwaCacheSchema, readMicrosoftStorePwaBrowserReleaseEvidence, requestMicrosoftStorePwaBrowserUpdate, resolveMicrosoftStorePwaScopedCacheName, type AssertMicrosoftStorePwaCacheTransitionInput, type InspectMicrosoftStorePwaCacheTransitionInput, type MicrosoftStorePwaBrowserPage, type MicrosoftStorePwaCachedIndexObservation, type MicrosoftStorePwaCacheTransitionEvidence, type MicrosoftStorePwaCacheTransitionObservation, type MicrosoftStorePwaReleaseEvidence, } from './microsoft-store-pwa-e2e.js';
|
|
3
5
|
export { assertProductionTargetReadiness, type ProductionTargetReadinessInput, } from './production-target-readiness.js';
|
|
4
6
|
export declare function runMpgdCli(args: readonly string[]): Promise<void>;
|
|
5
7
|
export declare function runCreateGameCli(args: readonly string[]): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -9,20 +9,27 @@ import { cli } from 'gunshi';
|
|
|
9
9
|
import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
|
|
10
10
|
import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
|
|
11
11
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
|
|
12
|
+
export { createBrowserGameplayE2EDriver, } from './browser-gameplay-e2e.js';
|
|
12
13
|
export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, } from './gameplay-e2e.js';
|
|
14
|
+
export { assertMicrosoftStorePwaCacheTransition, assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, inspectMicrosoftStorePwaBrowserCacheTransition, microsoftStorePwaCacheSchema, readMicrosoftStorePwaBrowserReleaseEvidence, requestMicrosoftStorePwaBrowserUpdate, resolveMicrosoftStorePwaScopedCacheName, } from './microsoft-store-pwa-e2e.js';
|
|
13
15
|
export { assertProductionTargetReadiness, } from './production-target-readiness.js';
|
|
14
16
|
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
const packageRoot = resolvePackageRoot(sourceDir);
|
|
16
18
|
const detectedKitRoot = resolveDefaultKitRoot();
|
|
17
19
|
const gameTemplateDir = path.resolve(packageRoot, 'templates/phaser-game');
|
|
18
20
|
const cliVersion = readPackageVersion(packageRoot);
|
|
19
|
-
const recommendedMatrixTargets = 'web,microsoft-store,ait,reddit';
|
|
21
|
+
const recommendedMatrixTargets = 'web,microsoft-store,verse8,ait,reddit';
|
|
20
22
|
const defaultDependencyVersion = `^${cliVersion}`;
|
|
23
|
+
const standaloneTemplateDependencyVersionFallbacks = {
|
|
24
|
+
// Initial-published independently from the CLI release line; package metadata wins once concrete.
|
|
25
|
+
'@mpgd/adapter-verse8': '^0.1.0',
|
|
26
|
+
};
|
|
21
27
|
const mpgdTemplateDependencyPackages = [
|
|
22
28
|
{ name: '@mpgd/adapter-ait', packageDir: 'adapters/ait' },
|
|
23
29
|
{ name: '@mpgd/adapter-browser', packageDir: 'adapters/browser' },
|
|
24
30
|
{ name: '@mpgd/adapter-capacitor', packageDir: 'adapters/capacitor' },
|
|
25
31
|
{ name: '@mpgd/adapter-devvit', packageDir: 'adapters/devvit' },
|
|
32
|
+
{ name: '@mpgd/adapter-verse8', packageDir: 'adapters/verse8' },
|
|
26
33
|
{ name: '@mpgd/analytics', packageDir: 'packages/analytics' },
|
|
27
34
|
{ name: '@mpgd/bridge', packageDir: 'packages/bridge' },
|
|
28
35
|
{ name: '@mpgd/catalog', packageDir: 'packages/catalog' },
|
|
@@ -33,10 +40,17 @@ const mpgdTemplateDependencyPackages = [
|
|
|
33
40
|
{ name: '@mpgd/platform', packageDir: 'packages/platform' },
|
|
34
41
|
{ name: '@mpgd/target-config', packageDir: 'packages/target-config' },
|
|
35
42
|
];
|
|
36
|
-
const recommendedMatrixTargetOrder = [
|
|
43
|
+
const recommendedMatrixTargetOrder = [
|
|
44
|
+
'web-preview',
|
|
45
|
+
'microsoft-store',
|
|
46
|
+
'verse8',
|
|
47
|
+
'ait',
|
|
48
|
+
'reddit',
|
|
49
|
+
];
|
|
37
50
|
const allMatrixTargetOrder = [
|
|
38
51
|
'web-preview',
|
|
39
52
|
'microsoft-store',
|
|
53
|
+
'verse8',
|
|
40
54
|
'android',
|
|
41
55
|
'ios',
|
|
42
56
|
'ait',
|
|
@@ -48,6 +62,7 @@ const supportedBuildTargets = [
|
|
|
48
62
|
'web-preview',
|
|
49
63
|
'microsoft-store',
|
|
50
64
|
'msstore',
|
|
65
|
+
'verse8',
|
|
51
66
|
'android',
|
|
52
67
|
'ios',
|
|
53
68
|
'ait',
|
|
@@ -1254,6 +1269,7 @@ function resolveMpgdDependencyVersionReplacements(input) {
|
|
|
1254
1269
|
?? (input.workspace ? 'workspace:*' : undefined)
|
|
1255
1270
|
?? resolvePublishedTemplateDependencyVersion(dependency.name, packageJson)
|
|
1256
1271
|
?? resolveWorkspaceTemplateDependencyVersion(kitRoot, dependency.packageDir)
|
|
1272
|
+
?? standaloneTemplateDependencyVersionFallbacks[dependency.name]
|
|
1257
1273
|
?? defaultDependencyVersion;
|
|
1258
1274
|
return {
|
|
1259
1275
|
placeholder: mpgdDependencyVersionPlaceholder(dependency.name),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export declare const microsoftStorePwaCacheSchema: 'microsoft-store-offline-v1';
|
|
2
|
+
export interface MicrosoftStorePwaReleaseEvidence {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly cacheSchema: typeof microsoftStorePwaCacheSchema;
|
|
5
|
+
readonly pwaId: string;
|
|
6
|
+
readonly appVersion: string;
|
|
7
|
+
readonly buildId: string;
|
|
8
|
+
readonly sourceGitSha: string;
|
|
9
|
+
readonly kitGitSha: string;
|
|
10
|
+
readonly configTarget: 'microsoft-store';
|
|
11
|
+
readonly revision: string;
|
|
12
|
+
readonly cachePrefix: string;
|
|
13
|
+
readonly cacheNamePattern: string;
|
|
14
|
+
readonly precacheUrls: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The minimal Playwright-compatible page surface used by the PWA helpers.
|
|
18
|
+
* Consumers keep Playwright and browser credentials in their own harness.
|
|
19
|
+
*/
|
|
20
|
+
export interface MicrosoftStorePwaBrowserPage {
|
|
21
|
+
evaluate<TResult>(pageFunction: () => TResult | Promise<TResult>): Promise<TResult>;
|
|
22
|
+
evaluate<TResult, TArgument>(pageFunction: (argument: TArgument) => TResult | Promise<TResult>, argument: TArgument): Promise<TResult>;
|
|
23
|
+
}
|
|
24
|
+
export interface MicrosoftStorePwaCachedIndexObservation {
|
|
25
|
+
readonly referencesReleaseA: boolean;
|
|
26
|
+
readonly referencesReleaseB: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface MicrosoftStorePwaCacheTransitionObservation {
|
|
29
|
+
readonly registrationScope: string;
|
|
30
|
+
readonly scopedCacheNames: {
|
|
31
|
+
readonly a: string;
|
|
32
|
+
readonly b: string;
|
|
33
|
+
};
|
|
34
|
+
readonly cacheNames: readonly string[];
|
|
35
|
+
readonly cachedReleaseBIndex: MicrosoftStorePwaCachedIndexObservation;
|
|
36
|
+
}
|
|
37
|
+
export interface InspectMicrosoftStorePwaCacheTransitionInput<TPage extends MicrosoftStorePwaBrowserPage = MicrosoftStorePwaBrowserPage> {
|
|
38
|
+
readonly page: TPage;
|
|
39
|
+
readonly releaseA: MicrosoftStorePwaReleaseEvidence;
|
|
40
|
+
readonly releaseB: MicrosoftStorePwaReleaseEvidence;
|
|
41
|
+
readonly releaseAIndexMarker: string;
|
|
42
|
+
readonly releaseBIndexMarker: string;
|
|
43
|
+
readonly releaseBIndexRequestCount: number;
|
|
44
|
+
readonly preservedCacheNames?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
export interface AssertMicrosoftStorePwaCacheTransitionInput extends MicrosoftStorePwaCacheTransitionObservation {
|
|
47
|
+
readonly releaseA: MicrosoftStorePwaReleaseEvidence;
|
|
48
|
+
readonly releaseB: MicrosoftStorePwaReleaseEvidence;
|
|
49
|
+
readonly releaseBIndexRequestCount: number;
|
|
50
|
+
readonly preservedCacheNames?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
export interface MicrosoftStorePwaCacheTransitionEvidence extends MicrosoftStorePwaCacheTransitionObservation {
|
|
53
|
+
readonly checks: {
|
|
54
|
+
readonly releaseBActivated: true;
|
|
55
|
+
readonly releaseACacheRemoved: true;
|
|
56
|
+
readonly preservedCachesRetained: true;
|
|
57
|
+
readonly releaseBIndexCached: true;
|
|
58
|
+
readonly staleReleaseAIndexExcluded: true;
|
|
59
|
+
readonly releaseBInstallBypassedHttpCache: true;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export declare function readMicrosoftStorePwaBrowserReleaseEvidence<TPage extends MicrosoftStorePwaBrowserPage>(page: TPage, releaseUrl?: string): Promise<MicrosoftStorePwaReleaseEvidence>;
|
|
63
|
+
export declare function requestMicrosoftStorePwaBrowserUpdate<TPage extends MicrosoftStorePwaBrowserPage>(page: TPage): Promise<void>;
|
|
64
|
+
export declare function inspectMicrosoftStorePwaBrowserCacheTransition<TPage extends MicrosoftStorePwaBrowserPage>(input: InspectMicrosoftStorePwaCacheTransitionInput<TPage>): Promise<MicrosoftStorePwaCacheTransitionEvidence>;
|
|
65
|
+
export declare function assertMicrosoftStorePwaCacheTransition(input: AssertMicrosoftStorePwaCacheTransitionInput): MicrosoftStorePwaCacheTransitionEvidence;
|
|
66
|
+
export declare function resolveMicrosoftStorePwaScopedCacheName(evidenceInput: MicrosoftStorePwaReleaseEvidence, registrationScopeInput: string): string;
|
|
67
|
+
export declare function assertMicrosoftStorePwaReleaseEvidence(input: unknown): MicrosoftStorePwaReleaseEvidence;
|
|
68
|
+
export declare function assertMicrosoftStorePwaPrecacheUrl(input: unknown): string;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const microsoftStorePwaCacheSchema = 'microsoft-store-offline-v1';
|
|
3
|
+
export async function readMicrosoftStorePwaBrowserReleaseEvidence(page, releaseUrl) {
|
|
4
|
+
const explicitReleaseUrl = releaseUrl === undefined
|
|
5
|
+
? undefined
|
|
6
|
+
: requireNonEmptyString(releaseUrl, 'PWA release evidence URL');
|
|
7
|
+
const input = await page.evaluate(async (browserInput) => {
|
|
8
|
+
let requestedUrl = browserInput.releaseUrl;
|
|
9
|
+
if (requestedUrl === undefined) {
|
|
10
|
+
const browser = globalThis;
|
|
11
|
+
const registration = await browser.navigator.serviceWorker.getRegistration();
|
|
12
|
+
if (registration === undefined) {
|
|
13
|
+
throw new Error('Missing service worker registration before reading release evidence.');
|
|
14
|
+
}
|
|
15
|
+
requestedUrl = new URL('./pwa-release.json', registration.scope).href;
|
|
16
|
+
}
|
|
17
|
+
const response = await fetch(requestedUrl);
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`Failed to read PWA release evidence: ${String(response.status)}.`);
|
|
20
|
+
}
|
|
21
|
+
return response.json();
|
|
22
|
+
}, { releaseUrl: explicitReleaseUrl });
|
|
23
|
+
return assertMicrosoftStorePwaReleaseEvidence(input);
|
|
24
|
+
}
|
|
25
|
+
export async function requestMicrosoftStorePwaBrowserUpdate(page) {
|
|
26
|
+
await page.evaluate(async () => {
|
|
27
|
+
const browser = globalThis;
|
|
28
|
+
const registration = await browser.navigator.serviceWorker.getRegistration();
|
|
29
|
+
if (registration === undefined) {
|
|
30
|
+
throw new Error('Missing service worker registration before update.');
|
|
31
|
+
}
|
|
32
|
+
await registration.update();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export async function inspectMicrosoftStorePwaBrowserCacheTransition(input) {
|
|
36
|
+
const releaseA = assertMicrosoftStorePwaReleaseEvidence(input.releaseA);
|
|
37
|
+
const releaseB = assertMicrosoftStorePwaReleaseEvidence(input.releaseB);
|
|
38
|
+
const releaseAIndexMarker = requireNonEmptyString(input.releaseAIndexMarker, 'release A index marker');
|
|
39
|
+
const releaseBIndexMarker = requireNonEmptyString(input.releaseBIndexMarker, 'release B index marker');
|
|
40
|
+
const observation = await input.page.evaluate(async (browserInput) => {
|
|
41
|
+
const browser = globalThis;
|
|
42
|
+
const registration = await browser.navigator.serviceWorker.ready;
|
|
43
|
+
const encodedScope = encodeURIComponent(registration.scope);
|
|
44
|
+
const scopedCacheNames = {
|
|
45
|
+
a: browserInput.releaseACacheNamePattern.replace('{scope}', encodedScope),
|
|
46
|
+
b: browserInput.releaseBCacheNamePattern.replace('{scope}', encodedScope),
|
|
47
|
+
};
|
|
48
|
+
const cacheNames = await browser.caches.keys();
|
|
49
|
+
const releaseBCache = await browser.caches.open(scopedCacheNames.b);
|
|
50
|
+
const cachedIndexUrl = new URL('./index.html', registration.scope).href;
|
|
51
|
+
const cachedIndexResponse = await releaseBCache.match(cachedIndexUrl);
|
|
52
|
+
if (cachedIndexResponse === undefined) {
|
|
53
|
+
throw new Error('Release B cache is missing index.html.');
|
|
54
|
+
}
|
|
55
|
+
const cachedIndex = await cachedIndexResponse.text();
|
|
56
|
+
return {
|
|
57
|
+
registrationScope: registration.scope,
|
|
58
|
+
scopedCacheNames,
|
|
59
|
+
cacheNames,
|
|
60
|
+
cachedReleaseBIndex: {
|
|
61
|
+
referencesReleaseA: cachedIndex.includes(browserInput.releaseAIndexMarker),
|
|
62
|
+
referencesReleaseB: cachedIndex.includes(browserInput.releaseBIndexMarker),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}, {
|
|
66
|
+
releaseACacheNamePattern: releaseA.cacheNamePattern,
|
|
67
|
+
releaseBCacheNamePattern: releaseB.cacheNamePattern,
|
|
68
|
+
releaseAIndexMarker,
|
|
69
|
+
releaseBIndexMarker,
|
|
70
|
+
});
|
|
71
|
+
return assertMicrosoftStorePwaCacheTransition({
|
|
72
|
+
releaseA,
|
|
73
|
+
releaseB,
|
|
74
|
+
releaseBIndexRequestCount: input.releaseBIndexRequestCount,
|
|
75
|
+
...(input.preservedCacheNames === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { preservedCacheNames: input.preservedCacheNames }),
|
|
78
|
+
...observation,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function assertMicrosoftStorePwaCacheTransition(input) {
|
|
82
|
+
const releaseA = assertMicrosoftStorePwaReleaseEvidence(input.releaseA);
|
|
83
|
+
const releaseB = assertMicrosoftStorePwaReleaseEvidence(input.releaseB);
|
|
84
|
+
const expectedCacheNames = {
|
|
85
|
+
a: resolveMicrosoftStorePwaScopedCacheName(releaseA, input.registrationScope),
|
|
86
|
+
b: resolveMicrosoftStorePwaScopedCacheName(releaseB, input.registrationScope),
|
|
87
|
+
};
|
|
88
|
+
if (releaseA.pwaId !== releaseB.pwaId || releaseA.cachePrefix !== releaseB.cachePrefix) {
|
|
89
|
+
throw new Error('PWA cache transition releases must identify the same application.');
|
|
90
|
+
}
|
|
91
|
+
if (releaseA.buildId === releaseB.buildId || releaseA.revision === releaseB.revision) {
|
|
92
|
+
throw new Error('PWA cache transition releases must use distinct builds and revisions.');
|
|
93
|
+
}
|
|
94
|
+
if (input.scopedCacheNames.a !== expectedCacheNames.a
|
|
95
|
+
|| input.scopedCacheNames.b !== expectedCacheNames.b) {
|
|
96
|
+
throw new Error('Observed PWA cache names do not match the registration scope.');
|
|
97
|
+
}
|
|
98
|
+
if (!input.cacheNames.includes(expectedCacheNames.b)) {
|
|
99
|
+
throw new Error('Release B cache must exist after activation.');
|
|
100
|
+
}
|
|
101
|
+
if (input.cacheNames.includes(expectedCacheNames.a)) {
|
|
102
|
+
throw new Error('Release A cache must be deleted after release B activates.');
|
|
103
|
+
}
|
|
104
|
+
for (const cacheName of input.preservedCacheNames ?? []) {
|
|
105
|
+
if (!input.cacheNames.includes(requireNonEmptyString(cacheName, 'preserved cache name'))) {
|
|
106
|
+
throw new Error(`Activating release B removed unrelated cache ${cacheName}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!input.cachedReleaseBIndex.referencesReleaseB) {
|
|
110
|
+
throw new Error('Release B cache must contain release B index.html.');
|
|
111
|
+
}
|
|
112
|
+
if (input.cachedReleaseBIndex.referencesReleaseA) {
|
|
113
|
+
throw new Error('Release B cache must not contain release A index.html.');
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isSafeInteger(input.releaseBIndexRequestCount) || input.releaseBIndexRequestCount <= 0) {
|
|
116
|
+
throw new Error('Release B install must request index.html while bypassing the HTTP cache.');
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
registrationScope: requireRegistrationScope(input.registrationScope),
|
|
120
|
+
scopedCacheNames: expectedCacheNames,
|
|
121
|
+
cacheNames: [...input.cacheNames],
|
|
122
|
+
cachedReleaseBIndex: { ...input.cachedReleaseBIndex },
|
|
123
|
+
checks: {
|
|
124
|
+
releaseBActivated: true,
|
|
125
|
+
releaseACacheRemoved: true,
|
|
126
|
+
preservedCachesRetained: true,
|
|
127
|
+
releaseBIndexCached: true,
|
|
128
|
+
staleReleaseAIndexExcluded: true,
|
|
129
|
+
releaseBInstallBypassedHttpCache: true,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function resolveMicrosoftStorePwaScopedCacheName(evidenceInput, registrationScopeInput) {
|
|
134
|
+
const evidence = assertMicrosoftStorePwaReleaseEvidence(evidenceInput);
|
|
135
|
+
const registrationScope = requireRegistrationScope(registrationScopeInput);
|
|
136
|
+
return evidence.cacheNamePattern.replace('{scope}', encodeURIComponent(registrationScope));
|
|
137
|
+
}
|
|
138
|
+
export function assertMicrosoftStorePwaReleaseEvidence(input) {
|
|
139
|
+
assertRecord(input, 'PWA release evidence');
|
|
140
|
+
if (input.schemaVersion !== 1 || input.cacheSchema !== microsoftStorePwaCacheSchema) {
|
|
141
|
+
throw new Error('Unsupported Microsoft Store PWA release evidence schema.');
|
|
142
|
+
}
|
|
143
|
+
if (input.configTarget !== 'microsoft-store') {
|
|
144
|
+
throw new Error('PWA release evidence must target microsoft-store.');
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(input.precacheUrls)) {
|
|
147
|
+
throw new Error('PWA release evidence precacheUrls must be an array.');
|
|
148
|
+
}
|
|
149
|
+
const pwaId = requireGameSpecificPwaId(input.pwaId);
|
|
150
|
+
const revision = requireRevision(input.revision);
|
|
151
|
+
const expectedCachePrefix = `mpgd-pwa-${createHash('sha256')
|
|
152
|
+
.update(pwaId)
|
|
153
|
+
.digest('hex')
|
|
154
|
+
.slice(0, 12)}-`;
|
|
155
|
+
const cachePrefix = requireNonEmptyString(input.cachePrefix, 'PWA cache prefix');
|
|
156
|
+
const cacheNamePattern = requireNonEmptyString(input.cacheNamePattern, 'PWA cache name pattern');
|
|
157
|
+
const precacheUrls = input.precacheUrls.map(assertMicrosoftStorePwaPrecacheUrl);
|
|
158
|
+
if (cachePrefix !== expectedCachePrefix) {
|
|
159
|
+
throw new Error('PWA cache prefix is inconsistent with its application ID.');
|
|
160
|
+
}
|
|
161
|
+
if (cacheNamePattern !== `${cachePrefix}{scope}-${revision}`) {
|
|
162
|
+
throw new Error('PWA cache name pattern is inconsistent.');
|
|
163
|
+
}
|
|
164
|
+
if (precacheUrls.length !== new Set(precacheUrls).size
|
|
165
|
+
|| precacheUrls.some((url, index) => index > 0 && compareCodeUnits(precacheUrls[index - 1] ?? '', url) > 0)) {
|
|
166
|
+
throw new Error('PWA precache URLs must be unique and sorted.');
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
schemaVersion: 1,
|
|
170
|
+
cacheSchema: microsoftStorePwaCacheSchema,
|
|
171
|
+
pwaId,
|
|
172
|
+
appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
|
|
173
|
+
buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
|
|
174
|
+
sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
|
|
175
|
+
kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
|
|
176
|
+
configTarget: 'microsoft-store',
|
|
177
|
+
revision,
|
|
178
|
+
cachePrefix,
|
|
179
|
+
cacheNamePattern,
|
|
180
|
+
precacheUrls,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function assertMicrosoftStorePwaPrecacheUrl(input) {
|
|
184
|
+
const url = requireNonEmptyString(input, 'PWA precache URL');
|
|
185
|
+
if (!url.startsWith('./')
|
|
186
|
+
|| url.includes('\\')
|
|
187
|
+
|| url.includes('?')
|
|
188
|
+
|| url.includes('#')
|
|
189
|
+
|| hasDotSegment(url)) {
|
|
190
|
+
throw new Error(`Unsafe PWA precache URL: ${url}`);
|
|
191
|
+
}
|
|
192
|
+
return url;
|
|
193
|
+
}
|
|
194
|
+
function hasDotSegment(url) {
|
|
195
|
+
for (const segment of url.slice(2).split('/')) {
|
|
196
|
+
let decoded;
|
|
197
|
+
try {
|
|
198
|
+
decoded = decodeURIComponent(segment);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (decoded === '.' || decoded === '..') {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
function requireGameSpecificPwaId(input) {
|
|
210
|
+
const pwaId = requireNonEmptyString(input, 'PWA ID');
|
|
211
|
+
if (pwaId === '.' || pwaId === './' || pwaId === '/') {
|
|
212
|
+
throw new Error('Microsoft Store PWA manifest id must be game-specific.');
|
|
213
|
+
}
|
|
214
|
+
return pwaId;
|
|
215
|
+
}
|
|
216
|
+
function requireRegistrationScope(input) {
|
|
217
|
+
const scope = requireNonEmptyString(input, 'service worker registration scope');
|
|
218
|
+
let parsed;
|
|
219
|
+
try {
|
|
220
|
+
parsed = new URL(scope);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
throw new Error('Service worker registration scope must be an absolute URL.');
|
|
224
|
+
}
|
|
225
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
226
|
+
throw new Error('Service worker registration scope must use HTTP or HTTPS.');
|
|
227
|
+
}
|
|
228
|
+
return scope;
|
|
229
|
+
}
|
|
230
|
+
function requireRevision(input) {
|
|
231
|
+
const revision = requireNonEmptyString(input, 'PWA revision');
|
|
232
|
+
if (!/^[0-9a-f]{16}$/u.test(revision)) {
|
|
233
|
+
throw new Error('PWA revision must be a 16-character hexadecimal digest.');
|
|
234
|
+
}
|
|
235
|
+
return revision;
|
|
236
|
+
}
|
|
237
|
+
function requireGitSha(input, label) {
|
|
238
|
+
const value = requireNonEmptyString(input, label);
|
|
239
|
+
if (!/^[0-9a-f]{40}$/u.test(value)) {
|
|
240
|
+
throw new Error(`${label} must be a full 40-character lowercase Git SHA.`);
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
function requireNonEmptyString(input, label) {
|
|
245
|
+
if (typeof input !== 'string' || input.trim().length === 0) {
|
|
246
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
247
|
+
}
|
|
248
|
+
return input.trim();
|
|
249
|
+
}
|
|
250
|
+
function assertRecord(input, label) {
|
|
251
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
252
|
+
throw new Error(`${label} must be an object.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function compareCodeUnits(left, right) {
|
|
256
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -49,18 +49,19 @@
|
|
|
49
49
|
"@types/node": "^24.0.0",
|
|
50
50
|
"ttsc": "0.18.4",
|
|
51
51
|
"typescript": "7.0.2",
|
|
52
|
-
"@mpgd/adapter-
|
|
53
|
-
"@mpgd/adapter-
|
|
54
|
-
"@mpgd/adapter-
|
|
55
|
-
"@mpgd/adapter-
|
|
56
|
-
"@mpgd/
|
|
57
|
-
"@mpgd/bridge": "0.6.
|
|
58
|
-
"@mpgd/catalog": "0.
|
|
59
|
-
"@mpgd/
|
|
60
|
-
"@mpgd/
|
|
61
|
-
"@mpgd/i18n": "0.5.
|
|
62
|
-
"@mpgd/
|
|
63
|
-
"@mpgd/
|
|
52
|
+
"@mpgd/adapter-browser": "0.5.0",
|
|
53
|
+
"@mpgd/adapter-capacitor": "0.4.4",
|
|
54
|
+
"@mpgd/adapter-devvit": "0.8.3",
|
|
55
|
+
"@mpgd/adapter-verse8": "0.2.0",
|
|
56
|
+
"@mpgd/adapter-ait": "0.4.4",
|
|
57
|
+
"@mpgd/bridge": "0.6.1",
|
|
58
|
+
"@mpgd/catalog": "0.5.0",
|
|
59
|
+
"@mpgd/analytics": "0.3.7",
|
|
60
|
+
"@mpgd/game-services": "0.9.0",
|
|
61
|
+
"@mpgd/i18n": "0.5.3",
|
|
62
|
+
"@mpgd/target-config": "0.8.0",
|
|
63
|
+
"@mpgd/platform": "0.7.0",
|
|
64
|
+
"@mpgd/phaser-assets": "0.4.1"
|
|
64
65
|
},
|
|
65
66
|
"main": "./dist/index.js",
|
|
66
67
|
"types": "./dist/index.d.ts",
|
|
@@ -31,17 +31,17 @@ platform-specific artifacts. Keep an `mpgd-kit` checkout nearby and pass it with
|
|
|
31
31
|
pnpm exec mpgd target build-all \
|
|
32
32
|
--targets-file ./mpgd.targets.json \
|
|
33
33
|
--kit-path ../mpgd-kit \
|
|
34
|
-
--targets web,microsoft-store,ait,reddit \
|
|
34
|
+
--targets web,microsoft-store,verse8,ait,reddit \
|
|
35
35
|
--profile staging \
|
|
36
36
|
--ait-variant wrapper
|
|
37
37
|
pnpm exec mpgd target smoke-all \
|
|
38
38
|
--targets-file ./mpgd.targets.json \
|
|
39
39
|
--kit-path ../mpgd-kit \
|
|
40
|
-
--targets web,microsoft-store,ait,reddit
|
|
40
|
+
--targets web,microsoft-store,verse8,ait,reddit
|
|
41
41
|
pnpm exec mpgd target doctor \
|
|
42
42
|
--targets-file ./mpgd.targets.json \
|
|
43
43
|
--kit-path ../mpgd-kit \
|
|
44
|
-
--targets web,microsoft-store,ait,reddit
|
|
44
|
+
--targets web,microsoft-store,verse8,ait,reddit
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
When developing the kit itself, the same commands work through the repository
|
|
@@ -51,7 +51,7 @@ script:
|
|
|
51
51
|
pnpm --dir ../mpgd-kit mpgd target build-all \
|
|
52
52
|
--targets-file "$PWD/mpgd.targets.json" \
|
|
53
53
|
--kit-path ../mpgd-kit \
|
|
54
|
-
--targets web,microsoft-store,ait,reddit \
|
|
54
|
+
--targets web,microsoft-store,verse8,ait,reddit \
|
|
55
55
|
--profile staging \
|
|
56
56
|
--ait-variant wrapper
|
|
57
57
|
```
|
|
@@ -285,7 +285,26 @@ submission.
|
|
|
285
285
|
app; do not force activation with `skipWaiting()`. Keep the web manifest `id`
|
|
286
286
|
game-specific because the cache namespace uses it to isolate apps sharing an
|
|
287
287
|
origin.
|
|
288
|
-
-
|
|
288
|
+
- Verse8 is a game-owned iframe web target that writes to `artifacts/verse8`.
|
|
289
|
+
The adapter verifies the host-provided auth credential and distinguishes
|
|
290
|
+
Verse8-signed accounts from self-signed guest accounts. Ads are available
|
|
291
|
+
only when placement mappings are present. VXShop stays capability-disabled
|
|
292
|
+
until the gateway receives the literal `purchaseEventAuthority:
|
|
293
|
+
'agent8-server'`, catalog product mappings, and an authenticated Agent8
|
|
294
|
+
entitlement reader. The client purchase result is always `pending`; grants
|
|
295
|
+
belong only in the reserved Agent8 `$onItemPurchased` event. Do not use
|
|
296
|
+
`VXShop.onClose`, client metadata, or a client-created transaction ID as
|
|
297
|
+
purchase evidence. Keep `@agent8/gameserver-node` in the separate Agent8
|
|
298
|
+
server project and use `@mpgd/adapter-verse8/agent8` there for the locked,
|
|
299
|
+
consume-once catalog grant. Authenticated-encrypted Agent8 cloud state is
|
|
300
|
+
available only after the gateway receives a game-owned remote storage client;
|
|
301
|
+
the starter does not install the React browser SDK, configure that client, or
|
|
302
|
+
ship server persistence secrets. Agent8 verified
|
|
303
|
+
leaderboards stay server-only and require a game-specific completion
|
|
304
|
+
verifier, so the generic platform leaderboard capability remains disabled.
|
|
305
|
+
- Reddit Devvit is game-owned in `apps/target-devvit`. Its Redis-backed storage
|
|
306
|
+
bridge fails closed on identity, provider, serialization, and quota errors;
|
|
307
|
+
it never switches progress to browser `localStorage`.
|
|
289
308
|
- Apps in Toss currently uses the kit reference wrapper at
|
|
290
309
|
`${MPGD_KIT_PATH}/apps/target-ait` for smoke packaging. Before a real Toss
|
|
291
310
|
submission, create a game-owned wrapper/config so the app name, CLI state, and
|
|
@@ -36,5 +36,19 @@ For Microsoft Store changes, keep the first pass as a PWA/web target that uses
|
|
|
36
36
|
the browser adapter. Add a dedicated Store commerce adapter only when wiring
|
|
37
37
|
Digital Goods API and Payment Request through backend ledger verification.
|
|
38
38
|
|
|
39
|
+
## Verse8 Agent8 Structured Server
|
|
40
|
+
|
|
41
|
+
Keep the Agent8 server in a separate game-owned project. Its acceptance flow
|
|
42
|
+
must call `createVerse8Agent8LeaderboardBoundary` with an authenticated sender
|
|
43
|
+
account and a game-specific completion verifier, then cover rejected malformed
|
|
44
|
+
submissions, verified recording, and account-scoped snapshot reads.
|
|
45
|
+
|
|
46
|
+
Inject endpoints, persistence secrets, encryption keys, and authentication at
|
|
47
|
+
runtime. Do not commit personal MCP configuration, `.env` files, credentials,
|
|
48
|
+
tokens, or Agent8 authentication state to this generated game. The kit command
|
|
49
|
+
`pnpm smoke:verse8-agent8-acceptance` validates the generic structured-server
|
|
50
|
+
contract and generated Verse8 target surface; it does not validate a deployed
|
|
51
|
+
game server or replace game-owned production evidence.
|
|
52
|
+
|
|
39
53
|
Verify the first screen reports the viewport orientation policy, and treat
|
|
40
54
|
locked orientation modes as soft prompts instead of unsafe WebView hard locks.
|
|
@@ -20,7 +20,7 @@ pnpm check
|
|
|
20
20
|
pnpm build
|
|
21
21
|
pnpm icons:generate
|
|
22
22
|
pnpm icons:verify
|
|
23
|
-
pnpm exec mpgd target build-all --targets-file ./mpgd.targets.json --kit-path ../mpgd-kit --targets web,microsoft-store,ait,reddit --profile staging --ait-variant wrapper
|
|
23
|
+
pnpm exec mpgd target build-all --targets-file ./mpgd.targets.json --kit-path ../mpgd-kit --targets web,microsoft-store,verse8,ait,reddit --profile staging --ait-variant wrapper
|
|
24
24
|
pnpm devvit:login
|
|
25
25
|
pnpm devvit:init
|
|
26
26
|
pnpm devvit:playtest
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "__GAME_TITLE__",
|
|
6
6
|
"engine": "phaser-4"
|
|
7
7
|
},
|
|
8
|
-
"targets": ["web-preview", "microsoft-store", "android", "ios", "ait", "reddit"],
|
|
8
|
+
"targets": ["web-preview", "microsoft-store", "verse8", "android", "ios", "ait", "reddit"],
|
|
9
9
|
"futureTargets": ["telegram", "tauri"],
|
|
10
10
|
"platformBoundary": "PlatformGateway",
|
|
11
11
|
"mcpRequirements": {
|
|
@@ -3,9 +3,11 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
3
3
|
import { createServer, context, getServerPort, reddit, redis } from '@devvit/web/server';
|
|
4
4
|
import type { UiResponse } from '@devvit/web/shared';
|
|
5
5
|
import {
|
|
6
|
+
bridgeStorageLoadProtocol,
|
|
6
7
|
createBridgeError,
|
|
7
8
|
type BridgeRequest,
|
|
8
9
|
type BridgeResponse,
|
|
10
|
+
type BridgeStorageLoadData,
|
|
9
11
|
} from '@mpgd/bridge';
|
|
10
12
|
import {
|
|
11
13
|
createBridgeRpcRouter,
|
|
@@ -15,6 +17,7 @@ import { createBridgeRpcNodeHandler } from '@mpgd/bridge/orpc/node';
|
|
|
15
17
|
|
|
16
18
|
const maxStorageKeyLength = 128;
|
|
17
19
|
const maxEncodedStorageKeyLength = 384;
|
|
20
|
+
const maxStorageValueBytes = 262_144;
|
|
18
21
|
const maxRequestBodySize = 1_048_576;
|
|
19
22
|
const gameName = '__GAME_NAME__';
|
|
20
23
|
const gameTitle = __GAME_TITLE_TS_LITERAL__;
|
|
@@ -263,7 +266,11 @@ async function loadStorage(input: BridgeRequest): Promise<BridgeResponse> {
|
|
|
263
266
|
const playerId = currentPlayerId();
|
|
264
267
|
|
|
265
268
|
if (playerId === undefined) {
|
|
266
|
-
return
|
|
269
|
+
return createBridgeError(
|
|
270
|
+
input.id,
|
|
271
|
+
'DEVVIT_STORAGE_IDENTITY_REQUIRED',
|
|
272
|
+
'A current Reddit player is required to load storage.',
|
|
273
|
+
);
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
const key = storageKey(input, playerId);
|
|
@@ -272,14 +279,36 @@ async function loadStorage(input: BridgeRequest): Promise<BridgeResponse> {
|
|
|
272
279
|
return key;
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
|
|
282
|
+
let stored: string | null | undefined;
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
stored = await redis.get(key);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.warn(`devvit storage load failed: ${errorMessage(error)}`);
|
|
288
|
+
return createBridgeError(
|
|
289
|
+
input.id,
|
|
290
|
+
'DEVVIT_STORAGE_LOAD_FAILED',
|
|
291
|
+
'Devvit storage could not be loaded.',
|
|
292
|
+
true,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
276
295
|
|
|
277
296
|
if (stored === undefined || stored === null) {
|
|
278
|
-
return ok(input,
|
|
297
|
+
return ok(input, {
|
|
298
|
+
__mpgdBridgeProtocol: bridgeStorageLoadProtocol,
|
|
299
|
+
found: false,
|
|
300
|
+
} satisfies BridgeStorageLoadData);
|
|
279
301
|
}
|
|
280
302
|
|
|
281
303
|
try {
|
|
282
|
-
return ok(
|
|
304
|
+
return ok(
|
|
305
|
+
input,
|
|
306
|
+
{
|
|
307
|
+
__mpgdBridgeProtocol: bridgeStorageLoadProtocol,
|
|
308
|
+
found: true,
|
|
309
|
+
value: JSON.parse(stored),
|
|
310
|
+
} satisfies BridgeStorageLoadData,
|
|
311
|
+
);
|
|
283
312
|
} catch {
|
|
284
313
|
return createBridgeError(input.id, 'CORRUPTED_STORAGE_VALUE', 'Stored data is not valid JSON.');
|
|
285
314
|
}
|
|
@@ -289,9 +318,11 @@ async function saveStorage(input: BridgeRequest): Promise<BridgeResponse> {
|
|
|
289
318
|
const playerId = currentPlayerId();
|
|
290
319
|
|
|
291
320
|
if (playerId === undefined) {
|
|
292
|
-
return
|
|
293
|
-
|
|
294
|
-
|
|
321
|
+
return createBridgeError(
|
|
322
|
+
input.id,
|
|
323
|
+
'DEVVIT_STORAGE_IDENTITY_REQUIRED',
|
|
324
|
+
'A current Reddit player is required to save storage.',
|
|
325
|
+
);
|
|
295
326
|
}
|
|
296
327
|
|
|
297
328
|
const key = storageKey(input, playerId);
|
|
@@ -301,16 +332,42 @@ async function saveStorage(input: BridgeRequest): Promise<BridgeResponse> {
|
|
|
301
332
|
}
|
|
302
333
|
|
|
303
334
|
const payload = optionalObjectPayload(input.payload) as { readonly value?: unknown };
|
|
335
|
+
let serialized: string;
|
|
304
336
|
|
|
305
337
|
try {
|
|
306
|
-
|
|
307
|
-
} catch (error) {
|
|
308
|
-
console.warn(`devvit storage save was not persisted for key ${key}: ${errorMessage(error)}`);
|
|
338
|
+
const candidate = JSON.stringify(payload.value);
|
|
309
339
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
340
|
+
if (typeof candidate !== 'string') {
|
|
341
|
+
throw new Error('JSON serialization did not produce a string.');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
serialized = candidate;
|
|
345
|
+
} catch {
|
|
346
|
+
return createBridgeError(
|
|
347
|
+
input.id,
|
|
348
|
+
'INVALID_STORAGE_VALUE',
|
|
349
|
+
'Storage values must be JSON serializable.',
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (new TextEncoder().encode(serialized).length > maxStorageValueBytes) {
|
|
354
|
+
return createBridgeError(
|
|
355
|
+
input.id,
|
|
356
|
+
'DEVVIT_STORAGE_QUOTA_EXCEEDED',
|
|
357
|
+
`Storage values must not exceed ${String(maxStorageValueBytes)} UTF-8 bytes.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await redis.set(key, serialized);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
console.warn(`devvit storage save was not persisted: ${errorMessage(error)}`);
|
|
365
|
+
return createBridgeError(
|
|
366
|
+
input.id,
|
|
367
|
+
'DEVVIT_STORAGE_SAVE_FAILED',
|
|
368
|
+
'Devvit storage could not be saved.',
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
314
371
|
}
|
|
315
372
|
|
|
316
373
|
return ok(input, {
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
"icon": { "profile": "microsoft-pwa" },
|
|
15
15
|
"output": "artifacts/microsoft-store"
|
|
16
16
|
},
|
|
17
|
+
"verse8": {
|
|
18
|
+
"kind": "web",
|
|
19
|
+
"gameApp": ".",
|
|
20
|
+
"adapter": "verse8",
|
|
21
|
+
"icon": { "profile": "web-preview" },
|
|
22
|
+
"output": "artifacts/verse8"
|
|
23
|
+
},
|
|
17
24
|
"android": {
|
|
18
25
|
"kind": "capacitor-android",
|
|
19
26
|
"gameApp": ".",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"ait:console:deploy": "aitcc app deploy",
|
|
31
31
|
"build:microsoft-store": "pnpm exec mpgd target build microsoft-store production --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
32
32
|
"smoke:microsoft-store": "pnpm exec mpgd target smoke microsoft-store --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
33
|
+
"build:verse8": "pnpm exec mpgd target build verse8 production --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
34
|
+
"smoke:verse8": "pnpm exec mpgd target smoke verse8 --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
33
35
|
"build:devvit": "pnpm exec mpgd target build reddit production --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
34
36
|
"build:devvit:staging": "pnpm exec mpgd target build reddit staging --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
35
37
|
"smoke:devvit": "pnpm exec mpgd target smoke reddit --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
@@ -59,6 +61,7 @@
|
|
|
59
61
|
"@mpgd/adapter-browser": "__MPGD_DEPENDENCY_VERSION_ADAPTER_BROWSER__",
|
|
60
62
|
"@mpgd/adapter-capacitor": "__MPGD_DEPENDENCY_VERSION_ADAPTER_CAPACITOR__",
|
|
61
63
|
"@mpgd/adapter-devvit": "__MPGD_DEPENDENCY_VERSION_ADAPTER_DEVVIT__",
|
|
64
|
+
"@mpgd/adapter-verse8": "__MPGD_DEPENDENCY_VERSION_ADAPTER_VERSE8__",
|
|
62
65
|
"@mpgd/analytics": "__MPGD_DEPENDENCY_VERSION_ANALYTICS__",
|
|
63
66
|
"@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__",
|
|
64
67
|
"@mpgd/catalog": "__MPGD_DEPENDENCY_VERSION_CATALOG__",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createVerse8PlatformGateway } from '@mpgd/adapter-verse8';
|
|
2
|
+
import type { AdPlacements } from '@mpgd/catalog';
|
|
3
|
+
import adPlacementsJson from '@mpgd/catalog/placements.json';
|
|
4
|
+
import type { PlatformGateway } from '@mpgd/platform';
|
|
5
|
+
|
|
6
|
+
import type { RuntimeConfig } from '../runtimeDetector';
|
|
7
|
+
|
|
8
|
+
export async function createBuildGateway(_runtime: RuntimeConfig): Promise<PlatformGateway> {
|
|
9
|
+
const adPlacements = adPlacementsJson as AdPlacements;
|
|
10
|
+
const placementIds = new Map(
|
|
11
|
+
adPlacements.placements.flatMap((placement) => {
|
|
12
|
+
const platformPlacementId = placement.platformPlacementIds.verse8;
|
|
13
|
+
|
|
14
|
+
return platformPlacementId === undefined
|
|
15
|
+
? []
|
|
16
|
+
: [[placement.id, platformPlacementId] as const];
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return createVerse8PlatformGateway({
|
|
21
|
+
resolveAdPlacementId(placementId) {
|
|
22
|
+
return placementIds.get(placementId);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -88,6 +88,8 @@ export function resolveBuildGatewayModule(input: {
|
|
|
88
88
|
return input.debug && input.buildId === devvitSandboxBuildId
|
|
89
89
|
? 'src/platform/buildGateways/redditSandbox.ts'
|
|
90
90
|
: 'src/platform/buildGateways/reddit.ts';
|
|
91
|
+
case 'verse8':
|
|
92
|
+
return 'src/platform/buildGateways/verse8.ts';
|
|
91
93
|
default:
|
|
92
94
|
return 'src/platform/buildGateways/browser.ts';
|
|
93
95
|
}
|