playcademy 0.27.1-beta.2 → 0.27.1-beta.4

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/dist/bucket.d.ts CHANGED
@@ -109,6 +109,34 @@ interface IntegrationsConfig {
109
109
  /** Queues (optional) */
110
110
  queues?: Record<string, QueueConfig | boolean>;
111
111
  }
112
+ /**
113
+ * Developer dashboard configuration.
114
+ *
115
+ * The dashboard is a separate app in the game's repo, deployed as a sibling
116
+ * worker with `playcademy dashboard deploy`. Auth and session plumbing are
117
+ * platform-managed; the dashboard app itself is just a static SPA build.
118
+ */
119
+ interface DashboardConfig {
120
+ /** Path to the dashboard app, relative to the config file (default: './dashboard') */
121
+ directory?: string;
122
+ /** Build command run inside the dashboard directory (defaults to the workspace package manager's build script) */
123
+ buildCommand?: string[];
124
+ /** Path to the dashboard build output, relative to the dashboard directory (default: 'dist') */
125
+ buildPath?: string;
126
+ /** Colors for the platform-managed login/invite page (hex); unset uses the built-in palette */
127
+ theme?: DashboardThemeConfig;
128
+ }
129
+ /**
130
+ * Login-page theming. Two seeds — a primary (button, focus, background
131
+ * wash) and a secondary accent — the platform derives the full palette
132
+ * from. Either may be omitted to keep that half of the built-in palette.
133
+ */
134
+ interface DashboardThemeConfig {
135
+ /** Primary color as a hex string, e.g. '#e0245e' */
136
+ primary?: string;
137
+ /** Secondary/accent color as a hex string, e.g. '#ffd166' */
138
+ secondary?: string;
139
+ }
112
140
  /**
113
141
  * Unified Playcademy configuration
114
142
  * Used for playcademy.config.{js,json}
@@ -130,6 +158,8 @@ interface PlaycademyConfig {
130
158
  externalUrl?: string;
131
159
  /** Game platform */
132
160
  platform?: 'web' | 'godot';
161
+ /** Developer dashboard app (optional) — `true` uses defaults */
162
+ dashboard?: DashboardConfig | boolean;
133
163
  /** Integrations (database, custom routes, external services) */
134
164
  integrations?: IntegrationsConfig;
135
165
  }
package/dist/bucket.js CHANGED
@@ -22,62 +22,12 @@ var init_file_loader = __esm({
22
22
  import { existsSync } from "fs";
23
23
  import { join as join8 } from "path";
24
24
 
25
- // ../constants/src/timeback.ts
26
- var TIMEBACK_ROUTES = {
27
- END_ACTIVITY: "/integrations/timeback/end-activity",
28
- GET_XP: "/integrations/timeback/xp",
29
- GET_MASTERY: "/integrations/timeback/mastery",
30
- GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
31
- HEARTBEAT: "/integrations/timeback/heartbeat",
32
- ADVANCE_COURSE: "/integrations/timeback/advance-course",
33
- UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
34
- };
35
- var TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
36
- xp: 1,
37
- mastery: 0,
38
- score: 2
39
- };
40
- var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
41
- xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
42
- mastery: 0,
43
- time: 60,
44
- score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
45
- };
46
-
47
- // ../constants/src/cloudflare.ts
48
- var CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
49
-
50
- // ../edge-play/src/constants.ts
25
+ // ../edge-play/src/lib/constants.ts
51
26
  var ASSET_PREFIX = "_assets/";
52
27
  var MANIFEST_KEY = "_manifest.json";
53
28
  var ASSET_ROUTE_PREFIX = "/api/assets/";
