@playdrop/playdrop-cli 0.14.0 → 0.14.2

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.
@@ -32,6 +32,9 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.resolveListingCaptureRouteSegment = resolveListingCaptureRouteSegment;
37
40
  exports.resolveCaptureListingDevRouterPort = resolveCaptureListingDevRouterPort;
@@ -49,6 +52,7 @@ exports.resolveListingCaptureBrowserContextOptions = resolveListingCaptureBrowse
49
52
  exports.assertExportedListingAudio = assertExportedListingAudio;
50
53
  exports.assertListingCaptureWindowCanContainViewport = assertListingCaptureWindowCanContainViewport;
51
54
  exports.computeRecordedCrop = computeRecordedCrop;
55
+ exports.assertListingCapturePixelMetrics = assertListingCapturePixelMetrics;
52
56
  exports.runListingRecorder = runListingRecorder;
53
57
  exports.formatCommandError = formatCommandError;
54
58
  exports.captureCommandMatchesExceptPoster = captureCommandMatchesExceptPoster;
@@ -59,6 +63,7 @@ const node_child_process_1 = require("node:child_process");
59
63
  const node_crypto_1 = require("node:crypto");
60
64
  const promises_1 = require("node:fs/promises");
61
65
  const node_path_1 = require("node:path");
66
+ const sharp_1 = __importDefault(require("sharp"));
62
67
  const appUrls_1 = require("../appUrls");
63
68
  const build_1 = require("../apps/build");
64
69
  const catalogue_1 = require("../catalogue");
@@ -96,6 +101,11 @@ const SURFACE_CAPTURE_DIMENSIONS = {
96
101
  MOBILE_LANDSCAPE: { width: 844, height: 390 },
97
102
  MOBILE_PORTRAIT: { width: 390, height: 844 },
98
103
  };
