@mpgd/cli 0.40.0 → 0.41.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.
@@ -1,3 +1,4 @@
1
+ import { type PerformanceAcceptanceEvidence, type PerformanceAcceptanceInput } from './performance-acceptance.js';
1
2
  export declare const defaultGameAcceptanceCommandTimeoutMs: number;
2
3
  export declare const maximumGameplayE2EReportBytes: number;
3
4
  export declare function resolveGameAcceptanceReleaseManifestFile(gameRoot: string, env?: NodeJS.ProcessEnv): string;
@@ -32,6 +33,7 @@ export interface GameAcceptanceReport {
32
33
  readonly options: Readonly<Record<string, string | boolean | null>>;
33
34
  readonly steps: readonly GameAcceptanceStepResult[];
34
35
  readonly evidence: {
36
+ readonly performance?: PerformanceAcceptanceEvidence;
35
37
  readonly releaseManifest: {
36
38
  readonly file: string;
37
39
  readonly found: boolean;
@@ -57,6 +59,8 @@ export interface RunGameAcceptanceInput {
57
59
  readonly reportDir: string;
58
60
  readonly releaseManifestFile?: string;
59
61
  readonly gameplayE2EReportFile?: string;
62
+ /** Optional fresh performance collection and consumer-authored budget. */
63
+ readonly performance?: PerformanceAcceptanceInput;
60
64
  readonly requireGameplayE2EReport?: boolean;
61
65
  /** Step that must replace any previous gameplay report before evidence is accepted. */
62
66
  readonly gameplayE2EStepId?: string;
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError, readBoundedUtf8File, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
5
5
  import { collectGameplayE2EPathEvidence, maximumGameplayE2EStates, readGameplayE2EPlan, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
6
+ import { preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, } from './performance-acceptance.js';
6
7
  export const defaultGameAcceptanceCommandTimeoutMs = 30 * 60 * 1_000;
7
8
  const defaultGameAcceptanceReleaseManifestFile = 'artifacts/release-manifest.json';
8
9
  export const maximumGameplayE2EReportBytes = 1_024 * 1_024;
@@ -22,10 +23,25 @@ export function runGameAcceptance(input) {
22
23
  const commandRunner = input.commandRunner
23
24
  ?? ((step) => runAcceptanceCommand(step, input.env ?? process.env, commandTimeoutMs));
24
25
  const gameRoot = path.resolve(input.gameRoot);
26
+ const reportDir = path.resolve(input.reportDir);
27
+ const jsonFile = path.join(reportDir, 'acceptance-report.json');
28
+ const markdownFile = path.join(reportDir, 'acceptance-report.md');
25
29
  const startedAtMs = now();
26
30
  const results = [];
27
31
  let failed = false;
28
32
  let gameplayE2EReportFileToReplace;
33
+ const performance = input.performance === undefined
34
+ ? undefined
35
+ : preparePerformanceAcceptance(gameRoot, input.performance);
36
+ if (performance !== undefined && [jsonFile, markdownFile].some((file) => file === performance.reportFile || file === path.resolve(gameRoot, performance.budgetEvidence.file))) {
37
+ throw new Error('Performance budget and report must not overwrite acceptance handoff files.');
38
+ }
39
+ let performanceStartedAtMs;
40
+ if (input.performance !== undefined
41
+ && (input.steps.filter((step) => step.id === input.performance?.stepId).length !== 1
42
+ || input.steps.some((step) => step.id === input.performance?.stepId && step.skipReason !== undefined))) {
43
+ throw new Error('Performance evidence needs exactly one runnable collection step.');
44
+ }
29
45
  if (input.gameplayE2EStepId !== undefined) {
30
46
  if (input.gameplayE2EReportFile === undefined) {
31
47
  throw new Error('A gameplay E2E evidence step needs a report file.');
@@ -50,6 +66,16 @@ export function runGameAcceptance(input) {
50
66
  continue;
51
67
  }
52
68
  }
69
+ if (!failed && step.id === input.performance?.stepId && performance !== undefined) {
70
+ try {
71
+ rmSync(resolveGameplayE2EPathInsideGameRoot(gameRoot, performance.reportFile, 'Performance report'), { force: true });
72
+ }
73
+ catch (error) {
74
+ results.push(failedStepResult(step, now(), formatError(error)));
75
+ failed = true;
76
+ continue;
77
+ }
78
+ }
53
79
  if (failed) {
54
80
  results.push(skippedStepResult(step, now(), 'A previous acceptance step failed.'));
55
81
  continue;
@@ -70,6 +96,9 @@ export function runGameAcceptance(input) {
70
96
  const cwd = path.resolve(runnableStep.cwd);
71
97
  const stepStartedAtMs = now();
72
98
  const startedAt = new Date(stepStartedAtMs).toISOString();
99
+ if (step.id === input.performance?.stepId) {
100
+ performanceStartedAtMs = stepStartedAtMs;
101
+ }
73
102
  log(`[mpgd:accept] ${step.label}`);
74
103
  let commandResult;
75
104
  try {
@@ -103,11 +132,23 @@ export function runGameAcceptance(input) {
103
132
  || !gameplayE2E.found
104
133
  || gameplayE2E.parseError !== null
105
134
  || gameplayE2E.validationError !== null);
135
+ const performanceEvidence = performance === undefined
136
+ ? undefined
137
+ : readPerformanceAcceptanceEvidence({
138
+ gameRoot,
139
+ prepared: performance,
140
+ startedAtMs: performanceStartedAtMs,
141
+ finishedAtMs: now(),
142
+ profile: typeof input.options.profile === 'string' ? input.options.profile : undefined,
143
+ releaseManifestFile: input.releaseManifestFile,
144
+ });
145
+ const performanceFailed = performanceEvidence?.validationError !== undefined
146
+ && performanceEvidence.validationError !== null;
106
147
  const finishedAtMs = now();
107
148
  const report = {
108
149
  schemaVersion: 1,
109
150
  generatedAt: new Date(finishedAtMs).toISOString(),
110
- status: failed || evidenceFailed ? 'failed' : 'passed',
151
+ status: failed || evidenceFailed || performanceFailed ? 'failed' : 'passed',
111
152
  gameRoot,
112
153
  durationMs: Math.max(0, finishedAtMs - startedAtMs),
113
154
  options: input.options,
@@ -115,11 +156,9 @@ export function runGameAcceptance(input) {
115
156
  evidence: {
116
157
  releaseManifest,
117
158
  gameplayE2E,
159
+ ...(performanceEvidence === undefined ? {} : { performance: performanceEvidence }),
118
160
  },
119
161
  };
120
- const reportDir = path.resolve(input.reportDir);
121
- const jsonFile = path.join(reportDir, 'acceptance-report.json');
122
- const markdownFile = path.join(reportDir, 'acceptance-report.md');
123
162
  mkdirSync(reportDir, { recursive: true });
124
163
  writeEvidenceReportFiles({
125
164
  jsonFile,
@@ -180,6 +219,12 @@ export function renderGameAcceptanceMarkdown(report) {
180
219
  else {
181
220
  lines.push(`- Gameplay E2E report: ${escapeMarkdownInline(report.evidence.gameplayE2E.file)}`);
182
221
  }
222
+ if (report.evidence.performance !== undefined) {
223
+ const performance = report.evidence.performance;
224
+ lines.push('', '## Performance Evidence', '', `- Report: ${escapeMarkdownInline(performance.file)}`, `- Budget: ${escapeMarkdownInline(performance.budget.file)} (${performance.budget.sha256})`, performance.validationError === null
225
+ ? '- Budget validation passed.'
226
+ : `- Validation failed: ${escapeMarkdownInline(performance.validationError)}`);
227
+ }
183
228
  return `${lines.join('\n')}\n`;
184
229
  }
185
230
  function runAcceptanceCommand(step, env, timeoutMs) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
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 { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, type PerformanceAcceptanceEvidence, type PerformanceAcceptanceInput, type PerformanceBudget, type PerformanceBudgetProfile, type PerformanceMetricBudget, type PreparedPerformanceAcceptance, } from './performance-acceptance.js';
2
3
  export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, type CapacitorShellCommandRunner, type CapacitorShellStarterInput, type CapacitorShellStarterPlan, } from './capacitor-shell-starter.js';
3
4
  export { ReleaseProcessError, runReleaseProcess, type ReleaseProcessInput, type ReleaseProcessResult, } from './deploy-process.js';
4
5
  export { installPinnedReleaseDependencies, pinNativeDeploymentPlan, preparePinnedReleaseWorkspace, runPinnedNativeBuild, withPinnedReleaseWorkspace, type PinnedNativeBuildResult, type PinnedReleaseInput, type PinnedReleaseWorkspace, } from './release-workspace.js';
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { initializeNativeDeployWorkflow } from './native-deploy-workflow.js';
18
18
  import { normalizeConfiguredBuildTargets, normalizeBuildTarget as normalizeConfiguredTargetName, supportedBuildTargets, } from './build-targets.js';
19
19
  import { escapeMarkdownInline as escapeEvidenceMarkdownInline, writeEvidenceReportFiles, } from './evidence-io.js';
20
20
  import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
21
- import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
21
+ import { resolveGameplayE2EPathInsideGameRoot, resolveGameplayE2EReportFile, } from './gameplay-e2e.js';
22
22
  import { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, verifyHostedPwaDeployment, } from './hosted-pwa-deployment.js';
23
23
  import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
24
24
  import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
@@ -31,6 +31,7 @@ import { readStoreCredential, runNativeDeployment } from './native-deploy-run.js
31
31
  import { submitRecordedNativeTarget } from './native-deploy-submission.js';
32
32
  import { readNativeReleaseStatus as readNativeReleaseStatusForCli } from './release-state.js';
33
33
  export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
34
+ export { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, } from './performance-acceptance.js';
34
35
  export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
35
36
  export { ReleaseProcessError, runReleaseProcess, } from './deploy-process.js';
36
37
  export { installPinnedReleaseDependencies, pinNativeDeploymentPlan, preparePinnedReleaseWorkspace, runPinnedNativeBuild, withPinnedReleaseWorkspace, } from './release-workspace.js';
@@ -111,6 +112,7 @@ const acceptanceStepIds = {
111
112
  targetBuild: 'target-build',
112
113
  targetSmoke: 'target-smoke',
113
114
  gameplayE2E: 'gameplay-e2e',
115
+ performance: 'performance',
114
116
  };
115
117
  /** Whether the last run reported a deadline failure that leaves
116
118
  * uncancellable filesystem work pending: the standalone binary forces its
@@ -725,6 +727,18 @@ const gameCommand = defineI18n({
725
727
  required: false,
726
728
  description: 'Skip the game-owned playtest package script.',
727
729
  },
730
+ performance: {
731
+ type: 'boolean', required: false,
732
+ description: 'Collect and validate fresh foreground performance evidence.',
733
+ },
734
+ 'performance-script': {
735
+ type: 'string', required: false, default: 'performance:e2e',
736
+ description: 'Game-owned performance collection package script.',
737
+ },
738
+ 'performance-budget': {
739
+ type: 'string', required: false, default: 'agent/performance.budget.json',
740
+ description: 'Consumer-authored performance budget JSON inside the game root.',
741
+ },
728
742
  'skip-gameplay-e2e': {
729
743
  type: 'boolean',
730
744
  required: false,
@@ -764,6 +778,9 @@ const gameCommand = defineI18n({
764
778
  skipGraph: ctx.values['skip-graph'] === true,
765
779
  skipPlaytest: ctx.values['skip-playtest'] === true,
766
780
  skipGameplayE2E: ctx.values['skip-gameplay-e2e'] === true,
781
+ performance: ctx.values.performance === true,
782
+ performanceScript: readOptionalString(ctx.values['performance-script']) ?? 'performance:e2e',
783
+ performanceBudget: readOptionalString(ctx.values['performance-budget']) ?? 'agent/performance.budget.json',
767
784
  skipTargetBuild: ctx.values['skip-target-build'] === true,
768
785
  skipTargetSmoke: ctx.values['skip-target-smoke'] === true,
769
786
  });
@@ -923,7 +940,7 @@ function acceptGame(input) {
923
940
  const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
924
941
  const gameplayE2EEnabled = !input.skipGameplayE2E
925
942
  && gamePackage.scripts?.[input.gameplayScript] !== undefined;
926
- const gameplayE2ETargets = gameplayE2EEnabled
943
+ const gameplayE2ETargets = gameplayE2EEnabled || input.performance
927
944
  ? parseTargetList(input.targets, {
928
945
  ...process.env,
929
946
  MPGD_PLATFORM_TARGETS_FILE: targetsFile,
@@ -1014,12 +1031,30 @@ function acceptGame(input) {
1014
1031
  skipped: input.skipGameplayE2E,
1015
1032
  }),
1016
1033
  ];
1034
+ const performanceReportFile = input.performance
1035
+ ? resolveGameplayE2EPathInsideGameRoot(gameRoot, readOptionalString(process.env.MPGD_PERFORMANCE_REPORT_FILE) ?? 'artifacts/performance/report.json', 'Performance report')
1036
+ : undefined;
1037
+ if (input.performance) {
1038
+ steps.push(packageScriptAcceptanceStep({
1039
+ id: acceptanceStepIds.performance,
1040
+ label: 'Foreground performance collection',
1041
+ script: input.performanceScript,
1042
+ gameRoot,
1043
+ packageManager,
1044
+ scripts: gamePackage.scripts,
1045
+ required: true,
1046
+ }));
1047
+ }
1017
1048
  const reportDir = path.resolve(gameRoot, input.reportDir ?? 'artifacts/acceptance');
1018
1049
  const releaseManifestFile = resolveGameAcceptanceReleaseManifestFile(gameRoot);
1019
1050
  const gameplayE2EReportFile = resolveGameplayE2EReportFile(gameRoot);
1020
1051
  const result = runGameAcceptance({
1021
1052
  gameRoot,
1022
1053
  reportDir,
1054
+ ...(performanceReportFile === undefined ? {} : { performance: {
1055
+ budgetFile: input.performanceBudget, reportFile: performanceReportFile,
1056
+ stepId: acceptanceStepIds.performance, targets: gameplayE2ETargets,
1057
+ } }),
1023
1058
  ...(input.skipTargetBuild
1024
1059
  ? {}
1025
1060
  : { releaseManifestFile }),
@@ -1038,6 +1073,8 @@ function acceptGame(input) {
1038
1073
  iosVariant: input.iosVariant ?? null,
1039
1074
  playtestScript: input.playtestScript,
1040
1075
  gameplayScript: input.gameplayScript,
1076
+ ...(input.performance ? { performance: true, performanceScript: input.performanceScript,
1077
+ performanceBudget: input.performanceBudget } : {}),
1041
1078
  skipTest: input.skipTest,
1042
1079
  skipGraph: input.skipGraph,
1043
1080
  skipPlaytest: input.skipPlaytest,
@@ -1051,12 +1088,16 @@ function acceptGame(input) {
1051
1088
  env: {
1052
1089
  ...process.env,
1053
1090
  MPGD_KIT_PATH: kitPath,
1054
- MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled
1091
+ MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled || input.performance
1055
1092
  ? gameplayE2ETargets.join(',')
1056
1093
  : input.targets,
1057
1094
  MPGD_ACCEPTANCE_PROFILE: input.profile,
1058
1095
  MPGD_RELEASE_MANIFEST_FILE: releaseManifestFile,
1059
1096
  MPGD_GAMEPLAY_E2E_REPORT_FILE: gameplayE2EReportFile,
1097
+ ...(performanceReportFile === undefined ? {} : {
1098
+ MPGD_PERFORMANCE_REPORT_FILE: performanceReportFile,
1099
+ MPGD_PERFORMANCE_BUDGET_FILE: path.resolve(gameRoot, input.performanceBudget),
1100
+ }),
1060
1101
  },
1061
1102
  });
1062
1103
  console.info(`[mpgd:accept] JSON report: ${result.jsonFile}`);
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "ccd019a49651a6730a4f0214d9dfcf0094bda226",
2
+ "kitGitSha": "ca727bc6ebd8c1ecb7515ceb76f48ea5e913ab82",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.40.0"
4
+ "packageVersion": "0.41.0"
5
5
  }
@@ -0,0 +1,56 @@
1
+ import { type GameplayE2EPathEvidence } from './gameplay-e2e.js';
2
+ export declare const maximumPerformanceEvidenceBytes: number;
3
+ export interface PerformanceMetricBudget {
4
+ readonly p95?: number;
5
+ readonly p99?: number;
6
+ readonly max?: number;
7
+ }
8
+ export interface PerformanceBudgetProfile {
9
+ readonly id: string;
10
+ readonly target: string;
11
+ readonly scenario: string;
12
+ readonly renderer: string;
13
+ readonly device: string;
14
+ readonly minSamples: number;
15
+ readonly metrics: Readonly<Record<string, PerformanceMetricBudget>>;
16
+ readonly maxExcludedFrames?: number;
17
+ readonly maxHeapGrowthBytes?: number;
18
+ readonly hitchThresholdMs?: number;
19
+ readonly maxHitches?: number;
20
+ }
21
+ export interface PerformanceBudget {
22
+ readonly schemaVersion: 1;
23
+ readonly profiles: readonly PerformanceBudgetProfile[];
24
+ }
25
+ export interface PerformanceAcceptanceInput {
26
+ readonly budgetFile: string;
27
+ readonly reportFile: string;
28
+ readonly stepId: string;
29
+ readonly targets: readonly string[];
30
+ }
31
+ export interface PreparedPerformanceAcceptance {
32
+ readonly budget: PerformanceBudget;
33
+ readonly budgetEvidence: GameplayE2EPathEvidence;
34
+ readonly reportFile: string;
35
+ }
36
+ export interface PerformanceAcceptanceEvidence {
37
+ readonly file: string;
38
+ readonly found: boolean;
39
+ readonly validationError: string | null;
40
+ readonly budget: GameplayE2EPathEvidence;
41
+ readonly report: GameplayE2EPathEvidence | null;
42
+ readonly value: unknown;
43
+ }
44
+ /** Consumer-authored budgets: no universal device/FPS thresholds are supplied. */
45
+ export declare function validatePerformanceBudget(value: unknown): PerformanceBudget;
46
+ export declare function preparePerformanceAcceptance(gameRoot: string, input: PerformanceAcceptanceInput): PreparedPerformanceAcceptance;
47
+ /** Accept only fresh untraced foreground measurements with matching authored
48
+ * context, adequate sample counts and current artifact/budget provenance. */
49
+ export declare function readPerformanceAcceptanceEvidence(input: {
50
+ readonly gameRoot: string;
51
+ readonly prepared: PreparedPerformanceAcceptance;
52
+ readonly startedAtMs: number | undefined;
53
+ readonly finishedAtMs: number;
54
+ readonly profile: string | undefined;
55
+ readonly releaseManifestFile?: string | undefined;
56
+ }): PerformanceAcceptanceEvidence;
@@ -0,0 +1,269 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { formatError, readBoundedUtf8File, relativeOrAbsolute } from './evidence-io.js';
4
+ import { collectGameplayE2EPathEvidence, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
5
+ export const maximumPerformanceEvidenceBytes = 1_024 * 1_024;
6
+ function record(value, label) {
7
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
8
+ throw new Error(`${label} must be an object.`);
9
+ }
10
+ return value;
11
+ }
12
+ function text(value, label) {
13
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > 256) {
14
+ throw new Error(`${label} must be a bounded non-empty string.`);
15
+ }
16
+ return value;
17
+ }
18
+ function number(value, label, signed = false) {
19
+ if (typeof value !== 'number' || !Number.isFinite(value) || (!signed && value < 0)) {
20
+ throw new Error(`${label} must be a finite ${signed ? '' : 'non-negative '}number.`);
21
+ }
22
+ return value;
23
+ }
24
+ function count(value, label, min = 0) {
25
+ const n = number(value, label);
26
+ if (!Number.isSafeInteger(n) || n < min || n > 1_000_000_000) {
27
+ throw new Error(`${label} must be an integer between ${min} and 1000000000.`);
28
+ }
29
+ return n;
30
+ }
31
+ function keys(value, allowed, label) {
32
+ if (Object.keys(value).some((key) => !allowed.includes(key))) {
33
+ throw new Error(`${label} contains an unknown field.`);
34
+ }
35
+ }
36
+ /** Consumer-authored budgets: no universal device/FPS thresholds are supplied. */
37
+ export function validatePerformanceBudget(value) {
38
+ const input = record(value, 'Performance budget');
39
+ keys(input, ['schemaVersion', 'profiles'], 'Performance budget');
40
+ if (input.schemaVersion !== 1 || !Array.isArray(input.profiles)
41
+ || input.profiles.length === 0 || input.profiles.length > 64) {
42
+ throw new Error('Performance budget needs schemaVersion 1 and 1–64 profiles.');
43
+ }
44
+ const ids = new Set();
45
+ const profiles = input.profiles.map((item) => {
46
+ const profile = record(item, 'Performance profile');
47
+ keys(profile, ['id', 'target', 'scenario', 'renderer', 'device', 'minSamples', 'metrics',
48
+ 'maxExcludedFrames', 'maxHeapGrowthBytes', 'hitchThresholdMs', 'maxHitches'], 'Performance profile');
49
+ for (const name of ['id', 'target', 'scenario', 'renderer', 'device']) {
50
+ text(profile[name], `Performance profile ${name}`);
51
+ }
52
+ const id = text(profile.id, 'Performance profile id');
53
+ if (ids.has(id)) {
54
+ throw new Error(`Duplicate performance profile: ${id}`);
55
+ }
56
+ ids.add(id);
57
+ count(profile.minSamples, `${id} minSamples`, 2);
58
+ const metrics = record(profile.metrics, `${id} metrics`);
59
+ const metricNames = Object.keys(metrics);
60
+ const builtInMetrics = ['intervalMs', 'totalCpuMs'];
61
+ const customNames = metricNames.filter((field) => !builtInMetrics.includes(field));
62
+ if (metricNames.length === 0 || customNames.length > 64) {
63
+ throw new Error(`${id} needs 1–66 metric budgets.`);
64
+ }
65
+ if (customNames.some((field) => field.length > 64 || !/^[a-z][a-zA-Z0-9]*$/.test(field)
66
+ || ['constructor', 'prototype'].includes(field))) {
67
+ throw new Error(`${id} has an invalid profiler metric name.`);
68
+ }
69
+ for (const [field, itemBudget] of Object.entries(metrics)) {
70
+ text(field, 'Performance metric name');
71
+ const budget = record(itemBudget, `${id}/${field} budget`);
72
+ keys(budget, ['p95', 'p99', 'max'], `${id}/${field} budget`);
73
+ if (Object.keys(budget).length === 0) {
74
+ throw new Error(`${id}/${field} needs a threshold.`);
75
+ }
76
+ for (const [quantile, threshold] of Object.entries(budget)) {
77
+ number(threshold, `${id}/${field}/${quantile}`);
78
+ }
79
+ }
80
+ for (const name of ['maxHeapGrowthBytes', 'hitchThresholdMs']) {
81
+ if (profile[name] !== undefined) {
82
+ number(profile[name], `${id} ${name}`);
83
+ }
84
+ }
85
+ for (const name of ['maxExcludedFrames', 'maxHitches']) {
86
+ if (profile[name] !== undefined) {
87
+ count(profile[name], `${id} ${name}`);
88
+ }
89
+ }
90
+ if ((profile.maxHitches === undefined) !== (profile.hitchThresholdMs === undefined)
91
+ || profile.hitchThresholdMs === 0) {
92
+ throw new Error(`${id} hitch budgets need maxHitches and a positive hitchThresholdMs.`);
93
+ }
94
+ const snapshot = structuredClone(profile);
95
+ for (const metric of Object.values(snapshot.metrics)) {
96
+ Object.freeze(metric);
97
+ }
98
+ Object.freeze(snapshot.metrics);
99
+ return Object.freeze(snapshot);
100
+ });
101
+ return Object.freeze({ schemaVersion: 1, profiles: Object.freeze(profiles) });
102
+ }
103
+ function readJson(file, label) {
104
+ const content = readBoundedUtf8File(file, maximumPerformanceEvidenceBytes);
105
+ if (content === null) {
106
+ throw new Error(`${label} exceeds its byte limit.`);
107
+ }
108
+ return JSON.parse(content);
109
+ }
110
+ function evidence(gameRoot, value, label) {
111
+ const input = record(value, label);
112
+ const file = text(input.file, `${label} file`);
113
+ const current = collectGameplayE2EPathEvidence(gameRoot, file, label);
114
+ if (current.kind === 'symbolic-link' || current.kind !== input.kind || current.sha256 !== input.sha256) {
115
+ throw new Error(`${label} no longer matches its current file/directory hash.`);
116
+ }
117
+ return current;
118
+ }
119
+ export function preparePerformanceAcceptance(gameRoot, input) {
120
+ const budgetFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, input.budgetFile, 'Performance budget');
121
+ const reportFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, input.reportFile, 'Performance report');
122
+ if (budgetFile === reportFile) {
123
+ throw new Error('Performance report must not overwrite its budget.');
124
+ }
125
+ const budget = validatePerformanceBudget(readJson(budgetFile, 'Performance budget'));
126
+ if (budget.profiles.some((profile) => !input.targets.includes(profile.target))) {
127
+ throw new Error('Performance budget targets must belong to the selected acceptance targets.');
128
+ }
129
+ const budgetEvidence = collectGameplayE2EPathEvidence(gameRoot, budgetFile, 'Performance budget');
130
+ if (budgetEvidence.kind !== 'file') {
131
+ throw new Error('Performance budget must be a regular file.');
132
+ }
133
+ return Object.freeze({ budget, budgetEvidence: Object.freeze(budgetEvidence), reportFile });
134
+ }
135
+ /** Accept only fresh untraced foreground measurements with matching authored
136
+ * context, adequate sample counts and current artifact/budget provenance. */
137
+ export function readPerformanceAcceptanceEvidence(input) {
138
+ const { gameRoot, prepared } = input;
139
+ const result = {
140
+ file: relativeOrAbsolute(gameRoot, prepared.reportFile),
141
+ found: false,
142
+ validationError: null,
143
+ budget: prepared.budgetEvidence,
144
+ report: null,
145
+ value: null,
146
+ };
147
+ let value;
148
+ try {
149
+ const file = resolveGameplayE2EPathInsideGameRoot(gameRoot, prepared.reportFile, 'Performance report');
150
+ if (!existsSync(file)) {
151
+ throw new Error('Performance collector did not produce its report.');
152
+ }
153
+ value = readJson(file, 'Performance report');
154
+ const reportEvidence = collectGameplayE2EPathEvidence(gameRoot, file, 'Performance report');
155
+ const report = record(value, 'Performance report');
156
+ if (report.schemaVersion !== 1 || input.startedAtMs === undefined) {
157
+ throw new Error('Performance report needs schemaVersion 1 and a completed collection step.');
158
+ }
159
+ text(report.profile, 'Performance report profile');
160
+ const at = Date.parse(text(report.generatedAt, 'Performance generatedAt'));
161
+ if (!Number.isFinite(at) || new Date(at).toISOString() !== report.generatedAt
162
+ || at < input.startedAtMs || at > input.finishedAtMs) {
163
+ throw new Error('Performance report timestamp must be inside this collection run.');
164
+ }
165
+ if (input.profile !== undefined && report.profile !== input.profile) {
166
+ throw new Error('Performance report profile must match acceptance.');
167
+ }
168
+ const currentBudget = evidence(gameRoot, report.budget, 'Performance budget');
169
+ if (currentBudget.file !== prepared.budgetEvidence.file
170
+ || currentBudget.sha256 !== prepared.budgetEvidence.sha256) {
171
+ throw new Error('Performance budget changed during acceptance.');
172
+ }
173
+ if (!Array.isArray(report.measurements) || report.measurements.length !== prepared.budget.profiles.length) {
174
+ throw new Error('Performance report must cover every budget profile exactly once.');
175
+ }
176
+ const seen = new Set();
177
+ const release = input.releaseManifestFile === undefined
178
+ ? undefined
179
+ : record(readJson(input.releaseManifestFile, 'Release manifest'), 'Release manifest');
180
+ for (const item of report.measurements) {
181
+ const measurement = record(item, 'Performance measurement');
182
+ const id = text(measurement.id, 'Performance measurement id');
183
+ const budget = prepared.budget.profiles.find((profile) => profile.id === id);
184
+ if (budget === undefined || seen.has(id)) {
185
+ throw new Error('Performance measurements have missing/duplicate profiles.');
186
+ }
187
+ seen.add(id);
188
+ const context = record(measurement.context, `${id} context`);
189
+ for (const name of ['target', 'scenario', 'renderer', 'device']) {
190
+ if (context[name] !== budget[name]) {
191
+ throw new Error(`${id} ${name} does not match its budget.`);
192
+ }
193
+ }
194
+ const dpr = number(context.devicePixelRatio, `${id} devicePixelRatio`);
195
+ if (dpr === 0 || dpr > 64 || context.foreground !== true) {
196
+ throw new Error(`${id} must record a positive DPR and foreground measurement.`);
197
+ }
198
+ const instrumentation = record(context.instrumentation, `${id} instrumentation`);
199
+ if (instrumentation.trace !== false || instrumentation.screenshots !== false) {
200
+ throw new Error(`${id} needs an untraced measurement without screenshot capture.`);
201
+ }
202
+ for (const name of ['logicalWidth', 'logicalHeight', 'backingWidth', 'backingHeight']) {
203
+ if (number(context[name], `${id} ${name}`) === 0) {
204
+ throw new Error(`${id} dimensions must be positive.`);
205
+ }
206
+ }
207
+ const artifact = evidence(gameRoot, measurement.artifact, `${id} target artifact`);
208
+ if (release !== undefined) {
209
+ const target = record(record(release.targets, 'Release targets')[budget.target], `${id} release target`);
210
+ if (typeof target.artifact !== 'string'
211
+ || path.resolve(gameRoot, target.artifact) !== path.resolve(gameRoot, artifact.file)
212
+ || (target.profile !== undefined && target.profile !== report.profile)) {
213
+ throw new Error(`${id} artifact/profile differs from the release target.`);
214
+ }
215
+ }
216
+ const snapshot = record(measurement.snapshot, `${id} snapshot`);
217
+ const metrics = record(snapshot.metrics, `${id} metrics`);
218
+ const maximums = record(snapshot.maximums, `${id} maximums`);
219
+ const samples = record(snapshot.sampleCounts, `${id} sampleCounts`);
220
+ const retained = count(snapshot.retainedSamples, `${id} retainedSamples`);
221
+ const frames = count(snapshot.frames, `${id} frames`);
222
+ const excluded = count(snapshot.excludedFrames, `${id} excludedFrames`);
223
+ if (frames < retained || (budget.maxExcludedFrames !== undefined && excluded > budget.maxExcludedFrames)) {
224
+ throw new Error(`${id} has inconsistent frame counts or too many excluded frames.`);
225
+ }
226
+ for (const [field, thresholds] of Object.entries(budget.metrics)) {
227
+ const n = count(samples[field], `${id}/${field} sampleCount`);
228
+ if (n < budget.minSamples || n > retained) {
229
+ throw new Error(`${id}/${field} has insufficient/inconsistent samples.`);
230
+ }
231
+ const quantiles = record(metrics[field], `${id}/${field} quantiles`);
232
+ const p50 = number(quantiles.p50, `${id}/${field} p50`);
233
+ const p95 = number(quantiles.p95, `${id}/${field} p95`);
234
+ const p99 = number(quantiles.p99, `${id}/${field} p99`);
235
+ const max = number(maximums[field], `${id}/${field} max`);
236
+ if (p50 > p95 || p95 > p99 || p99 > max) {
237
+ throw new Error(`${id}/${field} quantiles are inconsistent.`);
238
+ }
239
+ for (const [key, threshold] of Object.entries(thresholds)) {
240
+ const measured = key === 'max' ? max : number(quantiles[key], `${id}/${field}/${key}`);
241
+ if (measured > threshold) {
242
+ throw new Error(`${id}/${field} ${key} exceeds its budget.`);
243
+ }
244
+ }
245
+ }
246
+ if (budget.maxHeapGrowthBytes !== undefined
247
+ && number(measurement.heapGrowthBytes, `${id} heapGrowthBytes`, true) > budget.maxHeapGrowthBytes) {
248
+ throw new Error(`${id} heap growth exceeds its budget.`);
249
+ }
250
+ if (budget.maxHitches !== undefined) {
251
+ const hitches = record(measurement.hitches, `${id} hitches`);
252
+ const n = count(hitches.count, `${id} hitch count`);
253
+ const total = count(hitches.samples, `${id} hitch samples`, budget.minSamples);
254
+ if (n > total || n > budget.maxHitches || hitches.thresholdMs !== budget.hitchThresholdMs) {
255
+ throw new Error(`${id} hitches exceed or do not match their budget.`);
256
+ }
257
+ }
258
+ }
259
+ return { ...result, found: true, report: reportEvidence, value };
260
+ }
261
+ catch (error) {
262
+ return {
263
+ ...result,
264
+ found: existsSync(prepared.reportFile),
265
+ validationError: formatError(error),
266
+ value: value ?? null,
267
+ };
268
+ }
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,8 +59,8 @@
59
59
  "vite": "8.1.3",
60
60
  "yauzl": "3.4.0",
61
61
  "@mpgd/catalog": "0.8.0",
62
- "@mpgd/target-config": "0.20.0",
63
- "@mpgd/phaser-assets": "0.8.0"
62
+ "@mpgd/phaser-assets": "0.9.0",
63
+ "@mpgd/target-config": "0.21.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.0.0",
@@ -68,15 +68,15 @@
68
68
  "@types/yauzl": "3.4.0",
69
69
  "ttsc": "0.30.4",
70
70
  "typescript": "7.0.2",
71
- "@mpgd/adapter-ait": "0.13.2",
71
+ "@mpgd/adapter-ait": "0.13.3",
72
72
  "@mpgd/adapter-browser": "0.9.0",
73
- "@mpgd/adapter-capacitor": "0.7.3",
73
+ "@mpgd/adapter-capacitor": "0.7.4",
74
74
  "@mpgd/adapter-devvit": "0.9.17",
75
+ "@mpgd/adapter-verse8": "0.4.3",
75
76
  "@mpgd/analytics": "0.4.3",
76
- "@mpgd/adapter-verse8": "0.4.2",
77
77
  "@mpgd/game-services": "0.19.0",
78
78
  "@mpgd/bridge": "0.11.0",
79
- "@mpgd/game-runtime": "0.5.0",
79
+ "@mpgd/game-runtime": "0.6.0",
80
80
  "@mpgd/i18n": "0.6.8",
81
81
  "@mpgd/platform": "0.17.0"
82
82
  },
@@ -82,3 +82,10 @@ the snapshot bounds for persistent controls.
82
82
  Build and smoke the configured ONE play target using the game-owned kit path. Validate server-issued reward/checkout binding, raw-byte signed SSV, purchase token API verification, ledger-before-consume/acknowledge, retries without duplicate grants, signed PNS orphan-purchase recovery and atomic cancellation fences. Missing finalization must keep PNS pending; a busy SDK must report native not-started before rejecting a new checkout. Permit five minutes of provider/server clock skew while rejecting timestamps outside the intent window.
83
83
 
84
84
  Use an authenticated server session rather than the SDK player pseudonym. Confirm ad IDs, real payments, lifecycle, HTTPS iframe hosting and PNS/SSV in ONE store with issued ONEconsole credentials. Generic fixture and artifact smoke do not certify real-app commerce.
85
+
86
+ Performance evidence can be required with `mpgd game accept . --performance`.
87
+ Provide a game-owned `performance:e2e` script and `agent/performance.budget.json`;
88
+ collect a fresh foreground report at `MPGD_PERFORMANCE_REPORT_FILE`. Calibrate
89
+ per-device/scenario CPU and cadence budgets independently, link the tested
90
+ artifact/budget hashes and keep trace/screenshots in separate diagnostic runs.
91
+ See the [report contract](https://github.com/imjlk/mpgd-kit/blob/main/docs/GAME_PERFORMANCE_ACCEPTANCE.md).