@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.
@@ -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('&', '&amp;')
35
+ .replaceAll('<', '&lt;')
36
+ .replaceAll('>', '&gt;')
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
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { closeSync, existsSync, mkdirSync, openSync, readSync, rmSync, writeFileSync, } from 'node:fs';
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
- writeFileSync(jsonFile, `${JSON.stringify(report, null, 2)}\n`);
124
- writeFileSync(markdownFile, renderGameAcceptanceMarkdown(report));
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('&', '&amp;')
545
- .replaceAll('<', '&lt;')
546
- .replaceAll('>', '&gt;')
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
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { closeSync, existsSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, readlinkSync, readSync, rmSync, writeFileSync, } from 'node:fs';
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
- writeFileSync(verifiedJsonFile, `${JSON.stringify(report, null, 2)}\n`);
144
- writeFileSync(verifiedMarkdownFile, renderGameplayE2EMarkdown(report));
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('&', '&amp;')
671
- .replaceAll('<', '&lt;')
672
- .replaceAll('>', '&gt;')
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 error instanceof Error ? error.message : String(error);
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 = ['web-preview', 'microsoft-store', 'ait', 'reddit'];
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.10.1",
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-ait": "0.4.3",
53
- "@mpgd/adapter-browser": "0.4.3",
54
- "@mpgd/adapter-capacitor": "0.4.3",
55
- "@mpgd/adapter-devvit": "0.8.1",
56
- "@mpgd/analytics": "0.3.6",
57
- "@mpgd/bridge": "0.6.0",
58
- "@mpgd/catalog": "0.4.0",
59
- "@mpgd/game-services": "0.8.1",
60
- "@mpgd/platform": "0.6.0",
61
- "@mpgd/i18n": "0.5.2",
62
- "@mpgd/phaser-assets": "0.4.1",
63
- "@mpgd/target-config": "0.7.0"
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
- - Reddit Devvit is game-owned in `apps/target-devvit`.
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 ok(input, null);
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
- const stored = await redis.get(key);
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, null);
297
+ return ok(input, {
298
+ __mpgdBridgeProtocol: bridgeStorageLoadProtocol,
299
+ found: false,
300
+ } satisfies BridgeStorageLoadData);
279
301
  }
280
302
 
281
303
  try {
282
- return ok(input, JSON.parse(stored));
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 ok(input, {
293
- saved: false,
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
- await redis.set(key, JSON.stringify(payload.value ?? null));
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
- return ok(input, {
311
- saved: false,
312
- playerId,
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
+ }
@@ -5,6 +5,7 @@ const validTargets = new Set<string>([
5
5
  'ios',
6
6
  'ait',
7
7
  'reddit',
8
+ 'verse8',
8
9
  'telegram',
9
10
  'tauri',
10
11
  ] satisfies readonly PlatformTarget[]);
@@ -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
  }