54
- var ROUTES = {
55
- /** Route index (lists available routes) */
56
- INDEX: "/api",
57
- /** Health check endpoint */
58
- HEALTH: "/api/health",
59
- /**
60
- * Built-in content-addressable asset serving.
61
- *
62
- * Serves files from the game's BUCKET (R2) binding under their
63
- * content-hashed keys, with immutable caching. Registered in the
64
- * terminal tier so a game's own `server/api/assets/[...path].ts`
65
- * route supersedes it.
66
- */
67
- ASSETS: `${ASSET_ROUTE_PREFIX}*`,
68
- /** TimeBack integration routes */
69
- TIMEBACK: {
70
- END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
71
- GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
72
- GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
73
- GET_HIGHEST_GRADE_MASTERED: `/api${TIMEBACK_ROUTES.GET_HIGHEST_GRADE_MASTERED}`,
74
- HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
75
- ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`,
76
- UNENROLL_COURSE: `/api${TIMEBACK_ROUTES.UNENROLL_COURSE}`
77
- }
78
- };
79
29
 
80
- // src/lib/bundle/backend.ts
30
+ // src/lib/bundle/game-backend.ts
81
31
  import { join as join6 } from "node:path";
82
32
 
83
33
  // src/constants/api.ts
@@ -85,16 +35,16 @@ import { join as join2 } from "node:path";
85
35
 
86
36
  // src/constants/server.ts
87
37
  import { join } from "node:path";
88
- var SERVER_ROOT_DIRECTORY = "server";
89
- var SERVER_LIB_DIRECTORY = join(SERVER_ROOT_DIRECTORY, "lib");
38
+ var GAME_SERVER_ROOT_DIRECTORY = "server";
39
+ var GAME_SERVER_LIB_DIRECTORY = join(GAME_SERVER_ROOT_DIRECTORY, "lib");
90
40
 
91
41
  // src/constants/api.ts
92
- var DEFAULT_API_ROUTES_DIRECTORY = join2(SERVER_ROOT_DIRECTORY, "api");
42
+ var GAME_API_ROUTES_DIRECTORY = join2(GAME_SERVER_ROOT_DIRECTORY, "api");
93
43
 
94
44
  // ../better-auth/package.json
95
45
  var package_default = {
96
46
  name: "@playcademy/better-auth",
97
- version: "0.0.20-beta.1",
47
+ version: "0.0.20-beta.3",
98
48
  type: "module",
99
49
  exports: {
100
50
  "./server": {
@@ -200,6 +150,22 @@ var CLI_DEFAULT_OUTPUTS = {
200
150
  WORKER_BUNDLE: join5(WORKSPACE_NAME, "worker-bundle.js")
201
151
  };
202
152
 
153
+ // ../constants/src/timeback.ts
154
+ var TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
155
+ xp: 1,
156
+ mastery: 0,
157
+ score: 2
158
+ };
159
+ var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
160
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
161
+ mastery: 0,
162
+ time: 60,
163
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
164
+ };
165
+
166
+ // ../constants/src/cloudflare.ts
167
+ var CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
168
+
203
169
  // src/lib/core/client.ts
204
170
  import { PlaycademyClient } from "@playcademy/sdk/internal";
205
171
 
@@ -327,11 +293,13 @@ import {
327
293
  // src/lib/core/transpile.ts
328
294
  import * as esbuild2 from "esbuild";
329
295
 
330
- // src/lib/bundle/backend.ts
331
- function getCustomRoutesDirectory(projectPath, config) {
296
+ // src/lib/bundle/game-backend.ts
297
+ function resolveGameRoutesDir(config) {
332
298
  const customRoutes = config?.integrations?.customRoutes;
333
- const customRoutesDir = typeof customRoutes === "object" && customRoutes.directory || DEFAULT_API_ROUTES_DIRECTORY;
334
- return join6(projectPath, customRoutesDir);
299
+ return typeof customRoutes === "object" && customRoutes.directory || GAME_API_ROUTES_DIRECTORY;
300
+ }
301
+ function getGameRoutesDirectory(projectPath, config) {
302
+ return join6(projectPath, resolveGameRoutesDir(config));
335
303
  }
336
304
 
337
305
  // src/lib/bucket/local.ts
@@ -354,7 +322,7 @@ async function openLocalBucket() {
354
322
  // src/lib/bucket/dev.ts
355
323
  var ASSET_DEV_ROUTE_PREFIX = ASSET_ROUTE_PREFIX;
356
324
  function hasCustomAssetsRoute(projectRoot, config) {
357
- const routesDir = getCustomRoutesDirectory(projectRoot, config);
325
+ const routesDir = getGameRoutesDirectory(projectRoot, config);
358
326
  return existsSync(join8(routesDir, "assets")) || existsSync(join8(routesDir, "assets.ts"));
359
327
  }
360
328
  function openLocalAssetBucket() {
package/dist/cli.js CHANGED
@@ -506,11 +506,16 @@ function writeBlank() {
506
506
  _consoleLog();
507
507
  lastWasBlank = true;
508
508
  }
509
+ var FG_RESET = "\x1B[39m";
510
+ function nestColor(content, color2) {
511
+ const open = color2("").replace(FG_RESET, "");
512
+ return color2(open ? content.replaceAll(FG_RESET, open) : content);
513
+ }
509
514
  function customTransform(text) {
510
515
  let result = text;
511
- result = result.replace(/`([^`]+)`/g, (_, code) => greenBright(code));
512
516
  result = result.replace(/<<([^>]+)>>/g, (_, url) => blueBright(underline(url)));
513
517
  result = result.replace(/<([^>]+)>/g, (_, path2) => blueBright(path2));
518
+ result = result.replace(/`([^`]+)`/g, (_, code) => nestColor(code, greenBright));
514
519
  return result;
515
520
  }
516
521
  function formatTable(data, title) {
@@ -1180,12 +1185,12 @@ import { join as join3 } from "node:path";
1180
1185
 
1181
1186
  // src/constants/server.ts
1182
1187
  import { join as join2 } from "node:path";
1183
- var SERVER_ROOT_DIRECTORY = "server";
1184
- var SERVER_LIB_DIRECTORY = join2(SERVER_ROOT_DIRECTORY, "lib");
1185
- var HOOKS_FILE = "hooks";
1188
+ var GAME_SERVER_ROOT_DIRECTORY = "server";
1189
+ var GAME_SERVER_LIB_DIRECTORY = join2(GAME_SERVER_ROOT_DIRECTORY, "lib");
1190
+ var GAME_HOOKS_FILE = "hooks";
1186
1191
 
1187
1192
  // src/constants/api.ts
1188
- var DEFAULT_API_ROUTES_DIRECTORY = join3(SERVER_ROOT_DIRECTORY, "api");
1193
+ var GAME_API_ROUTES_DIRECTORY = join3(GAME_SERVER_ROOT_DIRECTORY, "api");
1189
1194
  var AUTH_API_SUBDIRECTORY = "auth";
1190
1195
  var SAMPLE_API_SUBDIRECTORY = "sample";
1191
1196
  var SAMPLE_CUSTOM_FILENAME = "custom.ts";
@@ -1196,7 +1201,7 @@ var SAMPLE_BUCKET_FILENAME = "bucket.ts";
1196
1201
  // ../better-auth/package.json
1197
1202
  var package_default = {
1198
1203
  name: "@playcademy/better-auth",
1199
- version: "0.0.20-beta.1",
1204
+ version: "0.0.20-beta.3",
1200
1205
  type: "module",
1201
1206
  exports: {
1202
1207
  "./server": {
@@ -1350,7 +1355,7 @@ var NON_INTERACTIVE_HINTS = {
1350
1355
  PROJECT_DIRECTORY: "Pass a project directory argument or run interactively.",
1351
1356
  MIGRATE_MODE: "Pass --migrate keep or --migrate replace.",
1352
1357
  BUILD_PATH: "Pass --build or set buildPath in playcademy.config.",
1353
- DASHBOARD_DRIFT: "Resolve dashboard drift interactively or update local config first.",
1358
+ GAME_METADATA_DRIFT: "Resolve game metadata drift interactively or update local config first.",
1354
1359
  TIMEBACK_DRIFT: "Resolve TimeBack config drift interactively before deploying."
1355
1360
  };
1356
1361
 
@@ -1612,6 +1617,40 @@ function validateQueueKey(queueKey) {
1612
1617
  }
1613
1618
 
1614
1619
  // src/lib/config/loader.ts
1620
+ function validateDashboardConfig(dashboard) {
1621
+ if (dashboard === true || dashboard === false) {
1622
+ return;
1623
+ }
1624
+ if (typeof dashboard !== "object" || dashboard === null || Array.isArray(dashboard)) {
1625
+ throw new ConfigError(
1626
+ 'The "dashboard" field must be a boolean or an object',
1627
+ "dashboard",
1628
+ "Use dashboard: true, or dashboard: { directory: './dashboard' }"
1629
+ );
1630
+ }
1631
+ const config = dashboard;
1632
+ if (config.directory !== void 0 && typeof config.directory !== "string") {
1633
+ throw new ConfigError(
1634
+ 'The "dashboard.directory" field must be a string',
1635
+ "dashboard.directory",
1636
+ "Use a path relative to your config file: directory: './dashboard'"
1637
+ );
1638
+ }
1639
+ if (config.buildCommand !== void 0 && (!Array.isArray(config.buildCommand) || config.buildCommand.some((part) => typeof part !== "string"))) {
1640
+ throw new ConfigError(
1641
+ 'The "dashboard.buildCommand" field must be an array of strings',
1642
+ "dashboard.buildCommand",
1643
+ "Use command array form: buildCommand: ['bun', 'run', 'build']"
1644
+ );
1645
+ }
1646
+ if (config.buildPath !== void 0 && typeof config.buildPath !== "string") {
1647
+ throw new ConfigError(
1648
+ 'The "dashboard.buildPath" field must be a string',
1649
+ "dashboard.buildPath",
1650
+ "Use a path relative to the dashboard directory: buildPath: 'dist'"
1651
+ );
1652
+ }
1653
+ }
1615
1654
  function validateQueuesConfig(configName, queues) {
1616
1655
  if (typeof queues !== "object" || queues === null || Array.isArray(queues)) {
1617
1656
  throw new ConfigError(
@@ -1845,6 +1884,9 @@ function validateConfig(config) {
1845
1884
  'Add a name property to your config: name: "My App Name"'
1846
1885
  );
1847
1886
  }
1887
+ if (cfg.dashboard !== void 0) {
1888
+ validateDashboardConfig(cfg.dashboard);
1889
+ }
1848
1890
  if (cfg.integrations) {
1849
1891
  if (typeof cfg.integrations !== "object") {
1850
1892
  throw new ConfigError(
@@ -2546,7 +2588,7 @@ async function promptForTemplate(framework) {
2546
2588
  });
2547
2589
  }
2548
2590
 
2549
- // src/lib/deploy/godot.ts
2591
+ // src/lib/deploy/game/godot.ts
2550
2592
  import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync } from "fs";
2551
2593
  import { join as join10 } from "path";
2552
2594
  function isGodotProject() {
@@ -2559,11 +2601,10 @@ import { execSync as execSync3 } from "child_process";
2559
2601
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2560
2602
  import path from "node:path";
2561
2603
  var viteConfigTemplate = loadTemplateString("config/vite-config.ts");
2562
- async function findViteConfig() {
2563
- const workspace = getWorkspace();
2604
+ async function findViteConfig(cwd = getWorkspace()) {
2564
2605
  const result = await findFile(
2565
2606
  ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"],
2566
- { cwd: workspace }
2607
+ { cwd }
2567
2608
  );
2568
2609
  return result?.path ?? null;
2569
2610
  }
@@ -2572,23 +2613,22 @@ function isPluginConfigured(configPath) {
2572
2613
  return content.includes("@playcademy/vite-plugin") || content.includes("playcademy(");
2573
2614
  }
2574
2615
  async function addVitePlugin(options = {}) {
2575
- const { install = false } = options;
2576
- const workspace = getWorkspace();
2577
- const pkgPath = path.join(workspace, "package.json");
2616
+ const { install = false, dir = getWorkspace() } = options;
2617
+ const pkgPath = path.join(dir, "package.json");
2578
2618
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
2579
2619
  const hasPlugin = pkg.dependencies?.["@playcademy/vite-plugin"] || pkg.devDependencies?.["@playcademy/vite-plugin"];
2580
2620
  if (hasPlugin) {
2581
2621
  return false;
2582
2622
  }
2583
2623
  if (install) {
2584
- const pm = detectPackageManager(workspace);
2624
+ const pm = detectPackageManager(dir);
2585
2625
  const command = getAddDevDependencyCommand(pm, "@playcademy/vite-plugin");
2586
2626
  try {
2587
2627
  await runStep(
2588
2628
  "Installing @playcademy/vite-plugin...",
2589
2629
  async () => {
2590
2630
  execSync3(command, {
2591
- cwd: workspace,
2631
+ cwd: dir,
2592
2632
  stdio: ["ignore", "ignore", "ignore"]
2593
2633
  });
2594
2634
  },
@@ -2607,7 +2647,7 @@ async function addVitePlugin(options = {}) {
2607
2647
  }
2608
2648
  return true;
2609
2649
  }
2610
- async function updateViteConfig(configPath) {
2650
+ async function updateViteConfig(configPath, pluginCall = "playcademy()") {
2611
2651
  let content = readFileSync3(configPath, "utf8");
2612
2652
  if (!content.includes("@playcademy/vite-plugin")) {
2613
2653
  const importMatch = content.match(/import .+ from ['"]vite['"]/);
@@ -2621,17 +2661,17 @@ import { playcademy } from '@playcademy/vite-plugin'${content.slice(importIndex)
2621
2661
  ${content}`;
2622
2662
  }
2623
2663
  }
