@playdrop/playdrop-cli 0.17.5 → 0.17.15

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.
Files changed (72) hide show
  1. package/README.md +12 -0
  2. package/config/client-meta.json +2 -2
  3. package/dist/apps/build.d.ts +2 -1
  4. package/dist/apps/build.js +6 -2
  5. package/dist/apps/index.d.ts +2 -3
  6. package/dist/apps/index.js +17 -49
  7. package/dist/apps/loadCheck.d.ts +3 -1
  8. package/dist/apps/loadCheck.js +123 -64
  9. package/dist/apps/publishingRuntime.d.ts +18 -0
  10. package/dist/apps/publishingRuntime.js +111 -0
  11. package/dist/apps/runtimeSyntax.d.ts +7 -0
  12. package/dist/apps/runtimeSyntax.js +154 -0
  13. package/dist/apps/serverBuild.d.ts +3 -3
  14. package/dist/apps/serverBuild.js +8 -43
  15. package/dist/apps/serverSdk.d.ts +23 -0
  16. package/dist/apps/serverSdk.js +157 -0
  17. package/dist/apps/upload.d.ts +9 -3
  18. package/dist/apps/upload.js +71 -25
  19. package/dist/apps/validate.d.ts +2 -1
  20. package/dist/apps/validate.js +3 -1
  21. package/dist/commandContext.js +34 -10
  22. package/dist/commands/build.js +3 -1
  23. package/dist/commands/create.js +35 -16
  24. package/dist/commands/dev.js +11 -0
  25. package/dist/commands/devGameServer.d.ts +8 -0
  26. package/dist/commands/devGameServer.js +12 -9
  27. package/dist/commands/devServer.d.ts +1 -0
  28. package/dist/commands/devServer.js +21 -3
  29. package/dist/commands/source.d.ts +11 -3
  30. package/dist/commands/source.js +82 -14
  31. package/dist/commands/taskCaptureSession.d.ts +1 -1
  32. package/dist/commands/taskCaptureSession.js +1 -1
  33. package/dist/commands/upload-content.js +13 -12
  34. package/dist/commands/upload.d.ts +2 -9
  35. package/dist/commands/upload.js +5 -21
  36. package/dist/commands/validate.js +1 -1
  37. package/dist/commands/worker/game-server-infra.d.ts +6 -0
  38. package/dist/commands/worker/game-server-infra.js +10 -0
  39. package/dist/commands/worker/game-source-git.d.ts +9 -1
  40. package/dist/commands/worker/game-source-git.js +65 -19
  41. package/dist/commands/worker/generation.d.ts +2 -0
  42. package/dist/commands/worker/generation.js +35 -5
  43. package/dist/commands/worker.d.ts +1 -1
  44. package/dist/commands/worker.js +57 -17
  45. package/dist/environment.d.ts +1 -0
  46. package/dist/environment.js +7 -0
  47. package/dist/index.js +10 -2
  48. package/dist/playwright.d.ts +6 -4
  49. package/dist/playwright.js +24 -9
  50. package/dist/proceduralValidation.d.ts +16 -0
  51. package/dist/proceduralValidation.js +227 -0
  52. package/dist/proceduralValidationBrowser.d.mts +3 -0
  53. package/dist/proceduralValidationBrowser.mjs +67 -0
  54. package/node_modules/@playdrop/api-client/dist/domains/apps.d.ts.map +1 -1
  55. package/node_modules/@playdrop/api-client/dist/domains/apps.js +11 -1
  56. package/node_modules/@playdrop/api-client/dist/domains/chat.d.ts +6 -1
  57. package/node_modules/@playdrop/api-client/dist/domains/chat.d.ts.map +1 -1
  58. package/node_modules/@playdrop/api-client/dist/domains/chat.js +13 -1
  59. package/node_modules/@playdrop/config/client-meta.json +2 -2
  60. package/node_modules/@playdrop/procedural/dist/index.d.ts +201 -0
  61. package/node_modules/@playdrop/procedural/dist/index.d.ts.map +1 -0
  62. package/node_modules/@playdrop/procedural/dist/index.js +763 -0
  63. package/node_modules/@playdrop/procedural/package.json +36 -0
  64. package/node_modules/@playdrop/types/dist/api.d.ts +37 -4
  65. package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
  66. package/node_modules/@playdrop/types/dist/chat.d.ts +7 -1
  67. package/node_modules/@playdrop/types/dist/chat.d.ts.map +1 -1
  68. package/node_modules/@playdrop/types/dist/social.d.ts +20 -0
  69. package/node_modules/@playdrop/types/dist/social.d.ts.map +1 -1
  70. package/node_modules/@playdrop/types/dist/version.d.ts +2 -0
  71. package/node_modules/@playdrop/types/dist/version.d.ts.map +1 -1
  72. package/package.json +7 -4