104
+ const MIN_LISTING_REFERENCE_FRAME_SIMILARITY = 0.25;
105
+ const MIN_LISTING_FRAME_MEAN_LUMA = 0.02;
106
+ const MIN_LISTING_VISIBLE_PIXEL_RATIO = 0.005;
107
+ const MIN_LISTING_MOTION_MEAN_DELTA = 0.002;
108
+ const MIN_LISTING_MOTION_CHANGED_PIXEL_RATIO = 0.01;
99
109
  const SURFACE_OUTPUT_SLUG = {
100
110
  DESKTOP: 'desktop',
101
111
  MOBILE_LANDSCAPE: 'mobile-landscape',
@@ -627,6 +637,125 @@ async function probeMediaFile(filePath, deadlineAt) {
627
637
  async function computeFileSha256(filePath) {
628
638
  return (0, node_crypto_1.createHash)('sha256').update(await (0, promises_1.readFile)(filePath)).digest('hex');
629
639
  }
640
+ async function readListingValidationPixels(filePath) {
641
+ return (0, sharp_1.default)(filePath)
642
+ .resize(64, 64, { fit: 'fill' })
643
+ .greyscale()
644
+ .raw()
645
+ .toBuffer();
646
+ }
647
+ async function measureListingFrameVisibility(filePath) {
648
+ const pixels = await readListingValidationPixels(filePath);
649
+ if (pixels.length === 0) {
650
+ throw new Error('listing_capture_frame_pixels_missing');
651
+ }
652
+ let total = 0;
653
+ let visiblePixels = 0;
654
+ for (const pixel of pixels) {
655
+ total += pixel;
656
+ if (pixel >= 24)
657
+ visiblePixels += 1;
658
+ }
659
+ return {
660
+ meanLuma: total / pixels.length / 255,
661
+ visiblePixelRatio: visiblePixels / pixels.length,
662
+ };
663
+ }
664
+ async function assertListingCaptureFrameVisible(surface, label, filePath) {
665
+ const visibility = await measureListingFrameVisibility(filePath);
666
+ if (visibility.meanLuma < MIN_LISTING_FRAME_MEAN_LUMA
667
+ && visibility.visiblePixelRatio < MIN_LISTING_VISIBLE_PIXEL_RATIO) {
668
+ throw new Error(`listing_capture_black_frame:${surface}:${label}`);
669
+ }
670
+ }
671
+ async function measureListingFrameMotion(firstPath, lastPath) {
672
+ const [first, last] = await Promise.all([
673
+ readListingValidationPixels(firstPath),
674
+ readListingValidationPixels(lastPath),
675
+ ]);
676
+ if (first.length !== last.length || first.length === 0) {
677
+ throw new Error('listing_capture_frame_shape_mismatch');
678
+ }
679
+ let deltaTotal = 0;
680
+ let changedPixels = 0;
681
+ for (let index = 0; index < first.length; index += 1) {
682
+ const delta = Math.abs(first[index] - last[index]);
683
+ deltaTotal += delta;
684
+ if (delta >= 16)
685
+ changedPixels += 1;
686
+ }
687
+ return {
688
+ meanDelta: deltaTotal / first.length / 255,
689
+ changedPixelRatio: changedPixels / first.length,
690
+ };
691
+ }
692
+ function calculateListingFrameSimilarity(referencePath, capturedPath, deadlineAt) {
693
+ const { stderr } = runTool('ffmpeg', [
694
+ '-hide_banner',
695
+ '-i',
696
+ referencePath,
697
+ '-i',
698
+ capturedPath,
699
+ '-filter_complex',
700
+ '[0:v]scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2,format=yuv420p[ref];[1:v]scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2,format=yuv420p[cap];[ref][cap]ssim',
701
+ '-f',
702
+ 'null',
703
+ '-',
704
+ ], 'ffmpeg_failed', deadlineAt);
705
+ const match = stderr.match(/All:([0-9.]+)/);
706
+ if (!match) {
707
+ throw new Error('listing_capture_similarity_missing');
708
+ }
709
+ return Number.parseFloat(match[1]);
710
+ }
711
+ function assertListingCapturePixelMetrics(surface, metrics) {
712
+ if (!Number.isFinite(metrics.referenceFrameSimilarity)
713
+ || metrics.referenceFrameSimilarity < MIN_LISTING_REFERENCE_FRAME_SIMILARITY) {
714
+ throw new Error(`listing_capture_wrong_game:${surface}:${Number.isFinite(metrics.referenceFrameSimilarity) ? metrics.referenceFrameSimilarity.toFixed(3) : 'unknown'}`);
715
+ }
716
+ if (metrics.firstFrameMeanLuma < MIN_LISTING_FRAME_MEAN_LUMA
717
+ && metrics.firstFrameVisiblePixelRatio < MIN_LISTING_VISIBLE_PIXEL_RATIO) {
718
+ throw new Error(`listing_capture_black_frame:${surface}:first`);
719
+ }
720
+ if (metrics.posterFrameMeanLuma < MIN_LISTING_FRAME_MEAN_LUMA
721
+ && metrics.posterFrameVisiblePixelRatio < MIN_LISTING_VISIBLE_PIXEL_RATIO) {
722
+ throw new Error(`listing_capture_black_frame:${surface}:poster`);
723
+ }
724
+ if (metrics.motionMeanDelta < MIN_LISTING_MOTION_MEAN_DELTA
725
+ && metrics.motionChangedPixelRatio < MIN_LISTING_MOTION_CHANGED_PIXEL_RATIO) {
726
+ throw new Error(`listing_capture_stale_video:${surface}`);
727
+ }
728
+ }
729
+ async function validateListingCapturePixels(input) {
730
+ const [firstVisibility, posterVisibility, motion] = await Promise.all([
731
+ measureListingFrameVisibility(input.firstFramePath),
732
+ measureListingFrameVisibility(input.posterPath),
733
+ measureListingFrameMotion(input.firstFramePath, input.lastFramePath),
734
+ ]);
735
+ const metrics = {
736
+ referenceFrameSimilarity: calculateListingFrameSimilarity(input.referencePath, input.firstFramePath, input.deadlineAt),
737
+ firstFrameMeanLuma: firstVisibility.meanLuma,
738
+ firstFrameVisiblePixelRatio: firstVisibility.visiblePixelRatio,
739
+ posterFrameMeanLuma: posterVisibility.meanLuma,
740
+ posterFrameVisiblePixelRatio: posterVisibility.visiblePixelRatio,
741
+ motionMeanDelta: motion.meanDelta,
742
+ motionChangedPixelRatio: motion.changedPixelRatio,
743
+ };
744
+ assertListingCapturePixelMetrics(input.surface, metrics);
745
+ return { status: 'passed', ...metrics };
746
+ }
747
+ function extractListingValidationFrame(videoPath, atSeconds, outputPath, deadlineAt) {
748
+ runTool('ffmpeg', [
749
+ '-y',
750
+ '-ss',
751
+ String(atSeconds),
752
+ '-i',
753
+ videoPath,
754
+ '-frames:v',
755
+ '1',
756
+ outputPath,
757
+ ], 'ffmpeg_failed', deadlineAt);
758
+ }
630
759
  function parseFrameRate(raw) {
631
760
  if (!raw) {
632
761
  return null;
@@ -741,6 +870,13 @@ function formatCommandError(error) {
741
870
  suggestions: ['Fix or remove the incomplete canonical capture directory, then record the runtime once.'],
742
871
  };
743
872
  }
873
+ if (error.message === 'listing_capture_report_pixels_unvalidated'
874
+ || error.message.startsWith('listing_capture_artifact_changed:')) {
875
+ return {
876
+ message: 'The existing canonical recording is unvalidated or its files changed after capture.',
877
+ suggestions: ['Remove the invalid capture directory and rerun "playdrop project capture" once.'],
878
+ };
879
+ }
744
880
  if (error.message.startsWith('listing_capture_dimensions_exceed_display:')) {
745
881
  const [, requested = 'unknown', available = 'unknown'] = error.message.split(':');
746
882
  return {
@@ -760,6 +896,24 @@ function formatCommandError(error) {
760
896
  suggestions: [],
761
897
  };
762
898
  }
899
+ if (error.message.startsWith('listing_capture_wrong_game:')) {
900
+ return {
901
+ message: 'The recorded pixels do not match the selected game preview.',
902
+ suggestions: ['Verify preview mode shows the selected game, then rerun "playdrop project capture".'],
903
+ };
904
+ }
905
+ if (error.message.startsWith('listing_capture_black_frame:')) {
906
+ return {
907
+ message: 'The gameplay recording contains a black first frame or poster.',
908
+ suggestions: ['Make preview mode visibly ready before capture starts, then rerun "playdrop project capture".'],
909
+ };
910
+ }
911
+ if (error.message.startsWith('listing_capture_stale_video:')) {
912
+ return {
913
+ message: 'The gameplay recording is visually static.',
914
+ suggestions: ['Make preview mode show real gameplay motion, then rerun "playdrop project capture".'],
915
+ };
916
+ }
763
917
  return {
764
918
  message: error.message,
765
919
  suggestions: [],
@@ -830,6 +984,9 @@ async function reuseCurrentListingCapture(input) {
830
984
  if (report.appName !== input.appName) {
831
985
  throw new Error(`listing_capture_report_app_mismatch:${report.appName}:${input.appName}`);
832
986
  }
987
+ if (report.binding?.pixelValidation !== 'passed') {
988
+ throw new Error('listing_capture_report_pixels_unvalidated');
989
+ }
833
990
  if (!input.runtimeBundleHash || report.binding?.runtimeBundleHash !== input.runtimeBundleHash) {
834
991
  return false;
835
992
  }
@@ -837,6 +994,15 @@ async function reuseCurrentListingCapture(input) {
837
994
  const captures = input.surfaces.map((surface) => report.captures.find((candidate) => candidate.surface === surface));
838
995
  for (const capture of captures) {
839
996
  await (0, promises_1.access)(capture.finalVideo.path);
997
+ await (0, promises_1.access)(capture.posterPath);
998
+ for (const artifactPath of [capture.finalVideo.path, capture.posterPath]) {
999
+ const artifactKey = (0, node_path_1.relative)(input.outputPaths.outputDir, artifactPath);
1000
+ const expectedHash = report.binding.artifactHashes[artifactKey];
1001
+ const actualHash = await computeFileSha256(artifactPath);
1002
+ if (!expectedHash || actualHash !== expectedHash) {
1003
+ throw new Error(`listing_capture_artifact_changed:${artifactKey}`);
1004
+ }
1005
+ }
840
1006
  }
841
1007
  if (action === 'reuse') {
842
1008
  console.log('[listing] The current runtime already has a matching canonical capture; reusing it.');
@@ -857,6 +1023,7 @@ async function reuseCurrentListingCapture(input) {
857
1023
  capture.command.posterAtSeconds = input.parsedOptions.posterAtSeconds;
858
1024
  const artifactKey = (0, node_path_1.relative)(input.outputPaths.outputDir, capture.posterPath) || 'poster.png';
859
1025
  report.binding.artifactHashes[artifactKey] = await computeFileSha256(capture.posterPath);
1026
+ await assertListingCaptureFrameVisible(capture.surface, 'poster', capture.posterPath);
860
1027
  }
861
1028
  await (0, promises_1.writeFile)(input.outputPaths.reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
862
1029
  console.log(`[listing] Refreshed the canonical poster at ${input.parsedOptions.posterAtSeconds}s without relaunching the game.`);
@@ -1206,6 +1373,12 @@ async function captureListing(targetArg, options = {}) {
1206
1373
  const dimensions = resolveCaptureDimensions(surface, parsedOptions);
1207
1374
  const captureSceneId = resolveListingCaptureSceneId(dimensions.width, dimensions.height);
1208
1375
  const surfaceOutputPaths = buildSurfaceOutputPaths(outputPaths.outputDir, surface, useSurfacePrefix);
1376
+ const validationPrefix = useSurfacePrefix ? `${surface.toLowerCase().replace(/_/g, '-')}-` : '';
1377
+ const validationPaths = {
1378
+ reference: (0, node_path_1.join)(outputPaths.outputDir, `.${validationPrefix}capture-reference.png`),
1379
+ first: (0, node_path_1.join)(outputPaths.outputDir, `.${validationPrefix}capture-first.png`),
1380
+ last: (0, node_path_1.join)(outputPaths.outputDir, `.${validationPrefix}capture-last.png`),
1381
+ };
1209
1382
  console.log(`[listing] Capturing ${surface} at ${dimensions.width}x${dimensions.height}.`);
1210
1383
  const contextOptions = resolveListingCaptureBrowserContextOptions(surface, dimensions);
1211
1384
  browserHandle = await launchListingBrowser(dimensions, frameUrlObject.origin, contextOptions);
@@ -1233,6 +1406,7 @@ async function captureListing(targetArg, options = {}) {
1233
1406
  });
1234
1407
  let recorderMetadata;
1235
1408
  try {
1409
+ await browserHandle.page.locator(CAPTURE_FRAME_SELECTOR).screenshot({ path: validationPaths.reference });
1236
1410
  recorderMetadata = await runListingRecorder(recorderPath, browserHandle.processId, parsedOptions.durationSeconds, surfaceOutputPaths.rawVideoPath, surfaceOutputPaths.metadataPath, shouldExportPreviewAudio ? false : parsedOptions.audio, captureDeadlineAt);
1237
1411
  }
1238
1412
  finally {
@@ -1262,6 +1436,16 @@ async function captureListing(targetArg, options = {}) {
1262
1436
  if (parsedOptions.audio && finalAudioTrackCount === 0) {
1263
1437
  throw new Error('audio_track_missing');
1264
1438
  }
1439
+ extractListingValidationFrame(surfaceOutputPaths.finalVideoPath, Math.min(0.1, finalDurationSeconds / 4), validationPaths.first, captureDeadlineAt);
1440
+ extractListingValidationFrame(surfaceOutputPaths.finalVideoPath, Math.max(0, finalDurationSeconds - 0.1), validationPaths.last, captureDeadlineAt);
1441
+ const validation = await validateListingCapturePixels({
1442
+ surface,
1443
+ referencePath: validationPaths.reference,
1444
+ firstFramePath: validationPaths.first,
1445
+ lastFramePath: validationPaths.last,
1446
+ posterPath: surfaceOutputPaths.posterPath,
1447
+ deadlineAt: captureDeadlineAt,
1448
+ });
1265
1449
  const warnings = createWarnings(finalProbe, parsedOptions.audio);
1266
1450
  for (const warning of warnings) {
1267
1451
  allWarnings.push(`${surface}: ${warning}`);
@@ -1300,6 +1484,7 @@ async function captureListing(targetArg, options = {}) {
1300
1484
  fps: finalFps,
1301
1485
  },
1302
1486
  posterPath: surfaceOutputPaths.posterPath,
1487
+ validation,
1303
1488
  warnings,
1304
1489
  });
1305
1490
  if (!parsedOptions.keepRaw) {
@@ -1314,6 +1499,7 @@ async function captureListing(targetArg, options = {}) {
1314
1499
  }
1315
1500
  }
1316
1501
  finally {
1502
+ await Promise.all(Object.values(validationPaths).map((filePath) => (0, promises_1.rm)(filePath, { force: true })));
1317
1503
  await browserHandle.close();
1318
1504
  browserHandle = null;
1319
1505
  }
@@ -1326,6 +1512,7 @@ async function captureListing(targetArg, options = {}) {
1326
1512
  binding: {
1327
1513
  artifactHashes,
1328
1514
  capturedSurfaces,
1515
+ pixelValidation: 'passed',
1329
1516
  runtimeBundleHash,
1330
1517
  },
1331
1518
  };
@@ -1,9 +1,19 @@
1
+ import { type AppSurface } from '@playdrop/types';
1
2
  type ProjectCheckOptions = {
2
3
  app?: string;
3
4
  timeout?: string | number;
4
5
  screenshot?: string;
5
- actions?: string;
6
6
  tape?: string;
7
7
  };
8
+ export declare function formatPlaytestCheckSuccess(input: {
9
+ creatorUsername: string;
10
+ appName: string;
11
+ surface: AppSurface;
12
+ actionCount: number;
13
+ meanDelta: number;
14
+ changedPixelRatio: number;
15
+ idleCapture: string;
16
+ tapeCapture: string;
17
+ }): string;
8
18
  export declare function check(targetArg: string | undefined, options?: ProjectCheckOptions): Promise<void>;
9
19
  export {};
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatPlaytestCheckSuccess = formatPlaytestCheckSuccess;
3
4
  exports.check = check;
4
5
  const node_fs_1 = require("node:fs");
5
6
  const node_path_1 = require("node:path");
@@ -42,14 +43,6 @@ function parseProjectCheckTimeoutMs(value) {
42
43
  }
43
44
  return Math.round(numeric * 1000);
44
45
  }
45
- function readProjectCheckActions(actionsPath) {
46
- const normalizedPath = actionsPath?.trim() ?? '';
47
- if (!normalizedPath) {
48
- return [];
49
- }
50
- const raw = (0, node_fs_1.readFileSync)(normalizedPath, 'utf8');
51
- return (0, loadCheck_1.normalizeProjectCheckActions)(JSON.parse(raw));
52
- }
53
46
  function defaultProjectCheckScreenshotPath(appName) {
54
47
  return (0, node_path_1.join)('assets', 'marketing', 'playdrop', 'check', `${appName}.png`);
55
48
  }
@@ -60,15 +53,18 @@ function buildComparisonScreenshotPath(basePath, run) {
60
53
  }
61
54
  return `${basePath.slice(0, -extension.length)}-${run}${extension}`;
62
55
  }
56
+ function formatPlaytestCheckSuccess(input) {
57
+ return `[check] Passed ${input.creatorUsername}/${input.appName} on ${input.surface}: input delivery ${input.actionCount}/${input.actionCount}, `
58
+ + `visible change mean=${input.meanDelta.toFixed(4)} changedPixels=${(input.changedPixelRatio * 100).toFixed(1)}%. `
59
+ + `Gameplay success was not assessed. Evidence: idle=${input.idleCapture}; tape=${input.tapeCapture}`;
60
+ }
63
61
  async function check(targetArg, options = {}) {
64
62
  let timeoutMs;
65
- let actions;
66
63
  try {
67
64
  timeoutMs = parseProjectCheckTimeoutMs(options.timeout);
68
- actions = readProjectCheckActions(options.actions);
69
65
  }
70
66
  catch (error) {
71
- (0, messages_1.printErrorWithHelp)(error?.message || 'Invalid project check options.', ['Use --actions with a JSON array of click, press, and wait actions.'], { command: 'project check' });
67
+ (0, messages_1.printErrorWithHelp)(error?.message || 'Invalid project check options.', ['Use --tape with DESKTOP, MOBILE_LANDSCAPE, or MOBILE_PORTRAIT.'], { command: 'project check' });
72
68
  process.exitCode = 1;
73
69
  return;
74
70
  }
@@ -96,11 +92,6 @@ async function check(targetArg, options = {}) {
96
92
  let tapeSurface = null;
97
93
  let playtestTape = null;
98
94
  if (options.tape) {
99
- if (actions.length > 0) {
100
- (0, messages_1.printErrorWithHelp)('--tape and --actions cannot be used together.', ['Use the catalogue playtest tape by itself so the timed sequence remains deterministic.'], { command: 'project check' });
101
- process.exitCode = 1;
102
- return;
103
- }
104
95
  tapeSurface = (0, types_1.parseAppSurface)(options.tape);
105
96
  if (!tapeSurface) {
106
97
  (0, messages_1.printErrorWithHelp)(`Invalid tape surface "${options.tape}".`, ['Use DESKTOP, MOBILE_LANDSCAPE, or MOBILE_PORTRAIT.'], { command: 'project check' });
@@ -192,18 +183,6 @@ async function check(targetArg, options = {}) {
192
183
  process.exitCode = 1;
193
184
  return;
194
185
  }
195
- const projectInfo = (0, devShared_1.findProjectInfo)(filePath);
196
- const devScriptAvailable = Boolean(projectInfo.projectDir
197
- && projectInfo.packageJson
198
- && typeof projectInfo.packageJson.scripts?.dev === 'string');
199
- const entryLabel = (0, node_path_1.relative)(process.cwd(), filePath) || filePath;
200
- console.log(`[check] Preparing ${entryLabel}.`);
201
- if (projectInfo.projectDir && !devScriptAvailable && projectInfo.packageJsonPath) {
202
- const projectLabel = (0, devShared_1.formatProjectLabel)(projectInfo);
203
- if (projectLabel) {
204
- console.log(`[check] package.json detected at ${projectLabel}, but no "dev" script was found. Run your app build manually if needed.`);
205
- }
206
- }
207
186
  const runCheck = async (input) => await (0, loadCheck_1.runLocalHostedLoadCheck)({
208
187
  client,
209
188
  apiBase: envConfig.apiBase,
@@ -215,7 +194,6 @@ async function check(targetArg, options = {}) {
215
194
  currentUser: null,
216
195
  captureSession,
217
196
  screenshotPath: input.screenshotPath,
218
- actions,
219
197
  surface: input.surface,
220
198
  playtestTape: input.tape,
221
199
  postReadyWaitMs: input.postReadyWaitMs,
@@ -223,7 +201,6 @@ async function check(targetArg, options = {}) {
223
201
  if (tapeSurface && playtestTape) {
224
202
  const idleScreenshotPath = buildComparisonScreenshotPath(screenshotPath, 'idle');
225
203
  const tapeScreenshotPath = buildComparisonScreenshotPath(screenshotPath, 'tape');
226
- console.log(`[check] Running ${playtestTape.durationMs} ms zero-input baseline on ${tapeSurface}.`);
227
204
  const idleResult = await runCheck({
228
205
  screenshotPath: idleScreenshotPath,
229
206
  surface: tapeSurface,
@@ -234,7 +211,6 @@ async function check(targetArg, options = {}) {
234
211
  process.exitCode = 1;
235
212
  return;
236
213
  }
237
- console.log(`[check] Reopening a clean run and executing the ${tapeSurface} tape.`);
238
214
  const tapeResult = await runCheck({
239
215
  screenshotPath: tapeScreenshotPath,
240
216
  surface: tapeSurface,
@@ -258,10 +234,16 @@ async function check(targetArg, options = {}) {
258
234
  process.exitCode = 1;
259
235
  return;
260
236
  }
261
- console.log(`[check] Playtest smoke check passed for ${currentUsername}/${appName}.`);
262
- console.log(`[check] ${playtestTape.events.length} tape actions completed without a crash and produced a visible response.`);
263
- console.log(`[check] Idle: ${idleCapture}`);
264
- console.log(`[check] Tape: ${tapeCapture}`);
237
+ console.log(formatPlaytestCheckSuccess({
238
+ creatorUsername: currentUsername,
239
+ appName,
240
+ surface: tapeSurface,
241
+ actionCount: playtestTape.events.length,
242
+ meanDelta: comparison.meanDelta,
243
+ changedPixelRatio: comparison.changedPixelRatio,
244
+ idleCapture,
245
+ tapeCapture,
246
+ }));
265
247
  return;
266
248
  }
267
249
  const result = await runCheck({ screenshotPath });
@@ -270,9 +252,7 @@ async function check(targetArg, options = {}) {
270
252
  process.exitCode = 1;
271
253
  return;
272
254
  }
273
- console.log(`[check] Passed ${currentUsername}/${appName}.`);
274
- if (result.webglRenderer) {
275
- console.log(`[check] Hardware renderer: ${result.webglRenderer}`);
276
- }
255
+ const checkedSurface = (0, loadCheck_1.resolveLoadCheckSurface)(taskLookup.task.surfaceTargets, taskLookup.task.primarySurface);
256
+ console.log(`[check] Passed ${currentUsername}/${appName} on ${checkedSurface}: launch ready${result.webglRenderer ? ` with hardware renderer ${result.webglRenderer}` : ''}. Gameplay success was not assessed. Screenshot=${result.screenshotPath ?? screenshotPath}`);
277
257
  }, { workspacePath });
278
258
  }
@@ -72,9 +72,34 @@ function decorateAsset(apiBase, ref, asset) {
72
72
  },
73
73
  };
74
74
  }
75
- function decorateAssetPack(apiBase, ref, pack) {
75
+ function decorateAssetPackMember(asset) {
76
+ const revisionLabel = asset.currentVersion?.revisionLabel?.trim();
77
+ if (!revisionLabel) {
78
+ throw new Error(`asset_pack_member_current_revision_missing:${asset.creatorUsername}/${asset.name}`);
79
+ }
80
+ const files = asset.currentVersion?.fileManifest?.files;
81
+ return {
82
+ assetRef: `asset:${asset.creatorUsername}/${asset.name}@${revisionLabel}`,
83
+ name: asset.name,
84
+ displayName: asset.displayName,
85
+ category: asset.category,
86
+ subcategory: asset.currentVersion?.subcategory ?? null,
87
+ format: asset.currentVersion?.format ?? null,
88
+ files: Array.isArray(files)
89
+ ? files
90
+ .filter((file) => typeof file?.role === 'string' && typeof file?.key === 'string')
91
+ .map((file) => ({
92
+ role: file.role,
93
+ key: file.key,
94
+ contentType: typeof file.contentType === 'string' ? file.contentType : null,
95
+ }))
96
+ : [],
97
+ };
98
+ }
99
+ function decorateAssetPack(apiBase, ref, pack, assets) {
76
100
  return {
77
101
  ...pack,
102
+ members: assets.map(decorateAssetPackMember),
78
103
  urls: {
79
104
  download: pack.currentVersion?.version ? buildAssetPackDownloadUrl(apiBase, ref, pack.currentVersion.version) : null,
80
105
  source: pack.currentVersion?.version ? buildAssetPackSourceUrl(apiBase, ref, pack.currentVersion.version) : null,
@@ -154,6 +179,14 @@ function printAssetPackText(ref, pack) {
154
179
  console.log(`Updated: ${(0, output_1.formatTimestamp)(pack.updatedAt)}`);
155
180
  console.log(`Current version: ${(0, output_1.formatOptionalValue)(pack.currentVersion?.version)}`);
156
181
  console.log(`Tags: ${formatTagSummary(pack.tags)}`);
182
+ console.log(`Pack members: ${pack.members.length}`);
183
+ for (const member of pack.members) {
184
+ const classification = [member.category, member.subcategory, member.format].filter(Boolean).join('/');
185
+ const files = member.files.length > 0
186
+ ? member.files.map((file) => `${file.role}:${file.contentType ?? file.key}`).join(', ')
187
+ : 'none';
188
+ console.log(`- assetRef=${member.assetRef} | ${member.displayName} | ${classification} | files=${files}`);
189
+ }
157
190
  console.log(`Download URL: ${(0, output_1.formatOptionalValue)(pack.urls.download)}`);
158
191
  console.log(`Source URL: ${(0, output_1.formatOptionalValue)(pack.urls.source)}`);
159
192
  console.log('\nNext: run "playdrop versions browse ' + ref + '" to inspect other versions.');
@@ -205,7 +238,7 @@ async function detail(rawRef, options = {}) {
205
238
  }
206
239
  if (ref.kind === 'asset-pack') {
207
240
  const response = await client.fetchAssetPackBySlug(ref.creator, ref.name);
208
- const item = decorateAssetPack(envConfig.apiBase, ref, response.pack);
241
+ const item = decorateAssetPack(envConfig.apiBase, ref, response.pack, response.assets);
209
242
  if (options.json) {
210
243
  (0, output_1.printJson)({ kind: ref.kind, ref: ref.ref, item });
211
244
  return;
@@ -1,4 +1,13 @@
1
- export declare const REVIEW_CRITERIA: readonly ["Gameplay / Core Loop", "Depth / Replayability", "Controls / Input", "UX / Usability", "First Time User Experience", "Visuals / Art Direction", "Audio / Feedback", "Store Listing & Metadata Accuracy", "Safety / Age Rating / Compliance", "Performance / Stability"];
1
+ export declare const REVIEW_CRITERIA: readonly ["Gameplay / Core Loop", "Depth / Replayability", "Controls / Input", "UX / Usability", "First Time User Experience", "Visuals / Art Direction", "Store Listing & Metadata Accuracy", "Safety / Age Rating / Compliance", "Performance / Stability"];
2
+ declare const STATE_OUTCOME: {
3
+ readonly EXCELLENT: "Excellent";
4
+ readonly FAILED: "Blocked";
5
+ readonly GOOD: "Good";
6
+ readonly LOW_QUALITY: "Limited";
7
+ readonly PASSED: "Passed";
8
+ };
9
+ export type TerminalReviewState = keyof typeof STATE_OUTCOME;
10
+ export declare function normalizeTerminalReviewState(value: string | undefined): TerminalReviewState;
2
11
  export declare const REQUIRED_REVIEW_EVIDENCE_FILES: string[];
3
12
  export type ValidateGameReviewResultInput = {
4
13
  creatorFeedback?: string;
@@ -44,3 +53,4 @@ export declare function createReviewRatingCard(options: ReviewRatingCardOptions)
44
53
  width: number;
45
54
  height: number;
46
55
  }>;
56
+ export {};
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.REQUIRED_REVIEW_EVIDENCE_FILES = exports.REVIEW_CRITERIA = void 0;
7
+ exports.normalizeTerminalReviewState = normalizeTerminalReviewState;
7
8
  exports.validateGameReviewResult = validateGameReviewResult;
8
9
  exports.validateReviewResultCommand = validateReviewResultCommand;
9
10
  exports.composeReviewEvidence = composeReviewEvidence;
@@ -19,18 +20,10 @@ exports.REVIEW_CRITERIA = [
19
20
  'UX / Usability',
20
21
  'First Time User Experience',
21
22
  'Visuals / Art Direction',
22
- 'Audio / Feedback',
23
23
  'Store Listing & Metadata Accuracy',
24
24
  'Safety / Age Rating / Compliance',
25
25
  'Performance / Stability',
26
26
  ];
27
- const TERMINAL_REVIEW_STATES = new Set([
28
- 'FAILED',
29
- 'LOW_QUALITY',
30
- 'PASSED',
31
- 'GOOD',
32
- 'EXCELLENT',
33
- ]);
34
27
  const STATE_OUTCOME = {
35
28
  EXCELLENT: 'Excellent',
36
29
  FAILED: 'Blocked',
@@ -38,6 +31,23 @@ const STATE_OUTCOME = {
38
31
  LOW_QUALITY: 'Limited',
39
32
  PASSED: 'Passed',
40
33
  };
34
+ const REVIEW_STATE_ALIASES = {
35
+ BLOCKED: 'FAILED',
36
+ EXCELLENT: 'EXCELLENT',
37
+ FAILED: 'FAILED',
38
+ GOOD: 'GOOD',
39
+ LIMITED: 'LOW_QUALITY',
40
+ LOW_QUALITY: 'LOW_QUALITY',
41
+ PASSED: 'PASSED',
42
+ };
43
+ function normalizeTerminalReviewState(value) {
44
+ const input = typeof value === 'string' ? value.trim() : '';
45
+ const state = REVIEW_STATE_ALIASES[input.toUpperCase()];
46
+ if (!state) {
47
+ throw new Error(`invalid_review_state:${input || 'missing'}:expected=Blocked|Limited|Passed|Good|Excellent`);
48
+ }
49
+ return state;
50
+ }
41
51
  exports.REQUIRED_REVIEW_EVIDENCE_FILES = [
42
52
  'first-frame.png',
43
53
  'core.png',
@@ -135,7 +145,7 @@ function validateRequiredEvidenceLines(reviewMessage) {
135
145
  throw new Error('challenge_evidence_too_vague');
136
146
  }
137
147
  if (punchline.length < 8 || punchline.length > 160) {
138
- throw new Error('punchline_assessment_invalid_length');
148
+ throw new Error(`punchline_assessment_invalid_length: Punchline assessment length=${punchline.length}; expected 8..160 characters.`);
139
149
  }
140
150
  if (benchmark.length < 8) {
141
151
  throw new Error('comparable_benchmark_too_vague');
@@ -169,10 +179,7 @@ async function assertPngFile(filePath) {
169
179
  }
170
180
  }
171
181
  async function validateGameReviewResult(input) {
172
- const normalizedState = String(input.reviewState || '').trim().toUpperCase();
173
- if (!TERMINAL_REVIEW_STATES.has(normalizedState)) {
174
- return { skipped: true, reason: 'non_terminal_state' };
175
- }
182
+ const normalizedState = normalizeTerminalReviewState(input.reviewState);
176
183
  if (!input.reviewMessage.trim()) {
177
184
  throw new Error('missing_review_message');
178
185
  }
@@ -21,8 +21,6 @@ export type WorkerAppPublishInput = {
21
21
  expectedSubtitle?: string | null;
22
22
  expectedPrimarySurface?: AppSurface | null;
23
23
  remixSourceRef?: string | null;
24
- playdropAssetRequirement?: WorkerPlaydropAssetRequirement | null;
25
- creatorRequest?: string | null;
26
24
  projectDir: string;
27
25
  creatorUsername: string;
28
26
  apiBase: string;
@@ -62,16 +60,12 @@ export type StaticHtmlPublishResult = WorkerAppPublishResult & {
62
60
  runtimeContainsInjectedSdk: true;
63
61
  hostedLoadCheckPassed: true;
64
62
  };
65
- export type WorkerPlaydropAssetRequirement = 'PACK' | 'ASSET_OR_PACK';
66
- export declare function resolveWorkerPlaydropAssetRequirement(value: unknown): WorkerPlaydropAssetRequirement | null;
63
+ export declare function assertAgentPrimarySurfaceContract(task: AppTask, kind: 'NEW_GAME' | 'REMIX_GAME' | 'GAME_UPDATE'): void;
67
64
  export declare function assertAgentNewGamePlaytestContract(task: AppTask): void;
68
65
  export declare function assertWorkerAppLocalUploadPreflight(input: {
69
66
  task: AppTask;
70
67
  kind: 'NEW_GAME' | 'REMIX_GAME' | 'GAME_UPDATE';
71
68
  executionTarget?: AgentExecutionTarget;
72
- creatorUsername?: string | null;
73
- playdropAssetRequirement?: WorkerPlaydropAssetRequirement | null;
74
- creatorRequest?: string | null;
75
69
  stage?: 'PRE_CAPTURE' | 'COMPLETE';
76
70
  expectedRuntimeBundleHash?: string;
77
71
  }): Promise<void>;