@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.
- package/config/client-meta.json +2 -2
- package/dist/apps/loadCheck.d.ts +2 -21
- package/dist/apps/loadCheck.js +22 -106
- package/dist/apps/surfaceProfiles.d.ts +1 -0
- package/dist/apps/surfaceProfiles.js +5 -4
- package/dist/apps/validate.d.ts +1 -1
- package/dist/apps/validate.js +2 -420
- package/dist/catalogue.js +32 -1
- package/dist/commands/captureListing.d.ts +22 -0
- package/dist/commands/captureListing.js +216 -7
- package/dist/commands/check.d.ts +11 -1
- package/dist/commands/check.js +19 -39
- package/dist/commands/detail.js +35 -2
- package/dist/commands/review.d.ts +11 -1
- package/dist/commands/review.js +20 -13
- package/dist/commands/upload.d.ts +1 -7
- package/dist/commands/upload.js +20 -462
- package/dist/commands/validate.js +0 -3
- package/dist/commands/worker.d.ts +1 -3
- package/dist/commands/worker.js +43 -39
- package/dist/index.js +2 -4
- package/dist/listingPreflight.d.ts +3 -0
- package/dist/listingPreflight.js +48 -24
- package/node_modules/@playdrop/config/client-meta.json +2 -2
- package/node_modules/@playdrop/types/dist/app-playtest-tape.d.ts.map +1 -1
- package/node_modules/@playdrop/types/dist/app-playtest-tape.js +23 -15
- package/package.json +1 -1
|
@@ -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
|
};
|
package/dist/commands/check.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/check.js
CHANGED
|
@@ -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 --
|
|
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(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
}
|
package/dist/commands/detail.js
CHANGED
|
@@ -72,9 +72,34 @@ function decorateAsset(apiBase, ref, asset) {
|
|
|
72
72
|
},
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
function
|
|
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", "
|
|
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 {};
|
package/dist/commands/review.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
}
|