2624
- if (!content.includes("playcademy()")) {
2664
+ if (!content.includes("playcademy(")) {
2625
2665
  const pluginsMatch = content.match(/plugins:\s*\[/);
2626
2666
  if (pluginsMatch) {
2627
2667
  const pluginsIndex = pluginsMatch.index + pluginsMatch[0].length;
2628
- content = `${content.slice(0, pluginsIndex)}playcademy(),${content.slice(pluginsIndex)}`;
2668
+ content = `${content.slice(0, pluginsIndex)}${pluginCall},${content.slice(pluginsIndex)}`;
2629
2669
  } else {
2630
2670
  const defineConfigMatch = content.match(/defineConfig\(\{/);
2631
2671
  if (defineConfigMatch) {
2632
2672
  const configIndex = defineConfigMatch.index + defineConfigMatch[0].length;
2633
2673
  content = `${content.slice(0, configIndex)}
2634
- plugins: [playcademy()],${content.slice(configIndex)}`;
2674
+ plugins: [${pluginCall}],${content.slice(configIndex)}`;
2635
2675
  }
2636
2676
  }
2637
2677
  }
@@ -2841,14 +2881,14 @@ ${socialProviders.join(",\n")}
2841
2881
  return authContent;
2842
2882
  }
2843
2883
  function scaffoldAuthConfig(workspace, authContent) {
2844
- const libDir = join12(workspace, SERVER_LIB_DIRECTORY);
2884
+ const libDir = join12(workspace, GAME_SERVER_LIB_DIRECTORY);
2845
2885
  if (!existsSync10(libDir)) {
2846
2886
  mkdirSync4(libDir, { recursive: true });
2847
2887
  }
2848
2888
  writeFileSync5(join12(libDir, AUTH_CONFIG_FILE), authContent);
2849
2889
  }
2850
2890
  function scaffoldAuthRoutes(workspace) {
2851
- const apiDir = join12(workspace, DEFAULT_API_ROUTES_DIRECTORY);
2891
+ const apiDir = join12(workspace, GAME_API_ROUTES_DIRECTORY);
2852
2892
  const authApiDir = join12(apiDir, AUTH_API_SUBDIRECTORY);
2853
2893
  if (!existsSync10(authApiDir)) {
2854
2894
  mkdirSync4(authApiDir, { recursive: true });
@@ -2875,7 +2915,7 @@ export * from './auth'
2875
2915
  }
2876
2916
  }
2877
2917
  function scaffoldProtectedExample(workspace) {
2878
- const apiDir = join12(workspace, DEFAULT_API_ROUTES_DIRECTORY);
2918
+ const apiDir = join12(workspace, GAME_API_ROUTES_DIRECTORY);
2879
2919
  const sampleDir = join12(apiDir, SAMPLE_API_SUBDIRECTORY);
2880
2920
  if (!existsSync10(sampleDir)) {
2881
2921
  mkdirSync4(sampleDir, { recursive: true });
@@ -2951,7 +2991,7 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as re
2951
2991
  import { join as join13 } from "node:path";
2952
2992
 
2953
2993
  // src/version.ts
2954
- var cliVersion = false ? "0.0.0-dev" : "0.27.1-beta.2";
2994
+ var cliVersion = false ? "0.0.0-dev" : "0.27.1-beta.4";
2955
2995
 
2956
2996
  // src/lib/init/database.ts
2957
2997
  var drizzleConfigTemplate = loadTemplateString("database/drizzle-config.ts");
@@ -3039,8 +3079,7 @@ async function setupPackageJson2(workspace, appName, stepOptions) {
3039
3079
  return true;
3040
3080
  }
3041
3081
  }
3042
- function hasDatabaseSetup() {
3043
- const workspace = getWorkspace();
3082
+ function hasDatabaseSetup(workspace = getWorkspace()) {
3044
3083
  return DRIZZLE_CONFIG_FILES.some((filename) => existsSync11(join13(workspace, filename)));
3045
3084
  }
3046
3085
 
@@ -3301,7 +3340,7 @@ async function promptForCustomRoutes(requiresRoutes = false) {
3301
3340
  }
3302
3341
  const directory = await input({
3303
3342
  message: "API routes directory:",
3304
- default: DEFAULT_API_ROUTES_DIRECTORY,
3343
+ default: GAME_API_ROUTES_DIRECTORY,
3305
3344
  nonInteractiveHint: NON_INTERACTIVE_HINTS.API_ROUTES_DIRECTORY,
3306
3345
  validate: (value) => {
3307
3346
  if (!value || value.trim().length === 0) {
@@ -3364,22 +3403,24 @@ function errorMessage(err) {
3364
3403
  return err instanceof Error ? err.message : String(err);
3365
3404
  }
3366
3405
 
3367
- // src/lib/bundle/backend.ts
3406
+ // src/lib/bundle/game-backend.ts
3368
3407
  import { existsSync as existsSync13 } from "node:fs";
3369
3408
  import { join as join15 } from "node:path";
3370
3409
  var HOOKS_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs"];
3371
- function getCustomRoutesDirectory(projectPath, config) {
3410
+ function resolveGameRoutesDir(config) {
3372
3411
  const customRoutes = config?.integrations?.customRoutes;
3373
- const customRoutesDir = typeof customRoutes === "object" && customRoutes.directory || DEFAULT_API_ROUTES_DIRECTORY;
3374
- return join15(projectPath, customRoutesDir);
3412
+ return typeof customRoutes === "object" && customRoutes.directory || GAME_API_ROUTES_DIRECTORY;
3413
+ }
3414
+ function getGameRoutesDirectory(projectPath, config) {
3415
+ return join15(projectPath, resolveGameRoutesDir(config));
3375
3416
  }
3376
- function hasLocalCustomRoutes(projectPath, config) {
3377
- const customRoutesDir = getCustomRoutesDirectory(projectPath, config);
3417
+ function hasGameCustomRoutes(projectPath, config) {
3418
+ const customRoutesDir = getGameRoutesDirectory(projectPath, config);
3378
3419
  return existsSync13(customRoutesDir);
3379
3420
  }
3380
- function hasHooksModule(workspace) {
3421
+ function hasGameHooksModule(workspace) {
3381
3422
  return HOOKS_EXTENSIONS.some(
3382
- (ext) => existsSync13(join15(workspace, SERVER_ROOT_DIRECTORY, `${HOOKS_FILE}${ext}`))
3423
+ (ext) => existsSync13(join15(workspace, GAME_SERVER_ROOT_DIRECTORY, `${GAME_HOOKS_FILE}${ext}`))
3383
3424
  );
3384
3425
  }
3385
3426
 
@@ -3409,27 +3450,27 @@ function hasKVSetup(config) {
3409
3450
  init_file_loader();
3410
3451
  import { existsSync as existsSync14, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "fs";
3411
3452
  import { join as join16 } from "path";
3412
- function hasAllRequiredIncludes(config) {
3453
+ function hasAllRequiredIncludes(config, required) {
3413
3454
  if (!config.include) {
3414
3455
  return false;
3415
3456
  }
3416
- return TSCONFIG_REQUIRED_INCLUDES.every((entry) => config.include.includes(entry));
3457
+ return required.every((entry) => config.include.includes(entry));
3417
3458
  }
3418
- function getMissingIncludes(config) {
3459
+ function getMissingIncludes(config, required) {
3419
3460
  if (!config.include) {
3420
- return [...TSCONFIG_REQUIRED_INCLUDES];
3461
+ return [...required];
3421
3462
  }
3422
- return TSCONFIG_REQUIRED_INCLUDES.filter((entry) => !config.include.includes(entry));
3463
+ return required.filter((entry) => !config.include.includes(entry));
3423
3464
  }
3424
- function addRequiredIncludes(config) {
3465
+ function addRequiredIncludes(config, required) {
3425
3466
  if (!config.include) {
3426
3467
  config.include = [];
3427
3468
  }
3428
- for (const entry of getMissingIncludes(config)) {
3469
+ for (const entry of getMissingIncludes(config, required)) {
3429
3470
  config.include.push(entry);
3430
3471
  }
3431
3472
  }
3432
- function updateExistingIncludeArray(content) {
3473
+ function updateExistingIncludeArray(content, required) {
3433
3474
  const includeRegex = /"include"\s*:\s*\[([^\]]*)\]/;
3434
3475
  const match = content.match(includeRegex);
3435
3476
  if (!match || !match[1]) {
@@ -3437,9 +3478,7 @@ function updateExistingIncludeArray(content) {
3437
3478
  }
3438
3479
  const fullMatch = match[0];
3439
3480
  const arrayContents = match[1];
3440
- const missingEntries = TSCONFIG_REQUIRED_INCLUDES.filter(
3441
- (entry) => !arrayContents.includes(entry)
3442
- );
3481
+ const missingEntries = required.filter((entry) => !arrayContents.includes(entry));
3443
3482
  if (missingEntries.length === 0) {
3444
3483
  return content;
3445
3484
  }
@@ -3459,7 +3498,7 @@ function updateExistingIncludeArray(content) {
3459
3498
  const newIncludeArray = `"include": [${newArrayContents}]`;
3460
3499
  return content.replace(fullMatch, newIncludeArray);
3461
3500
  }
3462
- function addNewIncludeProperty(content) {
3501
+ function addNewIncludeProperty(content, required) {
3463
3502
  const closingBraceMatch = content.match(/\n(\s*)\}(\s*)$/);
3464
3503
  if (!closingBraceMatch || closingBraceMatch.index === void 0) {
3465
3504
  return null;
@@ -3469,17 +3508,17 @@ function addNewIncludeProperty(content) {
3469
3508
  const insertPosition = closingBraceMatch.index;
3470
3509
  const beforeClosing = content.slice(0, insertPosition).trim();
3471
3510
  const needsComma = beforeClosing.endsWith("]") || beforeClosing.endsWith("}") || beforeClosing.endsWith('"');
3472
- const entriesStr = TSCONFIG_REQUIRED_INCLUDES.map((e) => `"${e}"`).join(", ");
3511
+ const entriesStr = required.map((e) => `"${e}"`).join(", ");
3473
3512
  const comma = needsComma ? "," : "";
3474
3513
  const includeEntry = `${comma}
3475
3514
  ${propertyIndent}"include": [${entriesStr}]`;
3476
3515
  const updatedContent = content.slice(0, insertPosition) + includeEntry + content.slice(insertPosition);
3477
3516
  return updatedContent;
3478
3517
  }
3479
- function addToIncludeArrayPreservingComments(content) {
3480
- return updateExistingIncludeArray(content) || addNewIncludeProperty(content);
3518
+ function addToIncludeArrayPreservingComments(content, required) {
3519
+ return updateExistingIncludeArray(content, required) || addNewIncludeProperty(content, required);
3481
3520
  }
3482
- async function ensureTsconfigIncludes(workspace) {
3521
+ async function ensureTsconfigIncludes(workspace, required = TSCONFIG_REQUIRED_INCLUDES) {
3483
3522
  for (const filename of TSCONFIG_FILES) {
3484
3523
  const configPath = join16(workspace, filename);
3485
3524
  if (existsSync14(configPath)) {
@@ -3489,19 +3528,22 @@ async function ensureTsconfigIncludes(workspace) {
3489
3528
  stripComments: true
3490
3529
  });
3491
3530
  if (config && (!config.references || filename === TSCONFIG_JSON)) {
3492
- if (hasAllRequiredIncludes(config)) {
3531
+ if (hasAllRequiredIncludes(config, required)) {
3493
3532
  return filename;
3494
3533
  }
3495
3534
  try {
3496
3535
  const rawContent = readFileSync8(configPath, "utf8");
3497
- const updatedContent = addToIncludeArrayPreservingComments(rawContent);
3536
+ const updatedContent = addToIncludeArrayPreservingComments(
3537
+ rawContent,
3538
+ required
3539
+ );
3498
3540
  if (updatedContent && updatedContent !== rawContent) {
3499
3541
  writeFileSync8(configPath, updatedContent);
3500
3542
  return filename;
3501
3543
  }
3502
3544
  } catch {
3503
3545
  }
3504
- addRequiredIncludes(config);
3546
+ addRequiredIncludes(config, required);
3505
3547
  writeFileSync8(configPath, `${JSON.stringify(config, null, 4)}
3506
3548
  `);
3507
3549
  return filename;
@@ -3520,9 +3562,9 @@ function detectBackendFeatures(workspace, config) {
3520
3562
  hasDB: hasDatabaseSetup(),
3521
3563
  hasKV: hasKVSetup(config),
3522
3564
  hasBucket: hasBucketSetup(config),
3523
- hasRoutes: hasLocalCustomRoutes(workspace, config),
3565
+ hasRoutes: hasGameCustomRoutes(workspace, config),
3524
3566
  hasQueues: hasQueueSetup(config),
3525
- hasHooks: hasHooksModule(workspace),
3567
+ hasHooks: hasGameHooksModule(workspace),
3526
3568
  hasSecrets: hasEnvFile(workspace)
3527
3569
  };
3528
3570
  }
@@ -7,7 +7,11 @@ export { GAME_WORKER_DOMAINS, PLAYCADEMY_BASE_URLS, PLAYCADEMY_DOMAINS } from '@
7
7
  * Default directory for custom API routes
8
8
  * Used when integrations.customRoutes.directory is not specified in config
9
9
  */
10
- declare const DEFAULT_API_ROUTES_DIRECTORY: string;
10
+ declare const GAME_API_ROUTES_DIRECTORY: string;
11
+ /**
12
+ * Route directory inside a dashboard app (relative to the dashboard root)
13
+ */
14
+ declare const DASHBOARD_API_ROUTES_DIRECTORY = "api";
11
15
  /**
12
16
  * Auth routes subdirectory name within API directory (fixed structure)
13
17
  */
@@ -75,7 +79,7 @@ declare const DEFAULT_BUCKET_DIRECTORY = "bucket";
75
79
  * Cloudflare-related constants
76
80
  *
77
81
  * Worker runtime pins (`CLOUDFLARE_COMPATIBILITY_DATE`,
78
- * `BACKEND_COMPATIBILITY_FLAGS`) live in `@playcademy/constants` so every
82
+ * `WORKER_COMPATIBILITY_FLAGS`) live in `@playcademy/constants` so every
79
83
  * package that deploys or emulates workers (CLI, cloudflare, sdk, api-core)
80
84
  * agrees on a single source of truth. Import them from there directly.
81
85
  */
@@ -310,12 +314,25 @@ declare const CLI_FILES: {
310
314
 
311
315
  /**
312
316
  * Default port numbers for local development servers
317
+ *
318
+ * Canonical on purpose: picking a free port at startup caused port climbing
319
+ * across dev-server restarts (8788 → 8789 → …) while the old process was
320
+ * still releasing its bind. Every server binds its canonical port and waits
321
+ * for it to free instead, so URLs stay stable between runs.
313
322
  */
314
323
  declare const DEFAULT_PORTS: {
315
324
  /** Sandbox server (mock platform API) */
316
325
  readonly SANDBOX: 4321;
317
326
  /** Backend dev server (project backend with HMR) */
318
- readonly BACKEND: 8788;
327
+ readonly GAME: 8788;
328
+ /** Dashboard worker dev server (composed dashboard runtime + routes) */
329
+ readonly DASHBOARD: 8789;
330
+ /**
331
+ * Dashboard app dev server (the dashboard's own Vite). Deliberately in
332
+ * the 87xx family with the other backend ports — 5174 collided with the
333
+ * platform hub's local dev server.
334
+ */
335
+ readonly DASHBOARD_APP: 8790;
319
336
  };
320
337
 
321
338
  /**
@@ -334,7 +351,7 @@ declare const NON_INTERACTIVE_HINTS: {
334
351
  readonly PROJECT_DIRECTORY: 'Pass a project directory argument or run interactively.';
335
352
  readonly MIGRATE_MODE: 'Pass --migrate keep or --migrate replace.';
336
353
  readonly BUILD_PATH: 'Pass --build or set buildPath in playcademy.config.';
337
- readonly DASHBOARD_DRIFT: 'Resolve dashboard drift interactively or update local config first.';
354
+ readonly GAME_METADATA_DRIFT: 'Resolve game metadata drift interactively or update local config first.';
338
355
  readonly TIMEBACK_DRIFT: 'Resolve TimeBack config drift interactively before deploying.';
339
356
  };
340
357
 
@@ -347,15 +364,15 @@ declare const NON_INTERACTIVE_HINTS: {
347
364
  /**
348
365
  * Server root directory (fixed location)
349
366
  */
350
- declare const SERVER_ROOT_DIRECTORY = "server";
367
+ declare const GAME_SERVER_ROOT_DIRECTORY = "server";
351
368
  /**
352
369
  * Server library/utilities directory (fixed location)
353
370
  */
354
- declare const SERVER_LIB_DIRECTORY: string;
371
+ declare const GAME_SERVER_LIB_DIRECTORY: string;
355
372
  /**
356
373
  * Hooks file basename (without extension) inside server/
357
374
  */
358
- declare const HOOKS_FILE = "hooks";
375
+ declare const GAME_HOOKS_FILE = "hooks";
359
376
  /**
360
377
  * Public assets directory (fixed location)
361
378
  */
@@ -376,4 +393,4 @@ declare const CONFIG_FILE_NAMES: string[];
376
393
  */
377
394
  declare const DOCS_URL = "https://docs.dev.playcademy.net/platform";
378
395
 
379
- export { AUTH_API_SUBDIRECTORY, AUTH_CONFIG_FILE, AUTH_PROVIDER_NAMES, BETTER_AUTH_VERSION, BUCKET_ALWAYS_SKIP, CALLBACK_PATH, CALLBACK_PORT, CLI_DEFAULT_OUTPUTS, CLI_DIRECTORIES, CLI_FILES, CLI_USER_DIRECTORIES, CLOUDFLARE_BINDINGS, CONFIG_FILE_NAMES, DB_FILES, DEFAULT_API_ROUTES_DIRECTORY, DEFAULT_BUCKET_DIRECTORY, DEFAULT_DATABASE_DIRECTORY, DEFAULT_PORTS, DEFAULT_TEMPLATE_REF, DOCS_URL, DRIZZLE_CONFIG_FILES, ENV_EXAMPLE_FILE, ENV_FILES, GODOT_ADDON_URL, GODOT_BUILD_DIRECTORIES, GODOT_BUILD_OUTPUTS, GODOT_EXECUTABLE_PATTERNS, GODOT_EXPORT_PRESETS_FILE, GODOT_PROJECT_FILE, GODOT_WEB_PLATFORM, HOOKS_FILE, MINIFLARE_D1_DIRECTORY, NON_INTERACTIVE_HINTS, OAUTH_CALLBACK_URL_PATTERN, PLACEHOLDER_APP_URL, PLAYCADEMY_AUTH_VERSION, PRESIGNED_UPLOAD_THRESHOLD, PUBLIC_DIRECTORY, QUEUE_NAME_PREFIX, SAMPLE_API_SUBDIRECTORY, SAMPLE_BUCKET_FILENAME, SAMPLE_CUSTOM_FILENAME, SAMPLE_DATABASE_FILENAME, SAMPLE_KV_FILENAME, SCHEMA_SUBDIRECTORY, SELF_WORKER_NAME_BINDING, SERVER_LIB_DIRECTORY, SERVER_ROOT_DIRECTORY, SSO_AUTH_TIMEOUT_MS, TEMPLATE_RENAME_FILES, TEMPLATE_REPOS, TSCONFIG_APP_JSON, TSCONFIG_FILES, TSCONFIG_JSON, TSCONFIG_REQUIRED_INCLUDES, WORKSPACE_NAME, cacheVersionDir, getConfigDir, nativeBinaryPath };
396
+ export { AUTH_API_SUBDIRECTORY, AUTH_CONFIG_FILE, AUTH_PROVIDER_NAMES, BETTER_AUTH_VERSION, BUCKET_ALWAYS_SKIP, CALLBACK_PATH, CALLBACK_PORT, CLI_DEFAULT_OUTPUTS, CLI_DIRECTORIES, CLI_FILES, CLI_USER_DIRECTORIES, CLOUDFLARE_BINDINGS, CONFIG_FILE_NAMES, DASHBOARD_API_ROUTES_DIRECTORY, DB_FILES, DEFAULT_BUCKET_DIRECTORY, DEFAULT_DATABASE_DIRECTORY, DEFAULT_PORTS, DEFAULT_TEMPLATE_REF, DOCS_URL, DRIZZLE_CONFIG_FILES, ENV_EXAMPLE_FILE, ENV_FILES, GAME_API_ROUTES_DIRECTORY, GAME_HOOKS_FILE, GAME_SERVER_LIB_DIRECTORY, GAME_SERVER_ROOT_DIRECTORY, GODOT_ADDON_URL, GODOT_BUILD_DIRECTORIES, GODOT_BUILD_OUTPUTS, GODOT_EXECUTABLE_PATTERNS, GODOT_EXPORT_PRESETS_FILE, GODOT_PROJECT_FILE, GODOT_WEB_PLATFORM, MINIFLARE_D1_DIRECTORY, NON_INTERACTIVE_HINTS, OAUTH_CALLBACK_URL_PATTERN, PLACEHOLDER_APP_URL, PLAYCADEMY_AUTH_VERSION, PRESIGNED_UPLOAD_THRESHOLD, PUBLIC_DIRECTORY, QUEUE_NAME_PREFIX, SAMPLE_API_SUBDIRECTORY, SAMPLE_BUCKET_FILENAME, SAMPLE_CUSTOM_FILENAME, SAMPLE_DATABASE_FILENAME, SAMPLE_KV_FILENAME, SCHEMA_SUBDIRECTORY, SELF_WORKER_NAME_BINDING, SSO_AUTH_TIMEOUT_MS, TEMPLATE_RENAME_FILES, TEMPLATE_REPOS, TSCONFIG_APP_JSON, TSCONFIG_FILES, TSCONFIG_JSON, TSCONFIG_REQUIRED_INCLUDES, WORKSPACE_NAME, cacheVersionDir, getConfigDir, nativeBinaryPath };