package/README.md CHANGED
@@ -123,6 +123,18 @@ playdrop project dev my-app
123
123
  playdrop project publish --env dev .
124
124
  ```
125
125
 
126
+ ## Game server SDK
127
+
128
+ For a game with a `server` declaration, `project build`, `project check`, `project dev`, and upload preflight select the SDK reported by the environment's rooms runtime. The CLI verifies and downloads `playdrop-server-<version>.tgz` from the SDK CDN, installs its matching dependencies, and records them in `package.json`, `package-lock.json`, and `vendor/playdrop/`. Keep these files with the project. There is no npm registry release of `@playdrop/server` to install manually.
129
+
130
+ SDK selection is fixed for one CLI invocation. Start a new invocation to pick up a platform SDK release. Missing runtime metadata, unavailable artifacts, or integrity mismatches fail explicitly. Production uses the SDK included in the deployed rooms runtime; the SDK release lane publishes the matching client and server artifacts before promoting them.
131
+
132
+ Use Node.js 22 or later. Mixed client/server TypeScript projects should include both `src` and `server`, use `lib: ["ES2022", "DOM", "DOM.Iterable"]`, and retain `strict: true`. Set `skipLibCheck: true` for the Colyseus browser SDK's upstream declaration errors; game code remains checked. The CLI does not rewrite your TypeScript configuration.
133
+
134
+ Cloud and direct first-party uploads run the same checks against the staged files before publishing. Declared playtest tapes run too. Server games run the exact prepared server bundle on a private temporary port, with isolated matchmaking and the publishing job's database settings. Auth-required unpublished games use the creator's test-player account, not a token for a version that does not exist yet. A failed check stops publication with its error.
135
+
136
+ For direct first-party server uploads from this checkout, start `npm run dev:agent-platform` and load `infra/.env.dev` in the publishing process. Its `PLAYDROP_WORKER_GAME_SERVER_REDIS_ADMIN_URL` and `PLAYDROP_DEV_MONGO_URL` let the CLI create and clean up a temporary database lease for that upload. Cloud publishing receives the existing task lease directly and does not delete it until the task ends.
137
+
126
138
  ## Documentation
127
139
 
128
140
  ```bash
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "0.17.5",
2
+ "version": "0.17.15",
3
3
  "build": 1,
4
- "runtimeSdkVersion": "0.17.5",
4
+ "runtimeSdkVersion": "0.17.15",
5
5
  "runtimeSdkBuild": 1,
