@playdrop/playdrop-cli 0.13.17 → 0.14.1

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;
@@ -47,8 +50,11 @@ exports.resolveWorkerCaptureOutputDir = resolveWorkerCaptureOutputDir;
47
50
  exports.resolveCaptureDimensions = resolveCaptureDimensions;
48
51
  exports.resolveListingCaptureBrowserContextOptions = resolveListingCaptureBrowserContextOptions;
49
52
  exports.assertExportedListingAudio = assertExportedListingAudio;
53
+ exports.assertListingCaptureWindowCanContainViewport = assertListingCaptureWindowCanContainViewport;
50
54
  exports.computeRecordedCrop = computeRecordedCrop;
55
+ exports.assertListingCapturePixelMetrics = assertListingCapturePixelMetrics;
51
56
  exports.runListingRecorder = runListingRecorder;
57
+ exports.formatCommandError = formatCommandError;
52
58
  exports.captureCommandMatchesExceptPoster = captureCommandMatchesExceptPoster;
53
59
  exports.resolveCurrentCaptureReuseAction = resolveCurrentCaptureReuseAction;
54
60
  exports.captureListing = captureListing;
@@ -57,6 +63,7 @@ const node_child_process_1 = require("node:child_process");
57
63
  const node_crypto_1 = require("node:crypto");
58
64
  const promises_1 = require("node:fs/promises");
59
65
  const node_path_1 = require("node:path");
66
+ const sharp_1 = __importDefault(require("sharp"));
60
67
  const appUrls_1 = require("../appUrls");
61
68
  const build_1 = require("../apps/build");
62
69
  const catalogue_1 = require("../catalogue");