6
6
  "clients": {
7
7
  "all": {
@@ -1,5 +1,6 @@
1
1
  import type { AppPlaytestTapes, AppSurface, AppType } from "@playdrop/types";
2
2
  import type { AppTask } from "../catalogue";
3
+ import { type ServerSdkOptions } from "./serverSdk";
3
4
  export type AppBuildMetadata = {
4
5
  displayName?: string;
5
6
  subtitle?: string;
@@ -24,7 +25,7 @@ export type AppBuildArtifacts = {
24
25
  sourceHash?: string;
25
26
  metadata: AppBuildMetadata;
26
27
  };
27
- export type BuildAppOptions = {
28
+ export type BuildAppOptions = ServerSdkOptions & {
28
29
  includeSourceArchive?: boolean;
29
30
  };
30
31
  export declare function runNpmScript(projectDir: string, script: string): Promise<void>;
@@ -9,6 +9,8 @@ const node_fs_1 = require("node:fs");
9
9
  const promises_1 = require("node:fs/promises");
10
10
  const node_path_1 = require("node:path");
11
11
  const node_zlib_1 = require("node:zlib");
12
+ const runtimeSyntax_1 = require("./runtimeSyntax");
13
+ const serverSdk_1 = require("./serverSdk");
12
14
  const BASE_SOURCE_IGNORE_PATTERNS = [
13
15
  "node_modules/",
14
16
  "dist/",
@@ -75,10 +77,11 @@ function readPackageJson(packageJsonPath) {
75
77
  const raw = (0, node_fs_1.readFileSync)(packageJsonPath, "utf8");
76
78
  return JSON.parse(raw);
77
79
  }
78
- async function ensureProjectBuild(task) {
80
+ async function ensureProjectBuild(task, options) {
79
81
  if (!task.isTypescriptProject || !task.packageJsonPath) {
80
82
  return;
81
83
  }
84
+ await (0, serverSdk_1.ensureGameServerSdk)(task, options);
82
85
  if (!projectBuildCache.has(task.projectDir)) {
83
86
  const promise = (async () => {
84
87
  const pkg = readPackageJson(task.packageJsonPath);
@@ -614,7 +617,7 @@ function createSourceArchive(task) {
614
617
  return { buffer, hash };
615
618
  }
616
619
  async function buildApp(task, options = {}) {
617
- await ensureProjectBuild(task);
620
+ await ensureProjectBuild(task, options);
618
621
  if (!(0, node_fs_1.existsSync)(task.filePath) || !(0, node_fs_1.statSync)(task.filePath).isFile()) {
619
622
  const locationHint = task.cataloguePath || "catalogue.json";
620
623
  const bundlePath = task.relativeFile || (0, node_path_1.basename)(task.filePath);
@@ -628,6 +631,7 @@ async function buildApp(task, options = {}) {
628
631
  ].join(" "));
629
632
  }
630
633
  const runtime = collectBundleFiles(task);
634
+ await (0, runtimeSyntax_1.validateRuntimeSyntax)(runtime.entries);
631
635
  const runtimeHash = (0, node_crypto_1.createHash)("sha256");
632
636
  const runtimeFiles = runtime.entries.map((entry) => {
633
637
  const content = (0, node_fs_1.readFileSync)(entry.absolutePath);
@@ -2,6 +2,7 @@ import type { ApiClient } from "@playdrop/api-client";
2
2
  import type { AgentTaskPlaytestProof, UserResponse } from "@playdrop/types";
3
3
  import type { AppTask } from "../catalogue";
4
4
  import type { TaskCaptureSession } from "../appUrls";
5
+ import type { GameServerDevServices } from "../commands/devGameServer";
5
6
  import { collectAppValidationWarnings, validateAppTask, runFormatScript } from "./validate";
6
7
  import { buildApp, type AppBuildArtifacts } from "./build";
7
8
  import { prepareAppUploadFiles, uploadApp, type AppUploadResult, type AppUploadOptions } from "./upload";
@@ -18,14 +19,12 @@ export type AppPipelineOptions = {
18
19
  webBase?: string | null;
19
20
  token?: string | null;
20
21
  user?: UserResponse | null;
21
- runLocalLoadCheck?: boolean;
22
22
  devRouterPort?: number;
23
- runStagedUploadLoadCheck?: boolean;
23
+ gameServerDev?: GameServerDevServices;
24
24
  loadCheckTimeoutMs?: number;
25
25
  ensureRegisteredAppShell?: boolean;
26
26
  agentTaskId?: number;
27
27
  playtestProof?: AgentTaskPlaytestProof;
28
- playtestSmokeCheckRequired?: boolean;
29
28
  captureSession?: TaskCaptureSession | null;
30
29
  artifacts?: AppBuildArtifacts;
31
30
  prepareGitTransition?: AppUploadOptions["prepareGitTransition"];
@@ -12,67 +12,35 @@ const upload_1 = require("./upload");
12
12
  Object.defineProperty(exports, "prepareAppUploadFiles", { enumerable: true, get: function () { return upload_1.prepareAppUploadFiles; } });
13
13
  Object.defineProperty(exports, "uploadApp", { enumerable: true, get: function () { return upload_1.uploadApp; } });
14
14
  const registration_1 = require("./registration");
15
- const loadCheck_1 = require("./loadCheck");
16
15
  async function runAppPipeline(client, task, options) {
17
- await (0, validate_1.validateAppTask)(task);
18
- // External apps don't need to be built - they're hosted elsewhere
19
- const isExternal = task.hostingMode === "EXTERNAL" || !!task.externalUrl;
20
- const shouldEnsureRegisteredShell = !isExternal && options?.ensureRegisteredAppShell;
21
- if (shouldEnsureRegisteredShell) {
22
- const creatorUsername = typeof options.creatorUsername === "string" ? options.creatorUsername.trim() : "";
23
- if (!creatorUsername) {
24
- throw new Error(`Missing creator username for required hosted app registration of "${task.name}".`);
25
- }
26
- await (0, registration_1.ensureRegisteredHostedAppShell)({
27
- client,
28
- creatorUsername,
29
- task,
30
- agentTaskId: options.agentTaskId,
31
- });
16
+ await (0, validate_1.validateAppTask)(task, { apiBase: options?.apiBase });
17
+ const isExternal = task.hostingMode === "EXTERNAL" || Boolean(task.externalUrl);
18
+ if (!isExternal && options?.ensureRegisteredAppShell) {
19
+ const creatorUsername = options.creatorUsername?.trim();
20
+ if (!creatorUsername)
21
+ throw new Error(`Missing creator username for app "${task.name}" registration.`);
22
+ await (0, registration_1.ensureRegisteredHostedAppShell)({ client, creatorUsername, task, agentTaskId: options.agentTaskId });
32
23
  }
33
- const artifacts = isExternal
34
- ? null
35
- : (options?.artifacts ?? (await (0, build_1.buildApp)(task, { includeSourceArchive: options?.agentTaskId === undefined })));
24
+ const artifacts = isExternal ? null : options?.artifacts ?? await (0, build_1.buildApp)(task, {
25
+ includeSourceArchive: options?.agentTaskId === undefined,
26
+ apiBase: options?.apiBase,
27
+ });
36
28
  const warnings = (0, validate_1.collectAppValidationWarnings)(task, isExternal ? "source" : "bundle");
37
- if (!isExternal && options?.runLocalLoadCheck) {
38
- const creatorUsername = typeof options.creatorUsername === "string" ? options.creatorUsername.trim() : "";
39
- if (!creatorUsername) {
40
- throw new Error(`Missing creator username for local hosted load-check of "${task.name}".`);
41
- }
42
- const apiBase = typeof options.apiBase === "string" ? options.apiBase.trim() : "";
43
- if (!apiBase) {
44
- throw new Error(`Missing API base URL for local hosted load-check of "${task.name}".`);
45
- }
46
- const loadCheck = await (0, loadCheck_1.runLocalHostedLoadCheck)({
47
- client,
48
- apiBase,
49
- webBase: options.webBase,
50
- creatorUsername,
51
- task,
52
- devRouterPort: options.devRouterPort,
53
- timeoutMs: options.loadCheckTimeoutMs,
54
- token: options.token ?? null,
55
- currentUser: options.user ?? null,
56
- captureSession: options.captureSession,
57
- });
58
- if (loadCheck.status !== "PASSED") {
59
- throw new Error((0, loadCheck_1.formatHostedLoadCheckFailure)(task.name, loadCheck, "local"));
60
- }
61
- }
62
- const uploadOptions = {
29
+ // Cloud and direct publishing use the same uploaded-game checks. A separate
30
+ // local browser pass cannot validate the uploaded bytes or their server.
31
+ const upload = await (0, upload_1.uploadApp)(client, task, artifacts, {
32
+ apiBase: options?.apiBase,
63
33
  skipReview: options?.skipReview,
64
34
  clearTags: options?.clearTags,
65
35
  creatorUsername: options?.creatorUsername,
66
36
  token: options?.token,
67
37
  user: options?.user ?? null,
68
- runStagedUploadLoadCheck: options?.runStagedUploadLoadCheck,
38
+ gameServerDev: options?.gameServerDev,
69
39
  loadCheckTimeoutMs: options?.loadCheckTimeoutMs,
70
40
  agentTaskId: options?.agentTaskId,
71
41
  playtestProof: options?.playtestProof,
72
- playtestSmokeCheckRequired: options?.playtestSmokeCheckRequired,
73
42
  captureSession: options?.captureSession,
74
43
  prepareGitTransition: options?.prepareGitTransition,
75
- };
76
- const upload = await (0, upload_1.uploadApp)(client, task, artifacts, uploadOptions);
44
+ });
77
45
  return { artifacts, upload, warnings };
78
46
  }
@@ -72,13 +72,14 @@ export declare function runLocalHostedLoadCheck(input: {
72
72
  postReadyWaitMs?: number;
73
73
  gameServerCredential?: DevGameServerCredentialResponse | null;
74
74
  }): Promise<HostedLoadCheckResult>;
75
- export declare function buildUploadedHostedLoadCheckUrl(rawUrl: string, phase?: "play" | "editor"): string;
75
+ export declare function buildUploadedHostedLoadCheckUrl(rawUrl: string, phase?: "play" | "editor", localDevPort?: number): string;
76
76
  export declare function buildUploadedHostedLoadCheckScreenshotPath(task: Pick<AppTask, "name" | "projectDir">, sessionId: string, phase?: "play" | "editor"): string | null;
77
77
  export declare function runUploadedHostedLoadCheck(input: {
78
78
  client: ApiClient;
79
79
  creatorUsername: string;
80
80
  task: AppTask;
81
81
  sessionId: string;
82
+ localDevPort?: number;
82
83
  timeoutMs?: number;
83
84
  token?: string | null;
84
85
  currentUser?: UserResponse | null;
@@ -100,6 +101,7 @@ export declare function runUploadedHostedPlaytestSmokeCheck(input: {
100
101
  creatorUsername: string;
101
102
  task: AppTask;
102
103
  sessionId: string;
104
+ localDevPort?: number;
103
105
  timeoutMs?: number;
104
106
  token?: string | null;
105
107
  currentUser?: UserResponse | null;
@@ -292,14 +292,13 @@ function formatLoadCheckErrors(errors) {
292
292
  return "";
293
293
  }
294
294
  const normalized = errors
295
- .map((entry) => entry.replace(/\s+/g, " ").trim())
295
+ .map((entry) => entry.trim())
296
296
  .filter(Boolean)
297
- .slice(0, 3)
298
- .map((entry) => (entry.length > 240 ? `${entry.slice(0, 237)}...` : entry));
297
+ .slice(0, 3);
299
298
  if (normalized.length === 0) {
300
299
  return "";
301
300
  }
302
- return ` First error${normalized.length === 1 ? "" : "s"}: ${normalized.join(" | ")}`;
301
+ return `\nFirst error${normalized.length === 1 ? "" : "s"}:\n${normalized.join("\n\n")}`;
303
302
  }
304
303
  function isSoftwareWebGlRenderer(renderer) {
305
304
  const normalized = typeof renderer === "string" ? renderer.trim() : "";
@@ -549,13 +548,17 @@ async function readWebGlRenderer(frame) {
549
548
  }
550
549
  return normalized;
551
550
  }
552
- async function saveScreenshot(page, screenshotPath) {
551
+ async function saveScreenshot(page, screenshotPath, timeoutMs = 30000) {
553
552
  const normalizedPath = typeof screenshotPath === "string" ? screenshotPath.trim() : "";
554
553
  if (!normalizedPath) {
555
554
  return null;
556
555
  }
557
556
  await (0, promises_1.mkdir)((0, node_path_1.dirname)(normalizedPath), { recursive: true });
558
- await page.screenshot({ path: normalizedPath, fullPage: true });
557
+ await page.screenshot({
558
+ path: normalizedPath,
559
+ fullPage: true,
560
+ timeout: timeoutMs,
561
+ });
559
562
  return normalizedPath;
560
563
  }
561
564
  async function saveGameFrameScreenshot(page, screenshotPath) {
@@ -632,11 +635,22 @@ async function runHostedLoadCheck(options) {
632
635
  const withTapeAction = (line) => (currentTapeAction ? `${line} after ${currentTapeAction}` : line);
633
636
  let hostedLaunchWaiterSettled = false;
634
637
  let lastHostedLaunchState = null;
638
+ let fatalRuntimeError = null;
639
+ let rejectFatalRuntimeFailure;
640
+ const fatalRuntimeFailure = new Promise((_resolve, reject) => {
641
+ rejectFatalRuntimeFailure = reject;
642
+ });
643
+ // Browser events can arrive before navigation starts awaiting this signal.
644
+ void fatalRuntimeFailure.catch(() => { });
635
645
  let resolveHostedLaunchWaiter = null;
636
646
  const hostedLaunchWaiter = new Promise((resolve) => {
637
647
  resolveHostedLaunchWaiter = resolve;
638
648
  });
639
649
  const settleHostedLaunchWaiter = (state) => {
650
+ if (state.state === "error" && !fatalRuntimeError) {
651
+ fatalRuntimeError = new Error(formatHostedLaunchError(state));
652
+ rejectFatalRuntimeFailure(fatalRuntimeError);
653
+ }
640
654
  lastHostedLaunchState = state;
641
655
  if (hostedLaunchWaiterSettled) {
642
656
  return;
@@ -760,9 +774,14 @@ async function runHostedLoadCheck(options) {
760
774
  pendingConsoleMessages.add(pending);
761
775
  });
762
776
  page.on("pageerror", (error) => {
763
- const text = redactReportText(error?.message ?? String(error));
777
+ const text = redactReportText(error.stack?.trim() || `${error.name}: ${error.message}`);
764
778
  const line = withTapeAction(`[check][pageerror] ${text}`);
765
779
  errors.push(line);
780
+ settleHostedLaunchWaiter({
781
+ state: "error",
782
+ errorCode: "hosted_app_javascript_error",
783
+ message: line,
784
+ });
766
785
  });
767
786
  page.on("crash", () => {
768
787
  const line = withTapeAction("[check][pagecrash] Hosted validator page crashed.");
@@ -811,7 +830,7 @@ async function runHostedLoadCheck(options) {
811
830
  });
812
831
  const captureFailureScreenshot = async () => {
813
832
  try {
814
- screenshotPath = await saveScreenshot(page, options.failureScreenshotPath ?? options.screenshotPath);
833
+ screenshotPath = await saveScreenshot(page, options.failureScreenshotPath ?? options.screenshotPath, HOSTED_LAUNCH_DIAGNOSTIC_TIMEOUT_MS);
815
834
  }
816
835
  catch (screenshotError) {
817
836
  const detail = redactReportText(screenshotError instanceof Error ? screenshotError.message : String(screenshotError));
@@ -826,57 +845,81 @@ async function runHostedLoadCheck(options) {
826
845
  screenshotPath = await saveScreenshot(page, options.screenshotPath);
827
846
  };
828
847
  try {
829
- const readinessTimeoutMs = Math.min(Math.max(timeoutMs, 1000), 30000);
830
- let response;
831
- let launchState;
832
- try {
833
- [response, launchState] = await waitForHostedLaunchDeadline(async () => await Promise.all([
834
- page.goto(options.targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }),
835
- hostedLaunchWaiter,
836
- ]), expectedHostedLaunchState, timeoutMs);
837
- }
838
- catch (error) {
839
- const timeoutContext = await describeHostedLaunchTimeout(page, lastHostedLaunchState);
840
- const detail = error instanceof Error ? error.message : String(error);
841
- throw new Error(`${detail} ${timeoutContext}`);
842
- }
843
- if (response && !response.ok()) {
844
- errors.push(`[check][navigation] ${response.status()} ${response.statusText()} ${reportTargetUrl}`);
845
- }
846
- if (launchState.state === "error") {
847
- throw new Error(formatHostedLaunchError(launchState));
848
- }
849
- finalUrl = (0, appUrls_1.redactTaskCaptureSession)(page.url());
850
- if (expectedHostedLaunchState !== "ready") {
848
+ const check = async () => {
849
+ const readinessTimeoutMs = Math.min(Math.max(timeoutMs, 1000), 30000);
850
+ let response;
851
+ let launchState;
852
+ try {
853
+ [response, launchState] = await waitForHostedLaunchDeadline(async () => await Promise.race([
854
+ Promise.all([
855
+ page.goto(options.targetUrl, {
856
+ waitUntil: "domcontentloaded",
857
+ timeout: 30000,
858
+ }),
859
+ hostedLaunchWaiter,
860
+ ]),
861
+ fatalRuntimeFailure,
862
+ ]), expectedHostedLaunchState, timeoutMs);
863
+ }
864
+ catch (error) {
865
+ const detail = error instanceof Error ? error.message : String(error);
866
+ if (!/^hosted_app_(?:missing_ready|gate_missing):/.test(detail))
867
+ throw error;
868
+ const timeoutContext = await describeHostedLaunchTimeout(page, lastHostedLaunchState);
869
+ throw new Error(`${detail} ${timeoutContext}`);
870
+ }
871
+ if (response && !response.ok()) {
872
+ errors.push(`[check][navigation] ${response.status()} ${response.statusText()} ${reportTargetUrl}`);
873
+ }
874
+ if (launchState.state === "error") {
875
+ throw new Error(formatHostedLaunchError(launchState));
876
+ }
877
+ finalUrl = (0, appUrls_1.redactTaskCaptureSession)(page.url());
878
+ if (expectedHostedLaunchState !== "ready") {
879
+ if (expectedHostedLaunchState === "login_required") {
880
+ try {
881
+ // Trial checks visibility and hit testing without opening the sign-in flow.
882
+ await page.locator('[data-hosted-play-gate="login_required"] button').click({
883
+ trial: true,
884
+ timeout: readinessTimeoutMs,
885
+ });
886
+ }
887
+ catch (error) {
888
+ throw new Error("hosted_app_gate_not_visible: PlayDrop reported that sign-in is required, but the game's sign-in panel could not be seen or used. " +
889
+ (error instanceof Error ? error.message : String(error)));
890
+ }
891
+ }
892
+ await drainConsoleMessages();
893
+ await captureCompletionScreenshot();
894
+ return;
895
+ }
896
+ const frame = await findGameFrame(page, readinessTimeoutMs);
897
+ webglRenderer = await readWebGlRenderer(frame);
898
+ if (isSoftwareWebGlRenderer(webglRenderer)) {
899
+ throw new Error(`project_check_webgl_renderer_software: WebGL renderer is "${webglRenderer}". Real hardware acceleration is required.`);
900
+ }
901
+ if (options.verifyEditorPhaseRoundTrip) {
902
+ await verifyHostedEditorPhaseRoundTrip(page, frame, readinessTimeoutMs);
903
+ }
904
+ await saveGameFrameScreenshot(page, options.readyGameFrameScreenshotPath);
905
+ if (options.playtestTape) {
906
+ if (!options.playtestSurface) {
907
+ throw new Error("project_check_playtest_surface_required");
908
+ }
909
+ await dispatchPlaytestTapeToFrame(page, frame, options.playtestSurface, options.playtestTape, {
910
+ onBeforeEvent: (eventIndex, event) => {
911
+ currentTapeAction = `action ${eventIndex + 1} of ${options.playtestTape.events.length} (${event.type} at ${event.atMs} ms)`;
912
+ },
913
+ });
914
+ }
915
+ else if (options.postReadyWaitMs && options.postReadyWaitMs > 0) {
916
+ await page.waitForTimeout(options.postReadyWaitMs);
917
+ }
918
+ await saveGameFrameScreenshot(page, options.gameFrameScreenshotPath);
851
919
  await drainConsoleMessages();
852
920
  await captureCompletionScreenshot();
853
- return;
854
- }
855
- const frame = await findGameFrame(page, readinessTimeoutMs);
856
- webglRenderer = await readWebGlRenderer(frame);
857
- if (isSoftwareWebGlRenderer(webglRenderer)) {
858
- throw new Error(`project_check_webgl_renderer_software: WebGL renderer is "${webglRenderer}". Real hardware acceleration is required.`);
859
- }
860
- if (options.verifyEditorPhaseRoundTrip) {
861
- await verifyHostedEditorPhaseRoundTrip(page, frame, readinessTimeoutMs);
862
- }
863
- await saveGameFrameScreenshot(page, options.readyGameFrameScreenshotPath);
864
- if (options.playtestTape) {
865
- if (!options.playtestSurface) {
866
- throw new Error("project_check_playtest_surface_required");
867
- }
868
- await dispatchPlaytestTapeToFrame(page, frame, options.playtestSurface, options.playtestTape, {
869
- onBeforeEvent: (eventIndex, event) => {
870
- currentTapeAction = `action ${eventIndex + 1} of ${options.playtestTape.events.length} (${event.type} at ${event.atMs} ms)`;
871
- },
872
- });
873
- }
874
- else if (options.postReadyWaitMs && options.postReadyWaitMs > 0) {
875
- await page.waitForTimeout(options.postReadyWaitMs);
876
- }
877
- await saveGameFrameScreenshot(page, options.gameFrameScreenshotPath);
878
- await drainConsoleMessages();
879
- await captureCompletionScreenshot();
921
+ };
922
+ await Promise.race([check(), fatalRuntimeFailure]);
880
923
  }
881
924
  catch (error) {
882
925
  await drainConsoleMessages();
@@ -887,7 +930,7 @@ async function runHostedLoadCheck(options) {
887
930
  // Keep the last known URL when the renderer is unavailable.
888
931
  }
889
932
  await captureFailureScreenshot();
890
- throw error;
933
+ throw fatalRuntimeError ?? error;
891
934
  }
892
935
  }, {
893
936
  ...options.contextOptions,
@@ -907,7 +950,9 @@ async function runHostedLoadCheck(options) {
907
950
  targetUrl: finalUrl,
908
951
  errors: errors.length > 0 ? errors : normalized.errors,
909
952
  warnings,
910
- message: normalized.message ? `${normalized.message}${formatLoadCheckErrors(errors)}` : normalized.message,
953
+ message: normalized.message
954
+ ? `${normalized.message}${formatLoadCheckErrors(errors.filter((entry) => !normalized.message.includes(entry)))}`
955
+ : normalized.message,
911
956
  };
912
957
  }
913
958
  if (errors.length > 0) {
@@ -1028,6 +1073,8 @@ async function runLocalHostedLoadCheck(input) {
1028
1073
  creatorUsername: input.creatorUsername,
1029
1074
  appName: input.task.name,
1030
1075
  appType: input.task.type ?? "GAME",
1076
+ devAuth: "player",
1077
+ player: "1",
1031
1078
  launchCheck: true,
1032
1079
  localDevPort,
1033
1080
  captureSession: input.captureSession,
@@ -1068,7 +1115,8 @@ async function runLocalHostedLoadCheck(input) {
1068
1115
  creatorUsername: input.creatorUsername,
1069
1116
  appName: input.task.name,
1070
1117
  appType: input.task.type ?? "GAME",
1071
- devAuth: "viewer",
1118
+ devAuth: "player",
1119
+ player: "1",
1072
1120
  launchCheck: true,
1073
1121
  localDevPort,
1074
1122
  }),
@@ -1114,9 +1162,11 @@ function appendLoadCheckParams(targetUrl, devAuth) {
1114
1162
  player: null,
1115
1163
  });
1116
1164
  }
1117
- function buildUploadedHostedLoadCheckUrl(rawUrl, phase) {
1165
+ function buildUploadedHostedLoadCheckUrl(rawUrl, phase, localDevPort) {
1118
1166
  const url = new URL(rawUrl);
1119
1167
  url.searchParams.set("launchCheck", "1");
1168
+ if (localDevPort !== undefined)
1169
+ url.searchParams.set("localDevPort", String(localDevPort));
1120
1170
  if (phase === "editor") {
1121
1171
  url.searchParams.set("hostPhase", "editor");
1122
1172
  }
@@ -1132,7 +1182,10 @@ function buildUploadedHostedLoadCheckScreenshotPath(task, sessionId, phase) {
1132
1182
  }
1133
1183
  async function runUploadedHostedLoadCheck(input) {
1134
1184
  const preview = await input.client.fetchAppUploadLaunchCheckPreview(input.creatorUsername, input.task.name, input.sessionId);
1135
- const withRequestedPhase = (rawUrl) => buildUploadedHostedLoadCheckUrl(rawUrl, input.phase);
1185
+ if (input.task.server && (!Number.isInteger(input.localDevPort) || input.localDevPort <= 0 || input.localDevPort > 65535)) {
1186
+ throw new Error("publishing_game_server_port_missing: The uploaded game cannot be checked without its running game server.");
1187
+ }
1188
+ const withRequestedPhase = (rawUrl) => buildUploadedHostedLoadCheckUrl(rawUrl, input.phase, input.localDevPort);
1136
1189
  const failureScreenshotPath = input.screenshotPath ?? buildUploadedHostedLoadCheckScreenshotPath(input.task, input.sessionId, input.phase);
1137
1190
  const captureSurface = resolveLoadCheckSurface(preview.localAppMetadata.surfaceTargets, input.task.primarySurface);
1138
1191
  const contextOptions = cloneLoadCheckContextOptions(captureSurface);
@@ -1142,7 +1195,10 @@ async function runUploadedHostedLoadCheck(input) {
1142
1195
  });
1143
1196
  let result;
1144
1197
  if (input.captureSession) {
1145
- const targetUrl = new URL(withRequestedPhase(preview.launchUrl));
1198
+ const targetUrl = new URL((0, devAuth_1.applyHostedDevAuthSelectionToUrl)(withRequestedPhase(preview.launchUrl), {
1199
+ devAuth: "player",
1200
+ player: 1,
1201
+ }));
1146
1202
  (0, appUrls_1.appendTaskCaptureSession)(targetUrl, input.captureSession);
1147
1203
  result = await runHostedLoadCheck({
1148
1204
  targetUrl: targetUrl.toString(),
@@ -1179,7 +1235,9 @@ async function runUploadedHostedLoadCheck(input) {
1179
1235
  }
1180
1236
  else {
1181
1237
  result = await runHostedLoadCheck({
1182
- targetUrl: withRequestedPhase(appendLoadCheckParams(preview.launchUrl, "viewer")),
1238
+ targetUrl: (0, devAuth_1.applyHostedDevAuthSelectionToUrl)(withRequestedPhase(preview.launchUrl), {
1239
+ devAuth: "player", player: 1,
1240
+ }),
1183
1241
  artifactFingerprint: preview.session.manifestHash,
1184
1242
  timeoutMs: input.timeoutMs,
1185
1243
  expectedHostedLaunchState: resolvePostAuthHostedLaunchState(preview.localAppMetadata.controllerMode),
@@ -1285,6 +1343,7 @@ async function runUploadedHostedPlaytestSmokeCheck(input) {
1285
1343
  creatorUsername: input.creatorUsername,
1286
1344
  task: input.task,
1287
1345
  sessionId: input.sessionId,
1346
+ localDevPort: input.localDevPort,
1288
1347
  timeoutMs: input.timeoutMs,
1289
1348
  token: input.token,
1290
1349
  currentUser: input.currentUser,
@@ -0,0 +1,18 @@
1
+ import type { ApiClient } from "@playdrop/api-client";
2
+ import type { AppTask } from "../catalogue";
3
+ import { type GameServerDevServices } from "../commands/devGameServer";
4
+ import type { GameServerBuild } from "./serverBuild";
5
+ export type PublishingRuntime = {
6
+ localDevPort: number;
7
+ close(): Promise<void>;
8
+ };
9
+ export declare function startPublishingRuntime(input: {
10
+ client: ApiClient;
11
+ task: AppTask;
12
+ creatorUsername: string;
13
+ appId: number;
14
+ sessionId: string;
15
+ apiBase?: string;
16
+ gameServerBuild: GameServerBuild;
17
+ gameServerDev?: GameServerDevServices;
18
+ }): Promise<PublishingRuntime>;