@@ -94,6 +101,11 @@ const SURFACE_CAPTURE_DIMENSIONS = {
94
101
  MOBILE_LANDSCAPE: { width: 844, height: 390 },
95
102
  MOBILE_PORTRAIT: { width: 390, height: 844 },
96
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;
97
109
  const SURFACE_OUTPUT_SLUG = {
98
110
  DESKTOP: 'desktop',
99
111
  MOBILE_LANDSCAPE: 'mobile-landscape',
@@ -501,11 +513,6 @@ async function fitWindowToRequestedGameplay(page, requestedWidth, requestedHeigh
501
513
  const cdpSession = await page.context().newCDPSession(page);
502
514
  let measurement = await waitForHostedGameMeasurement(page, 15000);
503
515
  for (let attempt = 0; attempt < 5; attempt += 1) {
504
- const deltaWidth = requestedWidth - Math.round(measurement.iframeRect.width);
505
- const deltaHeight = requestedHeight - Math.round(measurement.iframeRect.height);
506
- if (Math.abs(deltaWidth) <= 1 && Math.abs(deltaHeight) <= 1) {
507
- return measurement;
508
- }
509
516
  const windowState = await cdpSession.send('Browser.getWindowForTarget');
510
517
  const currentWidth = typeof windowState.bounds.width === 'number'
511
518
  ? windowState.bounds.width
@@ -513,6 +520,15 @@ async function fitWindowToRequestedGameplay(page, requestedWidth, requestedHeigh
513
520
  const currentHeight = typeof windowState.bounds.height === 'number'
514
521
  ? windowState.bounds.height
515
522
  : Math.round(measurement.outerHeight);
523
+ const deltaWidth = requestedWidth - Math.round(measurement.iframeRect.width);
524
+ const deltaHeight = requestedHeight - Math.round(measurement.iframeRect.height);
525
+ if (Math.abs(deltaWidth) <= 1 && Math.abs(deltaHeight) <= 1) {
526
+ assertListingCaptureWindowCanContainViewport(measurement, {
527
+ width: currentWidth,
528
+ height: currentHeight,
529
+ });
530
+ return measurement;
531
+ }
516
532
  const nextWidth = Math.max(400, currentWidth + deltaWidth);
517
533
  const nextHeight = Math.max(300, currentHeight + deltaHeight);
518
534
  await cdpSession.send('Browser.setWindowBounds', {
@@ -527,6 +543,15 @@ async function fitWindowToRequestedGameplay(page, requestedWidth, requestedHeigh
527
543
  }
528
544
  throw new Error('hosted_game_resize_failed');
529
545
  }
546
+ function assertListingCaptureWindowCanContainViewport(measurement, windowBounds) {
547
+ const requiredWidth = Math.ceil(measurement.innerWidth);
548
+ const requiredHeight = Math.ceil(measurement.innerHeight);
549
+ const availableWidth = Math.floor(windowBounds.width);
550
+ const availableHeight = Math.floor(windowBounds.height);
551
+ if (availableWidth + 1 < requiredWidth || availableHeight + 1 < requiredHeight) {
552
+ throw new Error(`listing_capture_dimensions_exceed_display:${requiredWidth}x${requiredHeight}:${availableWidth}x${availableHeight}`);
553
+ }
554
+ }
530
555
  function roundCropValue(value) {
531
556
  return Math.round(value);
532
557
  }
@@ -612,6 +637,125 @@ async function probeMediaFile(filePath, deadlineAt) {
612
637
  async function computeFileSha256(filePath) {
613
638
  return (0, node_crypto_1.createHash)('sha256').update(await (0, promises_1.readFile)(filePath)).digest('hex');
614
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
+ }
615
759
  function parseFrameRate(raw) {
616
760
  if (!raw) {
617
761
  return null;
@@ -726,6 +870,20 @@ function formatCommandError(error) {
726
870
  suggestions: ['Fix or remove the incomplete canonical capture directory, then record the runtime once.'],
727
871
  };
728
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
+ }
880
+ if (error.message.startsWith('listing_capture_dimensions_exceed_display:')) {
881
+ const [, requested = 'unknown', available = 'unknown'] = error.message.split(':');
882
+ return {
883
+ message: `Requested capture size ${requested} cannot fit in this worker's ${available} browser window.`,
884
+ suggestions: ['Omit --width and --height to use the safe surface defaults, or request dimensions that fit this display.'],
885
+ };
886
+ }
729
887
  if (error.message.startsWith('listing_recorder_failed:')) {
730
888
  return {
731
889
  message: error.message.slice('listing_recorder_failed:'.length),
@@ -738,6 +896,24 @@ function formatCommandError(error) {
738
896
  suggestions: [],
739
897
  };
740
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
+ }
741
917
  return {
742
918
  message: error.message,
743
919
  suggestions: [],
@@ -808,6 +984,9 @@ async function reuseCurrentListingCapture(input) {
808
984
  if (report.appName !== input.appName) {
809
985
  throw new Error(`listing_capture_report_app_mismatch:${report.appName}:${input.appName}`);
810
986
  }
987
+ if (report.binding?.pixelValidation !== 'passed') {
988
+ throw new Error('listing_capture_report_pixels_unvalidated');
989
+ }
811
990
  if (!input.runtimeBundleHash || report.binding?.runtimeBundleHash !== input.runtimeBundleHash) {
812
991
  return false;
813
992
  }
@@ -815,6 +994,15 @@ async function reuseCurrentListingCapture(input) {
815
994
  const captures = input.surfaces.map((surface) => report.captures.find((candidate) => candidate.surface === surface));
816
995
  for (const capture of captures) {
817
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
+ }
818
1006
  }
819
1007
  if (action === 'reuse') {
820
1008
  console.log('[listing] The current runtime already has a matching canonical capture; reusing it.');
@@ -835,6 +1023,7 @@ async function reuseCurrentListingCapture(input) {
835
1023
  capture.command.posterAtSeconds = input.parsedOptions.posterAtSeconds;
836
1024
  const artifactKey = (0, node_path_1.relative)(input.outputPaths.outputDir, capture.posterPath) || 'poster.png';
837
1025
  report.binding.artifactHashes[artifactKey] = await computeFileSha256(capture.posterPath);
1026
+ await assertListingCaptureFrameVisible(capture.surface, 'poster', capture.posterPath);
838
1027
  }
839
1028
  await (0, promises_1.writeFile)(input.outputPaths.reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
840
1029
  console.log(`[listing] Refreshed the canonical poster at ${input.parsedOptions.posterAtSeconds}s without relaunching the game.`);
@@ -1184,6 +1373,12 @@ async function captureListing(targetArg, options = {}) {
1184
1373
  const dimensions = resolveCaptureDimensions(surface, parsedOptions);
1185
1374
  const captureSceneId = resolveListingCaptureSceneId(dimensions.width, dimensions.height);
1186
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
+ };
1187
1382
  console.log(`[listing] Capturing ${surface} at ${dimensions.width}x${dimensions.height}.`);
1188
1383
  const contextOptions = resolveListingCaptureBrowserContextOptions(surface, dimensions);
1189
1384
  browserHandle = await launchListingBrowser(dimensions, frameUrlObject.origin, contextOptions);
@@ -1195,14 +1390,14 @@ async function captureListing(targetArg, options = {}) {
1195
1390
  await prepareHostedListingScene(browserHandle.page, captureSceneId);
1196
1391
  await browserHandle.page.waitForTimeout(1000);
1197
1392
  }
1393
+ const measurement = await fitWindowToRequestedGameplay(browserHandle.page, dimensions.width, dimensions.height);
1394
+ console.log(`[listing] Gameplay frame ${Math.round(measurement.iframeRect.width)}x${Math.round(measurement.iframeRect.height)} in window ${Math.round(measurement.outerWidth)}x${Math.round(measurement.outerHeight)}.`);
1198
1395
  if (shouldExportPreviewAudio) {
1199
1396
  console.log('[listing] Checking the in-app preview audio export contract.');
1200
1397
  await assertHostedListingAudioCaptureContract(browserHandle.page, outputPaths.outputDir);
1201
1398
  console.log('[listing] Starting in-app preview audio capture.');
1202
1399
  await startHostedListingAudioCapture(browserHandle.page);
1203
1400
  }
1204
- const measurement = await fitWindowToRequestedGameplay(browserHandle.page, dimensions.width, dimensions.height);
1205
- console.log(`[listing] Gameplay frame ${Math.round(measurement.iframeRect.width)}x${Math.round(measurement.iframeRect.height)} in window ${Math.round(measurement.outerWidth)}x${Math.round(measurement.outerHeight)}.`);
1206
1401
  await browserHandle.page.waitForTimeout(750);
1207
1402
  console.log('[listing] Waiting for the machine capture lease.');
1208
1403
  const captureLease = await (0, listingCaptureLease_1.acquireListingCaptureLease)({
@@ -1211,6 +1406,7 @@ async function captureListing(targetArg, options = {}) {
1211
1406
  });
1212
1407
  let recorderMetadata;
1213
1408
  try {
1409
+ await browserHandle.page.locator(CAPTURE_FRAME_SELECTOR).screenshot({ path: validationPaths.reference });
1214
1410
  recorderMetadata = await runListingRecorder(recorderPath, browserHandle.processId, parsedOptions.durationSeconds, surfaceOutputPaths.rawVideoPath, surfaceOutputPaths.metadataPath, shouldExportPreviewAudio ? false : parsedOptions.audio, captureDeadlineAt);
1215
1411
  }
1216
1412
  finally {
@@ -1240,6 +1436,16 @@ async function captureListing(targetArg, options = {}) {
1240
1436
  if (parsedOptions.audio && finalAudioTrackCount === 0) {
1241
1437
  throw new Error('audio_track_missing');
1242
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
+ });
1243
1449
  const warnings = createWarnings(finalProbe, parsedOptions.audio);
1244
1450
  for (const warning of warnings) {
1245
1451
  allWarnings.push(`${surface}: ${warning}`);
@@ -1278,6 +1484,7 @@ async function captureListing(targetArg, options = {}) {
1278
1484
  fps: finalFps,
1279
1485
  },
1280
1486
  posterPath: surfaceOutputPaths.posterPath,
1487
+ validation,
1281
1488
  warnings,
1282
1489
  });
1283
1490
  if (!parsedOptions.keepRaw) {
@@ -1292,6 +1499,7 @@ async function captureListing(targetArg, options = {}) {
1292
1499
  }
1293
1500
  }
1294
1501
  finally {
1502
+ await Promise.all(Object.values(validationPaths).map((filePath) => (0, promises_1.rm)(filePath, { force: true })));
1295
1503
  await browserHandle.close();
1296
1504
  browserHandle = null;
1297
1505
  }
@@ -1304,6 +1512,7 @@ async function captureListing(targetArg, options = {}) {
1304
1512
  binding: {
1305
1513
  artifactHashes,
1306
1514
  capturedSurfaces,
1515
+ pixelValidation: 'passed',
1307
1516
  runtimeBundleHash,
1308
1517
  },
1309
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
  }