@babylonjs-toolkit/server 0.0.0-stage → 9.35.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.
Files changed (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
@@ -0,0 +1,1059 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { BlockList } from "node:net";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { addNetworkEntry } from "./NetworkAddresses.js";
7
+ import { DISCORD_CALLBACK_PATH, MAXIMUM_SESSION_DAYS, MINIMUM_SESSION_DAYS } from "./discord/DiscordSignInConstants.js";
8
+ /** Thrown when the config is invalid; lists every problem. */
9
+ export class GameServerConfigError extends Error {
10
+ /** Every problem, one readable sentence each. */
11
+ problems;
12
+ /**
13
+ * Creates the error.
14
+ * @param problems Every problem found.
15
+ */
16
+ constructor(problems) {
17
+ super("Babylon Game Server configuration problems:\n" + problems.map((problem) => " - " + problem).join("\n"));
18
+ this.name = "GameServerConfigError";
19
+ this.problems = problems;
20
+ }
21
+ }
22
+ /** Prefix of every environment variable of the server. */
23
+ const ENVIRONMENT_PREFIX = "GAME_SERVER_";
24
+ /** Environment variable read for the port when GAME_SERVER_PORT is unset (platforms such as Heroku set it). */
25
+ const PLATFORM_PORT_VARIABLE = "PORT";
26
+ /** Base URL of the Colyseus Cloud API the console's Colyseus Cloud page reads (colyseus-cloud-console D6). */
27
+ const DEFAULT_COLYSEUS_CLOUD_API_URL = "https://cloud-prod.colyseus.io/api/v1";
28
+ /** Environment variable read for the Cloud API token when GAME_SERVER_ADMIN_COLYSEUS_CLOUD_API_TOKEN is unset (D6). */
29
+ const COLYSEUS_CLOUD_TOKEN_VARIABLE = "COLYSEUS_CLOUD_TOKEN";
30
+ /** Environment variables read for an option when its GAME_SERVER_ variable is unset, keyed by option path. */
31
+ const FALLBACK_ENVIRONMENT_VARIABLES = new Map([
32
+ ["port", PLATFORM_PORT_VARIABLE],
33
+ ["admin.colyseusCloud.apiToken", COLYSEUS_CLOUD_TOKEN_VARIABLE]
34
+ ]);
35
+ /** Environment variable that names the config file. */
36
+ const CONFIG_FILE_VARIABLE = "GAME_SERVER_CONFIG";
37
+ /** Flag that names the config file. */
38
+ const CONFIG_FILE_FLAG = "config";
39
+ /** Config files looked for in the working directory, in order. */
40
+ const DEFAULT_CONFIG_FILE_NAMES = ["babylon-game-server.config.mjs", "babylon-game-server.config.js", "babylon-game-server.config.json"];
41
+ /** Highest TCP/UDP port number. */
42
+ const MAX_PORT = 65535;
43
+ /** Prefix of a flag that turns a boolean option off. */
44
+ const NEGATED_FLAG_PREFIX = "no-";
45
+ /** What a secret shows in describeConfig. */
46
+ const REDACTED_VALUE = "[redacted]";
47
+ /** Text values a boolean option accepts as true. */
48
+ const TRUE_WORDS = ["true", "1", "yes"];
49
+ /** Text values a boolean option accepts as false. */
50
+ const FALSE_WORDS = ["false", "0", "no"];
51
+ /** Scene folder the legacy --dedicated flags load from when --scene-root is not given (the kit's dev server). */
52
+ const LEGACY_SCENE_ROOT = "http://localhost:8877/scenes/";
53
+ /** Legacy dedicated session flags (D19), handled apart from the option table. */
54
+ const LEGACY_FLAGS = {
55
+ dedicated: "dedicated",
56
+ sceneRoot: "scene-root",
57
+ scene: "scene",
58
+ projectScript: "project-script",
59
+ players: "players"
60
+ };
61
+ /** Defaults of a dedicated scene's optional fields. */
62
+ const DEDICATED_SCENE_DEFAULTS = {
63
+ projectScripts: [],
64
+ maxPlayers: 8,
65
+ onDemand: false,
66
+ idleSeconds: 60
67
+ };
68
+ /** The settings a server uses when nothing overrides them (D2: TLS, UDP and auth off). */
69
+ export const DEFAULT_GAME_SERVER_CONFIG = {
70
+ port: 2567,
71
+ host: "0.0.0.0",
72
+ publicUrl: null,
73
+ publicAddress: null,
74
+ trustProxy: false,
75
+ logFormat: "auto",
76
+ logLevel: "info",
77
+ tls: { enabled: false, certFile: null, keyFile: null, certPem: null, keyPem: null, developmentCertificate: false },
78
+ udp: { enabled: false },
79
+ auth: {
80
+ enabled: false,
81
+ mode: "shared-secret",
82
+ sharedSecret: { tokens: [] },
83
+ jwt: { secret: null, jwksUrl: null, issuer: null, audience: null, algorithms: [] },
84
+ webhook: { url: null, timeoutMs: 5000 }
85
+ },
86
+ discordSignIn: { enabled: false, clientId: null, clientSecret: null, redirectUri: null, returnOrigins: [], sessionDays: 30 },
87
+ admin: {
88
+ token: null,
89
+ console: true,
90
+ dataDir: "./babylon-game-server-data",
91
+ sessionIdleMinutes: 30,
92
+ sessionMaxHours: 12,
93
+ allowedNetworks: [],
94
+ logBufferEntries: 10000,
95
+ metricsHistoryHours: 24,
96
+ grafanaUrl: null,
97
+ instanceLabel: null,
98
+ oidc: { enabled: false, issuer: null, clientId: null, clientSecret: null, label: "Single sign-on", roleClaim: "groups", roleMapping: [], allowLocalSignIn: true },
99
+ passkeys: { enabled: true, rpId: null },
100
+ colyseusCloud: { apiToken: null, application: null, apiUrl: DEFAULT_COLYSEUS_CLOUD_API_URL }
101
+ },
102
+ metrics: { enabled: true, requireAdminToken: false },
103
+ limits: {
104
+ allowedOrigins: [],
105
+ allowMissingOrigin: true,
106
+ joinRequestsPerMinutePerAddress: 120,
107
+ maxSessions: 1000,
108
+ maxPlayersPerSession: 255,
109
+ maxDedicatedSessions: 4,
110
+ maxMessageBytes: 65536,
111
+ maxMessagesPerSecond: 2000,
112
+ maxSessionNameLength: 128
113
+ },
114
+ redis: { url: null },
115
+ dedicated: { scenes: [], boot: [] },
116
+ leaderboards: { definitionsFile: null, signedInPlayersOnly: false },
117
+ drainSeconds: 30
118
+ };
119
+ /**
120
+ * Builds one option table row.
121
+ * @param optionPath Dotted path in GameServerConfig.
122
+ * @param environmentSuffix Environment variable without the GAME_SERVER_ prefix.
123
+ * @param flag CLI flag without dashes, or null.
124
+ * @param valueType How text values parse.
125
+ * @param description One sentence for docs and --help.
126
+ * @param extras Allowed enum values and the secret marker.
127
+ * @returns The row.
128
+ */
129
+ function defineOption(optionPath, environmentSuffix, flag, valueType, description, extras = {}) {
130
+ const definition = { path: optionPath, environmentVariable: ENVIRONMENT_PREFIX + environmentSuffix, flag: flag, valueType: valueType, description: description, isSecret: extras.isSecret === true };
131
+ if (extras.allowedValues != null)
132
+ definition.allowedValues = extras.allowedValues;
133
+ return definition;
134
+ }
135
+ /** Every leaf option, once (D3). Flag parsing, env parsing, redaction and the docs all derive from it. */
136
+ export const CONFIG_OPTIONS = [
137
+ defineOption("port", "PORT", "port", "number", "TCP (and UDP) port to listen on; 0 picks a free port. Falls back to PORT."),
138
+ defineOption("host", "HOST", "host", "string", "Address to bind."),
139
+ defineOption("publicUrl", "PUBLIC_URL", "public-url", "nullable-string", "Public ws:// or wss:// URL players use, shown in the banner."),
140
+ defineOption("publicAddress", "PUBLIC_ADDRESS", "public-address", "nullable-string", "host[:port] this instance advertises for seat reservations when scaled out with Redis."),
141
+ defineOption("trustProxy", "TRUST_PROXY", "trust-proxy", "boolean", "Trust x-forwarded-for / x-real-ip from a reverse proxy for the client address."),
142
+ defineOption("logFormat", "LOG_FORMAT", "log-format", "enum", "Log format: auto (JSON when NODE_ENV=production), json or text.", { allowedValues: ["auto", "json", "text"] }),
143
+ defineOption("logLevel", "LOG_LEVEL", "log-level", "enum", "Least severe level logged.", { allowedValues: ["error", "warn", "info", "debug"] }),
144
+ defineOption("tls.enabled", "TLS_ENABLED", "tls", "boolean", "Serve https:// and wss:// from the server itself."),
145
+ defineOption("tls.certFile", "TLS_CERT_FILE", "tls-cert-file", "nullable-string", "Path of the PEM certificate chain; reloaded when it changes."),
146
+ defineOption("tls.keyFile", "TLS_KEY_FILE", "tls-key-file", "nullable-string", "Path of the PEM private key; reloaded when it changes."),
147
+ defineOption("tls.certPem", "TLS_CERT_PEM", null, "nullable-string", "PEM certificate chain text, for container secrets.", { isSecret: true }),
148
+ defineOption("tls.keyPem", "TLS_KEY_PEM", null, "nullable-string", "PEM private key text, for container secrets.", { isSecret: true }),
149
+ defineOption("tls.developmentCertificate", "TLS_DEVELOPMENT_CERTIFICATE", "tls-dev-cert", "boolean", "Make a local development certificate with mkcert (refused when NODE_ENV=production)."),
150
+ defineOption("udp.enabled", "UDP_ENABLED", "udp", "boolean", "Also accept WebTransport over HTTP/3 (UDP) on the same port; needs TLS."),
151
+ defineOption("auth.enabled", "AUTH_ENABLED", "auth", "boolean", "Require a valid player token on every join."),
152
+ defineOption("auth.mode", "AUTH_MODE", "auth-mode", "enum", "How tokens are verified: shared-secret, jwt, webhook or custom.", { allowedValues: ["shared-secret", "jwt", "webhook", "custom"] }),
153
+ defineOption("auth.sharedSecret.tokens", "AUTH_SHARED_SECRET_TOKENS", null, "string-list", "Comma-separated tokens accepted in shared-secret mode.", { isSecret: true }),
154
+ defineOption("auth.jwt.secret", "AUTH_JWT_SECRET", null, "nullable-string", "HMAC secret (HS256/384/512) that signs player JWTs.", { isSecret: true }),
155
+ defineOption("auth.jwt.jwksUrl", "AUTH_JWT_JWKS_URL", null, "nullable-string", "JWKS URL of the identity provider that signs player JWTs."),
156
+ defineOption("auth.jwt.issuer", "AUTH_JWT_ISSUER", null, "nullable-string", "Required JWT issuer (iss), or none."),
157
+ defineOption("auth.jwt.audience", "AUTH_JWT_AUDIENCE", null, "nullable-string", "Required JWT audience (aud), or none."),
158
+ defineOption("auth.jwt.algorithms", "AUTH_JWT_ALGORITHMS", null, "string-list", "Comma-separated accepted JWT algorithms (empty: the key's defaults)."),
159
+ defineOption("auth.webhook.url", "AUTH_WEBHOOK_URL", null, "nullable-string", "URL that approves players in webhook mode."),
160
+ defineOption("auth.webhook.timeoutMs", "AUTH_WEBHOOK_TIMEOUT_MS", null, "number", "Milliseconds the auth webhook may take."),
161
+ defineOption("discordSignIn.enabled", "DISCORD_SIGN_IN_ENABLED", "discord-sign-in", "boolean", "Let players sign in with Discord (website OAuth2, scope identify)."),
162
+ defineOption("discordSignIn.clientId", "DISCORD_CLIENT_ID", null, "nullable-string", "The Discord application's client id."),
163
+ defineOption("discordSignIn.clientSecret", "DISCORD_CLIENT_SECRET", null, "nullable-string", "The Discord application's client secret.", { isSecret: true }),
164
+ defineOption("discordSignIn.redirectUri", "DISCORD_REDIRECT_URI", null, "nullable-string", "This server's Discord callback address, exactly as registered with Discord (…/game-server/discord/callback)."),
165
+ defineOption("discordSignIn.returnOrigins", "DISCORD_RETURN_ORIGINS", null, "string-list", "Comma-separated game origins a Discord sign-in may return to."),
166
+ defineOption("discordSignIn.sessionDays", "DISCORD_SESSION_DAYS", null, "number", "Days a player's Discord sign-in lasts before they sign in again."),
167
+ defineOption("admin.token", "ADMIN_TOKEN", "admin-token", "nullable-string", "Bearer token of the admin endpoints (unset: the admin API is off).", { isSecret: true }),
168
+ defineOption("admin.console", "ADMIN_CONSOLE", null, "boolean", "Serve the web admin console at /game-server/admin/ when an admin account exists or a setup is pending."),
169
+ defineOption("admin.dataDir", "ADMIN_DATA_DIR", "admin-data-dir", "string", "Folder of the admin data (accounts, bans, audit) when Redis is not configured."),
170
+ defineOption("admin.sessionIdleMinutes", "ADMIN_SESSION_IDLE_MINUTES", null, "number", "Console sign-out after this many idle minutes."),
171
+ defineOption("admin.sessionMaxHours", "ADMIN_SESSION_MAX_HOURS", null, "number", "Console sign-out after this many hours in all."),
172
+ defineOption("admin.allowedNetworks", "ADMIN_ALLOWED_NETWORKS", null, "string-list", "Comma-separated addresses or CIDR ranges that may reach the console and its API (empty: any)."),
173
+ defineOption("admin.logBufferEntries", "ADMIN_LOG_BUFFER_ENTRIES", null, "number", "Log entries each instance keeps for the console."),
174
+ defineOption("admin.metricsHistoryHours", "ADMIN_METRICS_HISTORY_HOURS", null, "number", "Hours of metrics history kept for the console charts."),
175
+ defineOption("admin.grafanaUrl", "ADMIN_GRAFANA_URL", null, "nullable-string", "An external Grafana dashboard URL the console links to."),
176
+ defineOption("admin.instanceLabel", "ADMIN_INSTANCE_LABEL", null, "nullable-string", "A region or label the console shows beside this instance."),
177
+ defineOption("admin.oidc.enabled", "ADMIN_OIDC_ENABLED", null, "boolean", "Offer OpenID Connect single sign-on on the console (needs issuer and clientId)."),
178
+ defineOption("admin.oidc.issuer", "ADMIN_OIDC_ISSUER", null, "nullable-string", "OpenID Connect issuer URL (with a /.well-known/openid-configuration)."),
179
+ defineOption("admin.oidc.clientId", "ADMIN_OIDC_CLIENT_ID", null, "nullable-string", "The console's OpenID Connect client id."),
180
+ defineOption("admin.oidc.clientSecret", "ADMIN_OIDC_CLIENT_SECRET", null, "nullable-string", "The console's OpenID Connect client secret.", { isSecret: true }),
181
+ defineOption("admin.oidc.label", "ADMIN_OIDC_LABEL", null, "string", "Label of the single sign-on button."),
182
+ defineOption("admin.oidc.roleClaim", "ADMIN_OIDC_ROLE_CLAIM", null, "string", "ID token claim that carries the groups or roles mapped to console roles."),
183
+ defineOption("admin.oidc.roleMapping", "ADMIN_OIDC_ROLE_MAPPING", null, "string-list", "Comma-separated claim value=role pairs, e.g. admins=admin,support=moderator."),
184
+ defineOption("admin.oidc.allowLocalSignIn", "ADMIN_OIDC_ALLOW_LOCAL_SIGN_IN", null, "boolean", "Allow username and password sign-in besides single sign-on (the CLI always works)."),
185
+ defineOption("admin.passkeys.enabled", "ADMIN_PASSKEYS_ENABLED", null, "boolean", "Offer passkeys as a second factor and for password-less console sign-in."),
186
+ defineOption("admin.passkeys.rpId", "ADMIN_PASSKEYS_RP_ID", null, "nullable-string", "Passkey relying party id (unset: the console host)."),
187
+ defineOption("admin.colyseusCloud.apiToken", "ADMIN_COLYSEUS_CLOUD_API_TOKEN", null, "nullable-string", "Colyseus Cloud API token the console's Colyseus Cloud page reads with (read-only, team-wide). Falls back to COLYSEUS_CLOUD_TOKEN.", { isSecret: true }),
188
+ defineOption("admin.colyseusCloud.application", "ADMIN_COLYSEUS_CLOUD_APPLICATION", null, "nullable-string", "Slug of the Colyseus Cloud application the console shows, e.g. 1234-my-game (empty: the team's only application)."),
189
+ defineOption("admin.colyseusCloud.apiUrl", "ADMIN_COLYSEUS_CLOUD_API_URL", null, "string", "Base URL of the Colyseus Cloud API."),
190
+ defineOption("metrics.enabled", "METRICS_ENABLED", null, "boolean", "Serve Prometheus metrics on GET /metrics."),
191
+ defineOption("metrics.requireAdminToken", "METRICS_REQUIRE_ADMIN_TOKEN", null, "boolean", "Require the admin token on /metrics."),
192
+ defineOption("limits.allowedOrigins", "ALLOWED_ORIGINS", "allowed-origins", "string-list", "Comma-separated browser origins allowed to join (empty: any)."),
193
+ defineOption("limits.allowMissingOrigin", "ALLOW_MISSING_ORIGIN", null, "boolean", "Accept joins that send no Origin header (native clients)."),
194
+ defineOption("limits.joinRequestsPerMinutePerAddress", "JOIN_REQUESTS_PER_MINUTE", null, "number", "Join requests one client address may make per minute."),
195
+ defineOption("limits.maxSessions", "MAX_SESSIONS", null, "number", "Sessions one process hosts."),
196
+ defineOption("limits.maxPlayersPerSession", "MAX_PLAYERS_PER_SESSION", null, "number", "Players one session accepts; larger requests are clamped."),
197
+ defineOption("limits.maxDedicatedSessions", "MAX_DEDICATED_SESSIONS", null, "number", "Dedicated sessions one process runs."),
198
+ defineOption("limits.maxMessageBytes", "MAX_MESSAGE_BYTES", null, "number", "Largest WebSocket message in bytes."),
199
+ defineOption("limits.maxMessagesPerSecond", "MAX_MESSAGES_PER_SECOND", null, "number", "Messages one client may send per second before it is disconnected (a live change applies to new sessions)."),
200
+ defineOption("limits.maxSessionNameLength", "MAX_SESSION_NAME_LENGTH", null, "number", "Longest session name in characters."),
201
+ defineOption("redis.url", "REDIS_URL", "redis-url", "nullable-string", "redis:// URL shared by every instance when scaled out.", { isSecret: true }),
202
+ defineOption("leaderboards.definitionsFile", "LEADERBOARDS_DEFINITIONS_FILE", "leaderboards-definitions-file", "nullable-string", "JSON file of leaderboard boards to create on start when missing (relative to the working directory)."),
203
+ defineOption("leaderboards.signedInPlayersOnly", "LEADERBOARDS_SIGNED_IN_PLAYERS_ONLY", "leaderboards-signed-in-players-only", "boolean", "Only signed-in players (an auth identity with a user id) may submit leaderboard scores; anonymous entries are hidden on start."),
204
+ defineOption("drainSeconds", "DRAIN_SECONDS", "drain-seconds", "number", "Seconds running sessions may continue after SIGTERM.")
205
+ ];
206
+ /** Set by Colyseus Cloud on every application process. */
207
+ const COLYSEUS_CLOUD_VARIABLE = "COLYSEUS_CLOUD";
208
+ /** Redis address Colyseus Cloud uses when it names none (the same address @colyseus/core falls back to). */
209
+ const COLYSEUS_CLOUD_LOCAL_REDIS_URL = "redis://127.0.0.1:6379";
210
+ /** Seconds running sessions may finish during a Colyseus Cloud deploy; a race fits inside it. */
211
+ const COLYSEUS_CLOUD_DRAIN_SECONDS = 600;
212
+ /** Admin data folder under the home folder, because a deploy can change the working folder. */
213
+ const COLYSEUS_CLOUD_ADMIN_FOLDER = ".babylon-game-server-data";
214
+ /**
215
+ * Tells whether this process runs on Colyseus Cloud.
216
+ * @param environment The process environment.
217
+ * @returns True on Colyseus Cloud.
218
+ */
219
+ export function isColyseusCloud(environment) {
220
+ return environment[COLYSEUS_CLOUD_VARIABLE] !== undefined;
221
+ }
222
+ /**
223
+ * Loads the CLI config: defaults < platform < config file < environment < flags (D3), plus the legacy dedicated flags (D19).
224
+ * @param sources The arguments, environment and working directory.
225
+ * @returns The validated config.
226
+ * @throws GameServerConfigError listing every problem.
227
+ */
228
+ export async function loadGameServerConfig(sources) {
229
+ return (await loadGameServerConfigWithSources(sources)).config;
230
+ }
231
+ /**
232
+ * Loads the CLI config like loadGameServerConfig and records where each option came from (game-server-admin-console
233
+ * D20): the last layer that set it, else "default".
234
+ * @param sources The arguments, environment and working directory.
235
+ * @returns The validated config and the source of every CONFIG_OPTIONS path.
236
+ * @throws GameServerConfigError listing every problem.
237
+ */
238
+ export async function loadGameServerConfigWithSources(sources) {
239
+ const problems = [];
240
+ const parsedArguments = parseArguments(sources.argv, problems);
241
+ const configPath = findConfigFile(parsedArguments.configPath ?? nonEmpty(sources.environment[CONFIG_FILE_VARIABLE]), sources.workingDirectory, problems);
242
+ const fileLayer = (configPath != null) ? await readConfigFile(configPath, problems) : {};
243
+ const environmentLayer = parseEnvironment(sources.environment, problems);
244
+ const platformLayer = buildPlatformLayer(sources.environment, sources.processorCount ?? os.cpus().length, sources.homeDirectory ?? os.homedir());
245
+ const config = assembleConfig([platformLayer, fileLayer, environmentLayer, parsedArguments.overrides], problems);
246
+ // The legacy session is added to the file's scenes rather than replacing them, as a flag layer would.
247
+ const legacySession = parsedArguments.legacySession;
248
+ if (legacySession != null && Array.isArray(config.dedicated?.scenes) && Array.isArray(config.dedicated.boot)) {
249
+ config.dedicated.scenes.push(legacySession.scene);
250
+ config.dedicated.boot.push(legacySession.boot);
251
+ }
252
+ problems.push(...validateGameServerConfig(config, sources.environment));
253
+ if (problems.length > 0)
254
+ throw new GameServerConfigError(problems);
255
+ const layers = [
256
+ { source: "platform", layer: platformLayer },
257
+ { source: "file", layer: fileLayer },
258
+ { source: "environment", layer: environmentLayer },
259
+ { source: "flag", layer: parsedArguments.overrides }
260
+ ];
261
+ return { config: config, sources: recordConfigSources(layers) };
262
+ }
263
+ /**
264
+ * The defaults Colyseus Cloud needs: empty everywhere else, so no other host changes.
265
+ * @param environment The process environment.
266
+ * @param processorCount CPUs on this machine; more than one means Colyseus runs on Redis.
267
+ * @param homeDirectory The user's home folder.
268
+ * @returns The platform config layer.
269
+ */
270
+ function buildPlatformLayer(environment, processorCount, homeDirectory) {
271
+ if (!isColyseusCloud(environment))
272
+ return {};
273
+ const layer = {
274
+ trustProxy: true,
275
+ drainSeconds: COLYSEUS_CLOUD_DRAIN_SECONDS,
276
+ admin: { dataDir: path.join(homeDirectory, COLYSEUS_CLOUD_ADMIN_FOLDER) }
277
+ };
278
+ const subdomain = nonEmpty(environment.SUBDOMAIN);
279
+ const serverName = nonEmpty(environment.SERVER_NAME);
280
+ if (subdomain != null && serverName != null)
281
+ layer.publicUrl = "wss://" + subdomain + "." + serverName;
282
+ const redisUrl = colyseusCloudRedisUrl(environment, processorCount);
283
+ if (redisUrl != null)
284
+ layer.redis = { url: redisUrl };
285
+ return layer;
286
+ }
287
+ /**
288
+ * The Redis address Colyseus Cloud matchmaking uses, under the rule @colyseus/core applies on its own.
289
+ * @param environment The process environment.
290
+ * @param processorCount CPUs on this machine.
291
+ * @returns REDIS_URI, else the local address on a machine of several CPUs, else null (one process, no Redis).
292
+ */
293
+ function colyseusCloudRedisUrl(environment, processorCount) {
294
+ const namedUrl = nonEmpty(environment.REDIS_URI);
295
+ if (namedUrl != null)
296
+ return namedUrl;
297
+ return (processorCount > 1) ? COLYSEUS_CLOUD_LOCAL_REDIS_URL : null;
298
+ }
299
+ /**
300
+ * The source of every option: the last layer that set it, else "default".
301
+ * @param layers The layers in merge order.
302
+ * @returns The sources, keyed by dotted path.
303
+ */
304
+ function recordConfigSources(layers) {
305
+ const sourceMap = {};
306
+ for (const definition of CONFIG_OPTIONS) {
307
+ sourceMap[definition.path] = "default";
308
+ for (const entry of layers)
309
+ if (readPath(entry.layer, definition.path) !== undefined)
310
+ sourceMap[definition.path] = entry.source;
311
+ }
312
+ return sourceMap;
313
+ }
314
+ /**
315
+ * Parses a value given for an option by its value type (the console's live overrides, game-server-admin-console
316
+ * D20): text parses as it would from an environment variable (so a string-list takes a comma-separated string), any
317
+ * other value is kept as it is for validateGameServerConfig to check.
318
+ * @param definition The option.
319
+ * @param value The value given.
320
+ * @returns The value, or null when text does not parse.
321
+ */
322
+ export function parseOptionValue(definition, value) {
323
+ return (typeof value === "string") ? parseOptionText(definition, value) : { value: value };
324
+ }
325
+ /**
326
+ * The defaults deep-merged with overrides (objects merge, arrays and scalars replace), then validated.
327
+ * @param overrides The values to apply (a full config is fine).
328
+ * @param environment The environment validation checks (NODE_ENV); the process environment when omitted.
329
+ * @returns The validated config.
330
+ * @throws GameServerConfigError listing every problem.
331
+ */
332
+ export function resolveGameServerConfig(overrides, environment) {
333
+ const problems = [];
334
+ const config = assembleConfig([overrides], problems);
335
+ problems.push(...validateGameServerConfig(config, environment));
336
+ if (problems.length > 0)
337
+ throw new GameServerConfigError(problems);
338
+ return config;
339
+ }
340
+ /**
341
+ * The problems of a config (empty when valid): wrong value types, out-of-range numbers and incomplete opt-ins (D2).
342
+ * @param config The config.
343
+ * @param environment The environment (NODE_ENV); the process environment when omitted.
344
+ * @returns One sentence per problem, each naming the option and its environment variable.
345
+ */
346
+ export function validateGameServerConfig(config, environment = process.env) {
347
+ const typeProblems = CONFIG_OPTIONS.map((definition) => valueTypeProblem(definition, readPath(config, definition.path))).filter(isProblem);
348
+ if (typeProblems.length > 0)
349
+ return typeProblems;
350
+ return [
351
+ portProblem(config),
352
+ publicUrlProblem(config),
353
+ ...tlsProblems(config, environment),
354
+ udpProblem(config),
355
+ authProblem(config.auth),
356
+ ...limitProblems(config),
357
+ ...leaderboardProblems(config),
358
+ ...discordSignInProblems(config.discordSignIn),
359
+ ...adminProblems(config.admin),
360
+ ...dedicatedProblems(config.dedicated),
361
+ ...colyseusCloudProblems(config, environment)
362
+ ].filter(isProblem);
363
+ }
364
+ /**
365
+ * A copy safe to log: every isSecret option replaced by "[redacted]" (when set) or null (D4).
366
+ * @param config The config.
367
+ * @returns The redacted copy.
368
+ */
369
+ export function describeConfig(config) {
370
+ const copy = JSON.parse(JSON.stringify(config));
371
+ for (const definition of CONFIG_OPTIONS) {
372
+ if (!definition.isSecret)
373
+ continue;
374
+ const value = readPath(copy, definition.path);
375
+ const isSet = Array.isArray(value) ? value.length > 0 : (value != null && value !== "");
376
+ writePath(copy, definition.path, isSet ? REDACTED_VALUE : null);
377
+ }
378
+ return copy;
379
+ }
380
+ /**
381
+ * The log format a config means here: "auto" is JSON under NODE_ENV=production, else text (D4).
382
+ * @param config The config.
383
+ * @param environment The environment (NODE_ENV); the process environment when omitted.
384
+ * @returns "json" or "text".
385
+ */
386
+ export function effectiveLogFormat(config, environment = process.env) {
387
+ if (config.logFormat !== "auto")
388
+ return config.logFormat;
389
+ return isProductionEnvironment(environment) ? "json" : "text";
390
+ }
391
+ /**
392
+ * Whether NODE_ENV says production.
393
+ * @param environment The environment.
394
+ * @returns True when NODE_ENV is "production".
395
+ */
396
+ export function isProductionEnvironment(environment) {
397
+ return environment.NODE_ENV === "production";
398
+ }
399
+ /**
400
+ * Merges layers over the defaults, reporting keys no option knows, and fills each dedicated scene's optional fields.
401
+ * @param layers The overrides, least important first.
402
+ * @param problems Receives unknown-key problems.
403
+ * @returns The merged config (a new object).
404
+ */
405
+ function assembleConfig(layers, problems) {
406
+ let config = cloneConfig(DEFAULT_GAME_SERVER_CONFIG);
407
+ for (const layer of layers) {
408
+ problems.push(...unknownKeyProblems(layer, DEFAULT_GAME_SERVER_CONFIG, ""));
409
+ config = mergeConfig(config, layer);
410
+ }
411
+ const scenes = isPlainObject(config.dedicated) ? config.dedicated.scenes : undefined;
412
+ if (Array.isArray(scenes))
413
+ config.dedicated.scenes = scenes.map((scene) => Object.assign({}, DEDICATED_SCENE_DEFAULTS, scene));
414
+ return config;
415
+ }
416
+ /**
417
+ * A deep copy of a config, so merges never share arrays with the defaults.
418
+ * @param config The config.
419
+ * @returns The copy.
420
+ */
421
+ function cloneConfig(config) {
422
+ return structuredClone(config);
423
+ }
424
+ /**
425
+ * Whether a value is a plain object (not an array, null or a class instance).
426
+ * @param value The value.
427
+ * @returns True for object literals and JSON objects.
428
+ */
429
+ function isPlainObject(value) {
430
+ if (value == null || typeof value !== "object" || Array.isArray(value))
431
+ return false;
432
+ const prototype = Object.getPrototypeOf(value);
433
+ return prototype === Object.prototype || prototype === null;
434
+ }
435
+ /**
436
+ * Copies overrides onto a base config: objects merge key by key, arrays and scalars replace.
437
+ * @param base The config to start from (not modified).
438
+ * @param overrides The values to apply.
439
+ * @returns A new config.
440
+ */
441
+ function mergeConfig(base, overrides) {
442
+ const merged = Object.assign({}, base);
443
+ for (const [key, overrideValue] of Object.entries(overrides)) {
444
+ if (overrideValue === undefined)
445
+ continue;
446
+ const baseValue = merged[key];
447
+ const mergesDeeper = isPlainObject(baseValue) && isPlainObject(overrideValue);
448
+ merged[key] = mergesDeeper ? mergeConfig(baseValue, overrideValue) : overrideValue;
449
+ }
450
+ return merged;
451
+ }
452
+ /**
453
+ * Problems for keys of an override object that the config does not have (typos in config files and API calls).
454
+ * Arrays (dedicated scenes and boot entries) are checked by validation, not here.
455
+ * @param layer The overrides.
456
+ * @param reference The matching part of the defaults.
457
+ * @param prefix The dotted path of the layer.
458
+ * @returns One problem per unknown key.
459
+ */
460
+ function unknownKeyProblems(layer, reference, prefix) {
461
+ if (!isPlainObject(layer) || !isPlainObject(reference))
462
+ return [];
463
+ return Object.keys(layer).flatMap((key) => {
464
+ const keyPath = prefix + key;
465
+ if (!(key in reference))
466
+ return ["unknown option " + keyPath];
467
+ return unknownKeyProblems(layer[key], reference[key], keyPath + ".");
468
+ });
469
+ }
470
+ /**
471
+ * Reads the command line: option table flags, --config and the legacy dedicated flags (D19). The subcommand must
472
+ * already be removed.
473
+ * @param argv The arguments.
474
+ * @param problems Receives parse problems.
475
+ * @returns The parsed arguments.
476
+ */
477
+ function parseArguments(argv, problems) {
478
+ const overrides = {};
479
+ const legacyValues = new Map();
480
+ const legacyFlagNames = Object.values(LEGACY_FLAGS);
481
+ let configPath = null;
482
+ for (let i = 0; i < argv.length; i++) {
483
+ const argument = argv[i];
484
+ if (!argument.startsWith("--")) {
485
+ problems.push("unexpected argument " + argument);
486
+ continue;
487
+ }
488
+ const equalsIndex = argument.indexOf("=");
489
+ const flag = (equalsIndex < 0) ? argument.slice(2) : argument.slice(2, equalsIndex);
490
+ const inlineValue = (equalsIndex < 0) ? null : argument.slice(equalsIndex + 1);
491
+ const definition = findFlagDefinition(flag);
492
+ if (definition != null && definition.valueType === "boolean") {
493
+ i += readBooleanFlag(definition, flag, inlineValue, argv[i + 1], overrides, problems);
494
+ continue;
495
+ }
496
+ const isKnown = definition != null || flag === CONFIG_FILE_FLAG || legacyFlagNames.includes(flag);
497
+ if (!isKnown) {
498
+ problems.push("unknown option --" + flag);
499
+ continue;
500
+ }
501
+ const followingValue = argv[i + 1];
502
+ const hasFollowingValue = inlineValue == null && followingValue != null && !followingValue.startsWith("--");
503
+ const value = inlineValue ?? (hasFollowingValue ? followingValue : null);
504
+ if (hasFollowingValue)
505
+ i++;
506
+ if (value == null) {
507
+ problems.push("--" + flag + " needs a value");
508
+ continue;
509
+ }
510
+ if (definition != null)
511
+ setParsedValue(overrides, definition, value, "--" + flag, problems);
512
+ else if (flag === CONFIG_FILE_FLAG)
513
+ configPath = value;
514
+ else
515
+ legacyValues.set(flag, (legacyValues.get(flag) ?? []).concat([value]));
516
+ }
517
+ return { overrides: overrides, configPath: configPath, legacySession: readLegacySession(legacyValues, problems) };
518
+ }
519
+ /**
520
+ * The option a flag (or its --no- form) sets.
521
+ * @param flag The flag without dashes or value.
522
+ * @returns The option, or undefined.
523
+ */
524
+ function findFlagDefinition(flag) {
525
+ const direct = CONFIG_OPTIONS.find((definition) => definition.flag === flag);
526
+ if (direct != null || !flag.startsWith(NEGATED_FLAG_PREFIX))
527
+ return direct;
528
+ const positiveFlag = flag.slice(NEGATED_FLAG_PREFIX.length);
529
+ return CONFIG_OPTIONS.find((definition) => definition.flag === positiveFlag && definition.valueType === "boolean");
530
+ }
531
+ /**
532
+ * Sets a boolean flag: `--flag` is true, `--no-flag` false, `--flag=<value>` or `--flag <true|false|1|0|yes|no>` that value.
533
+ * @param definition The boolean option.
534
+ * @param flag The flag as written, without dashes.
535
+ * @param inlineValue The text after "=", or null.
536
+ * @param followingArgument The next argument, which is consumed when it is a boolean word.
537
+ * @param overrides Receives the value.
538
+ * @param problems Receives a bad value.
539
+ * @returns How many following arguments were consumed (0 or 1).
540
+ */
541
+ function readBooleanFlag(definition, flag, inlineValue, followingArgument, overrides, problems) {
542
+ const isNegated = flag !== definition.flag;
543
+ if (isNegated) {
544
+ if (inlineValue != null)
545
+ problems.push("--" + flag + " takes no value");
546
+ else
547
+ writePath(overrides, definition.path, false);
548
+ return 0;
549
+ }
550
+ if (inlineValue != null) {
551
+ setParsedValue(overrides, definition, inlineValue, "--" + flag, problems);
552
+ return 0;
553
+ }
554
+ const followingIsBoolean = followingArgument != null && parseBoolean(followingArgument) != null;
555
+ writePath(overrides, definition.path, followingIsBoolean ? parseBoolean(followingArgument) : true);
556
+ return followingIsBoolean ? 1 : 0;
557
+ }
558
+ /**
559
+ * Turns the legacy dedicated flags into one scene named after the session plus its boot entry (D19).
560
+ * @param legacyValues The values of each legacy flag, in order.
561
+ * @param problems Receives a missing --scene or a bad --players.
562
+ * @returns The session, or null without --dedicated.
563
+ */
564
+ function readLegacySession(legacyValues, problems) {
565
+ const lastValue = (flag) => {
566
+ const values = legacyValues.get(flag);
567
+ return (values != null && values.length > 0) ? values[values.length - 1] : null;
568
+ };
569
+ const sessionName = lastValue(LEGACY_FLAGS.dedicated);
570
+ if (sessionName == null) {
571
+ const strayFlags = Array.from(legacyValues.keys());
572
+ if (strayFlags.length > 0)
573
+ problems.push("--" + strayFlags.join(", --") + " only apply with --dedicated <sessionName>");
574
+ return null;
575
+ }
576
+ const sceneFile = lastValue(LEGACY_FLAGS.scene);
577
+ if (sceneFile == null)
578
+ problems.push("--dedicated needs --scene <file> (the exported scene the clients load)");
579
+ const playersText = lastValue(LEGACY_FLAGS.players);
580
+ const maxPlayers = (playersText != null) ? Number(playersText) : DEDICATED_SCENE_DEFAULTS.maxPlayers;
581
+ if (playersText != null && !Number.isInteger(maxPlayers))
582
+ problems.push("--players needs a whole number, got \"" + playersText + "\"");
583
+ if (sceneFile == null)
584
+ return null;
585
+ const scene = {
586
+ name: sessionName,
587
+ sceneRoot: lastValue(LEGACY_FLAGS.sceneRoot) ?? LEGACY_SCENE_ROOT,
588
+ sceneFile: sceneFile,
589
+ projectScripts: legacyValues.get(LEGACY_FLAGS.projectScript) ?? [],
590
+ maxPlayers: maxPlayers,
591
+ onDemand: false,
592
+ idleSeconds: DEDICATED_SCENE_DEFAULTS.idleSeconds
593
+ };
594
+ return { scene: scene, boot: { sessionName: sessionName, sceneName: sessionName } };
595
+ }
596
+ /**
597
+ * Reads every option's environment variable (empty counts as unset); an option's fallback variable (PORT,
598
+ * COLYSEUS_CLOUD_TOKEN) stands in when its GAME_SERVER_ variable is unset.
599
+ * @param environment The environment.
600
+ * @param problems Receives values that do not parse.
601
+ * @returns The environment layer.
602
+ */
603
+ function parseEnvironment(environment, problems) {
604
+ const layer = {};
605
+ for (const definition of CONFIG_OPTIONS) {
606
+ const fallbackVariable = FALLBACK_ENVIRONMENT_VARIABLES.get(definition.path);
607
+ const usesFallback = fallbackVariable != null && nonEmpty(environment[definition.environmentVariable]) == null;
608
+ const variableName = (usesFallback && fallbackVariable != null) ? fallbackVariable : definition.environmentVariable;
609
+ const text = nonEmpty(environment[variableName]);
610
+ if (text != null)
611
+ setParsedValue(layer, definition, text, variableName, problems);
612
+ }
613
+ return layer;
614
+ }
615
+ /**
616
+ * Parses an option's text and writes it into a layer, or reports why it does not parse.
617
+ * @param layer Receives the value.
618
+ * @param definition The option.
619
+ * @param text The text.
620
+ * @param sourceName The flag or variable it came from, for the problem.
621
+ * @param problems Receives a bad value.
622
+ */
623
+ function setParsedValue(layer, definition, text, sourceName, problems) {
624
+ const parsed = parseOptionText(definition, text);
625
+ if (parsed == null) {
626
+ problems.push(definition.path + " (" + sourceName + ") must be " + describeValueType(definition) + ", got \"" + text + "\"");
627
+ return;
628
+ }
629
+ writePath(layer, definition.path, parsed.value);
630
+ }
631
+ /**
632
+ * Parses an option's text by its value type.
633
+ * @param definition The option.
634
+ * @param text The text.
635
+ * @returns The value, or null when the text does not parse.
636
+ */
637
+ function parseOptionText(definition, text) {
638
+ switch (definition.valueType) {
639
+ case "number": {
640
+ const value = Number(text);
641
+ return (text.trim() !== "" && Number.isFinite(value)) ? { value: value } : null;
642
+ }
643
+ case "boolean": {
644
+ const value = parseBoolean(text);
645
+ return (value != null) ? { value: value } : null;
646
+ }
647
+ case "string-list":
648
+ return { value: text.split(",").map((item) => item.trim()).filter((item) => item !== "") };
649
+ case "enum":
650
+ return (definition.allowedValues ?? []).includes(text) ? { value: text } : null;
651
+ case "nullable-string":
652
+ return { value: (text === "") ? null : text };
653
+ default:
654
+ return { value: text };
655
+ }
656
+ }
657
+ /**
658
+ * Parses a boolean word, case-insensitive.
659
+ * @param text true/false/1/0/yes/no.
660
+ * @returns The value, or null for any other text.
661
+ */
662
+ function parseBoolean(text) {
663
+ const word = text.trim().toLowerCase();
664
+ if (TRUE_WORDS.includes(word))
665
+ return true;
666
+ if (FALSE_WORDS.includes(word))
667
+ return false;
668
+ return null;
669
+ }
670
+ /**
671
+ * What values an option accepts, for problems.
672
+ * @param definition The option.
673
+ * @returns A short phrase, e.g. "a number".
674
+ */
675
+ function describeValueType(definition) {
676
+ switch (definition.valueType) {
677
+ case "number": return "a number";
678
+ case "boolean": return "true or false";
679
+ case "string-list": return "a comma-separated list";
680
+ case "enum": return "one of " + (definition.allowedValues ?? []).join(", ");
681
+ case "nullable-string": return "text or unset";
682
+ default: return "text";
683
+ }
684
+ }
685
+ /**
686
+ * The config file to read: the named one (which must exist), else the first default file in the working directory.
687
+ * @param namedPath The --config or GAME_SERVER_CONFIG path, or null.
688
+ * @param workingDirectory The folder relative paths resolve against.
689
+ * @param problems Receives a named file that does not exist.
690
+ * @returns The absolute path, or null when there is none.
691
+ */
692
+ function findConfigFile(namedPath, workingDirectory, problems) {
693
+ if (namedPath != null) {
694
+ const absolutePath = path.resolve(workingDirectory, namedPath);
695
+ if (existsSync(absolutePath))
696
+ return absolutePath;
697
+ problems.push("the config file " + absolutePath + " does not exist (--config / " + CONFIG_FILE_VARIABLE + ")");
698
+ return null;
699
+ }
700
+ const defaultPaths = DEFAULT_CONFIG_FILE_NAMES.map((fileName) => path.join(workingDirectory, fileName));
701
+ return defaultPaths.find((candidate) => existsSync(candidate)) ?? null;
702
+ }
703
+ /**
704
+ * Reads a config file: JSON, or a module whose default export is an object or a function (async allowed) returning one.
705
+ * @param filePath The absolute path.
706
+ * @param problems Receives load errors and non-object contents.
707
+ * @returns The file layer ({} on a problem).
708
+ */
709
+ async function readConfigFile(filePath, problems) {
710
+ try {
711
+ const contents = filePath.endsWith(".json") ? JSON.parse(readFileSync(filePath, "utf8")) : await readConfigModule(filePath);
712
+ if (isPlainObject(contents))
713
+ return contents;
714
+ problems.push("the config file " + filePath + " must contain an object");
715
+ }
716
+ catch (error) {
717
+ const detail = (error instanceof Error) ? error.message : String(error);
718
+ problems.push("the config file " + filePath + " could not be read: " + detail);
719
+ }
720
+ return {};
721
+ }
722
+ /**
723
+ * Imports a .mjs/.js config file and resolves its default export (calling it when it is a function).
724
+ * @param filePath The absolute path.
725
+ * @returns The exported config.
726
+ */
727
+ async function readConfigModule(filePath) {
728
+ const configModule = await import(pathToFileURL(filePath).href);
729
+ const exported = configModule.default;
730
+ return (typeof exported === "function") ? await exported() : exported;
731
+ }
732
+ /**
733
+ * Reads a dotted path of an object.
734
+ * @param target The object.
735
+ * @param dottedPath E.g. "tls.certFile".
736
+ * @returns The value, or undefined when a step is missing.
737
+ */
738
+ export function readPath(target, dottedPath) {
739
+ let current = target;
740
+ for (const key of dottedPath.split(".")) {
741
+ if (!isPlainObject(current))
742
+ return undefined;
743
+ current = current[key];
744
+ }
745
+ return current;
746
+ }
747
+ /**
748
+ * Writes a dotted path of an object, creating the objects on the way.
749
+ * @param target The object.
750
+ * @param dottedPath E.g. "tls.certFile".
751
+ * @param value The value.
752
+ */
753
+ export function writePath(target, dottedPath, value) {
754
+ const keys = dottedPath.split(".");
755
+ let current = target;
756
+ for (const key of keys.slice(0, -1)) {
757
+ if (!isPlainObject(current[key]))
758
+ current[key] = {};
759
+ current = current[key];
760
+ }
761
+ current[keys[keys.length - 1]] = value;
762
+ }
763
+ /**
764
+ * Text, or null when it is unset or empty.
765
+ * @param text The text.
766
+ * @returns The text, or null.
767
+ */
768
+ function nonEmpty(text) {
769
+ return (text == null || text === "") ? null : text;
770
+ }
771
+ /**
772
+ * Narrows a problem list entry.
773
+ * @param problem A problem, or null.
774
+ * @returns True for a problem.
775
+ */
776
+ function isProblem(problem) {
777
+ return problem != null;
778
+ }
779
+ /**
780
+ * How an option is named in problems: its path and its environment variable.
781
+ * @param optionPath The dotted path.
782
+ * @returns E.g. "udp.enabled (GAME_SERVER_UDP_ENABLED)".
783
+ */
784
+ function optionLabel(optionPath) {
785
+ const definition = CONFIG_OPTIONS.find((candidate) => candidate.path === optionPath);
786
+ return (definition != null) ? optionPath + " (" + definition.environmentVariable + ")" : optionPath;
787
+ }
788
+ /**
789
+ * Whether a value has an option's type (files and API callers may pass anything).
790
+ * @param definition The option.
791
+ * @param value The value.
792
+ * @returns A problem, or null.
793
+ */
794
+ function valueTypeProblem(definition, value) {
795
+ const isValid = hasValueType(definition, value);
796
+ return isValid ? null : optionLabel(definition.path) + " must be " + describeValueType(definition) + ", got " + JSON.stringify(value);
797
+ }
798
+ /**
799
+ * Whether a value has an option's type.
800
+ * @param definition The option.
801
+ * @param value The value.
802
+ * @returns True when it fits.
803
+ */
804
+ function hasValueType(definition, value) {
805
+ switch (definition.valueType) {
806
+ case "number": return typeof value === "number" && Number.isFinite(value);
807
+ case "boolean": return typeof value === "boolean";
808
+ case "string-list": return Array.isArray(value) && value.every((item) => typeof item === "string");
809
+ case "enum": return typeof value === "string" && (definition.allowedValues ?? []).includes(value);
810
+ case "nullable-string": return value === null || typeof value === "string";
811
+ default: return typeof value === "string";
812
+ }
813
+ }
814
+ /**
815
+ * @param config The config.
816
+ * @returns A problem when the port is not a whole number from 0 to 65535, else null.
817
+ */
818
+ function portProblem(config) {
819
+ const isValid = Number.isInteger(config.port) && config.port >= 0 && config.port <= MAX_PORT;
820
+ return isValid ? null : optionLabel("port") + " must be a whole number from 0 to " + MAX_PORT + ", got " + config.port;
821
+ }
822
+ /**
823
+ * @param config The config.
824
+ * @returns A problem when the public URL is not a ws:// or wss:// URL, else null.
825
+ */
826
+ function publicUrlProblem(config) {
827
+ if (config.publicUrl == null || /^wss?:\/\/./.test(config.publicUrl))
828
+ return null;
829
+ return optionLabel("publicUrl") + " must be a ws:// or wss:// URL, got \"" + config.publicUrl + "\"";
830
+ }
831
+ /**
832
+ * @param config The config.
833
+ * @param environment The environment (NODE_ENV).
834
+ * @returns Problems of an enabled TLS with no certificate source, and of a development certificate in production.
835
+ */
836
+ function tlsProblems(config, environment) {
837
+ const tls = config.tls;
838
+ const problems = [];
839
+ const hasFilePair = tls.certFile != null && tls.keyFile != null;
840
+ const hasPemPair = tls.certPem != null && tls.keyPem != null;
841
+ if (tls.enabled && !hasFilePair && !hasPemPair && !tls.developmentCertificate) {
842
+ problems.push(optionLabel("tls.enabled") + " needs a certificate: set " + optionLabel("tls.certFile") + " and " + optionLabel("tls.keyFile") + ", or " + optionLabel("tls.certPem") + " and " + optionLabel("tls.keyPem") + ", or " + optionLabel("tls.developmentCertificate"));
843
+ }
844
+ if (tls.developmentCertificate && isProductionEnvironment(environment)) {
845
+ problems.push(optionLabel("tls.developmentCertificate") + " is refused when NODE_ENV=production: use a real certificate");
846
+ }
847
+ return problems;
848
+ }
849
+ /**
850
+ * The settings that break routing behind Colyseus Cloud's NGINX, which a Cloud deploy must not turn on.
851
+ * @param config The config.
852
+ * @param environment The environment.
853
+ * @returns One problem per such setting; none on any other host.
854
+ */
855
+ function colyseusCloudProblems(config, environment) {
856
+ if (!isColyseusCloud(environment))
857
+ return [];
858
+ const problems = [];
859
+ if (config.tls.enabled)
860
+ problems.push("Colyseus Cloud serves TLS itself; turn off GAME_SERVER_TLS_ENABLED");
861
+ if (config.udp.enabled)
862
+ problems.push("UDP does not reach the server on Colyseus Cloud; turn off GAME_SERVER_UDP_ENABLED");
863
+ if (config.publicAddress != null)
864
+ problems.push("Colyseus Cloud sets the public address; unset GAME_SERVER_PUBLIC_ADDRESS");
865
+ return problems;
866
+ }
867
+ /**
868
+ * @param config The config.
869
+ * @returns A problem when UDP is enabled without TLS (WebTransport runs over HTTP/3, which needs TLS), else null.
870
+ */
871
+ function udpProblem(config) {
872
+ if (!config.udp.enabled || config.tls.enabled)
873
+ return null;
874
+ return optionLabel("udp.enabled") + " needs " + optionLabel("tls.enabled") + ": WebTransport runs over HTTP/3, which always uses TLS";
875
+ }
876
+ /**
877
+ * @param auth The auth settings.
878
+ * @returns A problem when auth is enabled but its mode lacks what it needs, else null.
879
+ */
880
+ function authProblem(auth) {
881
+ if (!auth.enabled)
882
+ return null;
883
+ if (auth.mode === "shared-secret" && auth.sharedSecret.tokens.length === 0) {
884
+ return optionLabel("auth.mode") + " shared-secret needs at least one token in " + optionLabel("auth.sharedSecret.tokens");
885
+ }
886
+ if (auth.mode === "jwt" && auth.jwt.secret == null && auth.jwt.jwksUrl == null) {
887
+ return optionLabel("auth.mode") + " jwt needs " + optionLabel("auth.jwt.secret") + " or " + optionLabel("auth.jwt.jwksUrl");
888
+ }
889
+ if (auth.mode === "webhook" && auth.webhook.url == null) {
890
+ return optionLabel("auth.mode") + " webhook needs " + optionLabel("auth.webhook.url");
891
+ }
892
+ return null;
893
+ }
894
+ /**
895
+ * @param config The config.
896
+ * @returns A problem per limit, timeout or drain time that is out of range.
897
+ */
898
+ function limitProblems(config) {
899
+ const positivePaths = CONFIG_OPTIONS
900
+ .filter((definition) => definition.path.startsWith("limits.") && definition.valueType === "number")
901
+ .map((definition) => definition.path)
902
+ .concat(["auth.webhook.timeoutMs"]);
903
+ const problems = positivePaths
904
+ .filter((optionPath) => readPath(config, optionPath) <= 0)
905
+ .map((optionPath) => optionLabel(optionPath) + " must be more than 0, got " + String(readPath(config, optionPath)));
906
+ if (config.drainSeconds < 0)
907
+ problems.push(optionLabel("drainSeconds") + " must be 0 or more, got " + config.drainSeconds);
908
+ return problems;
909
+ }
910
+ /**
911
+ * The problems of the leaderboard options (game-server-leaderboards D11).
912
+ * @param config The config.
913
+ * @returns A problem when the definitions file is an empty path.
914
+ */
915
+ function leaderboardProblems(config) {
916
+ return (config.leaderboards.definitionsFile === "") ? ["leaderboards.definitionsFile must not be empty"] : [];
917
+ }
918
+ /** The admin options that must be more than 0. */
919
+ const POSITIVE_ADMIN_OPTION_PATHS = ["admin.sessionIdleMinutes", "admin.sessionMaxHours", "admin.logBufferEntries", "admin.metricsHistoryHours"];
920
+ /** An http:// or https:// URL (admin.colyseusCloud.apiUrl). */
921
+ const HTTP_URL_PATTERN = /^https?:\/\//;
922
+ /**
923
+ * The problems of the admin options (game-server-admin-console DR §4): positive durations and sizes, network
924
+ * entries that parse as addresses or CIDR ranges, and an http(s) Colyseus Cloud API URL.
925
+ * @param admin The admin config.
926
+ * @returns One sentence per problem.
927
+ */
928
+ function adminProblems(admin) {
929
+ const problems = POSITIVE_ADMIN_OPTION_PATHS
930
+ .filter((optionPath) => readPath({ admin: admin }, optionPath) <= 0)
931
+ .map((optionPath) => optionLabel(optionPath) + " must be more than 0, got " + String(readPath({ admin: admin }, optionPath)));
932
+ const probeList = new BlockList();
933
+ for (const entry of admin.allowedNetworks) {
934
+ if (!addNetworkEntry(probeList, entry))
935
+ problems.push(optionLabel("admin.allowedNetworks") + " must list addresses or CIDR ranges, got " + JSON.stringify(entry));
936
+ }
937
+ if (!HTTP_URL_PATTERN.test(admin.colyseusCloud.apiUrl))
938
+ problems.push(optionLabel("admin.colyseusCloud.apiUrl") + " must be an http:// or https:// URL, got " + JSON.stringify(admin.colyseusCloud.apiUrl));
939
+ return problems.concat(openIdProblems(admin.oidc));
940
+ }
941
+ /** A role mapping entry: "claim value=role" (DR §4). */
942
+ const ROLE_MAPPING_PATTERN = /^[^=]+=(owner|admin|operator|moderator|developer|viewer)$/;
943
+ /**
944
+ * The problems of the OpenID Connect options (DR §4): enabled needs issuer and clientId, and each role mapping entry
945
+ * must be "value=role".
946
+ * @param oidc The OpenID Connect settings.
947
+ * @returns One sentence per problem.
948
+ */
949
+ function openIdProblems(oidc) {
950
+ const problems = [];
951
+ if (oidc.enabled && (oidc.issuer == null || oidc.issuer === ""))
952
+ problems.push(optionLabel("admin.oidc.issuer") + " is required when admin.oidc.enabled is true");
953
+ if (oidc.enabled && (oidc.clientId == null || oidc.clientId === ""))
954
+ problems.push(optionLabel("admin.oidc.clientId") + " is required when admin.oidc.enabled is true");
955
+ for (const entry of oidc.roleMapping) {
956
+ if (!ROLE_MAPPING_PATTERN.test(entry))
957
+ problems.push(optionLabel("admin.oidc.roleMapping") + " entries must be value=role with a console role, got " + JSON.stringify(entry));
958
+ }
959
+ return problems;
960
+ }
961
+ /**
962
+ * The problems of the Discord sign-in section (discord-sign-in D12); none while it is off.
963
+ * @param discord The section.
964
+ * @returns One message per problem.
965
+ */
966
+ function discordSignInProblems(discord) {
967
+ if (!discord.enabled)
968
+ return [];
969
+ const problems = [];
970
+ if (discord.clientId == null || discord.clientId === "")
971
+ problems.push(optionLabel("discordSignIn.clientId") + " is required when discordSignIn.enabled is true");
972
+ if (discord.clientSecret == null || discord.clientSecret === "")
973
+ problems.push(optionLabel("discordSignIn.clientSecret") + " is required when discordSignIn.enabled is true");
974
+ if (!isDiscordCallbackUri(discord.redirectUri))
975
+ problems.push(optionLabel("discordSignIn.redirectUri") + " must be an http(s) address ending in " + DISCORD_CALLBACK_PATH);
976
+ if (discord.returnOrigins.length === 0)
977
+ problems.push(optionLabel("discordSignIn.returnOrigins") + " needs at least one game origin");
978
+ for (const origin of discord.returnOrigins) {
979
+ if (!isPlainOrigin(origin))
980
+ problems.push(optionLabel("discordSignIn.returnOrigins") + " entry " + JSON.stringify(origin) + " is not an origin (scheme://host[:port])");
981
+ }
982
+ if (!Number.isInteger(discord.sessionDays) || discord.sessionDays < MINIMUM_SESSION_DAYS || discord.sessionDays > MAXIMUM_SESSION_DAYS)
983
+ problems.push(optionLabel("discordSignIn.sessionDays") + " must be a whole number from 1 to 365");
984
+ return problems;
985
+ }
986
+ /**
987
+ * Whether a text is an http(s) URL whose path is the Discord callback path.
988
+ * @param uri The configured redirect address.
989
+ * @returns True when it can be registered with Discord as this server's callback.
990
+ */
991
+ function isDiscordCallbackUri(uri) {
992
+ if (uri == null)
993
+ return false;
994
+ try {
995
+ const parsed = new URL(uri);
996
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.pathname === DISCORD_CALLBACK_PATH && parsed.search === "" && parsed.hash === "";
997
+ }
998
+ catch {
999
+ return false;
1000
+ }
1001
+ }
1002
+ /**
1003
+ * Whether a text is a bare origin: http(s), a host, an optional port, and nothing else.
1004
+ * @param origin The text.
1005
+ * @returns True for "https://game.example.com" or "http://localhost:6178".
1006
+ */
1007
+ export function isPlainOrigin(origin) {
1008
+ try {
1009
+ const parsed = new URL(origin);
1010
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.origin === origin;
1011
+ }
1012
+ catch {
1013
+ return false;
1014
+ }
1015
+ }
1016
+ /**
1017
+ * @param dedicated The dedicated session settings.
1018
+ * @returns Problems of malformed scenes, duplicate scene names and boot entries naming unknown scenes.
1019
+ */
1020
+ function dedicatedProblems(dedicated) {
1021
+ const hasLists = isPlainObject(dedicated) && Array.isArray(dedicated.scenes) && Array.isArray(dedicated.boot);
1022
+ if (!hasLists)
1023
+ return ["dedicated.scenes and dedicated.boot must be lists"];
1024
+ const malformedEntries = !dedicated.scenes.every(isPlainObject) || !dedicated.boot.every(isPlainObject);
1025
+ if (malformedEntries)
1026
+ return ["every dedicated.scenes and dedicated.boot entry must be an object"];
1027
+ const problems = dedicated.scenes.flatMap((scene, index) => sceneProblems(scene, index));
1028
+ const sceneNames = dedicated.scenes.map((scene) => scene.name);
1029
+ const duplicateNames = sceneNames.filter((name, index) => sceneNames.indexOf(name) !== index);
1030
+ for (const name of new Set(duplicateNames))
1031
+ problems.push("dedicated.scenes has more than one scene named \"" + name + "\"");
1032
+ for (const boot of dedicated.boot) {
1033
+ if (!sceneNames.includes(boot.sceneName))
1034
+ problems.push("dedicated.boot session \"" + boot.sessionName + "\" names the unknown scene \"" + boot.sceneName + "\"");
1035
+ }
1036
+ return problems;
1037
+ }
1038
+ /**
1039
+ * @param scene One dedicated scene.
1040
+ * @param index Its position in dedicated.scenes.
1041
+ * @returns Problems of missing or out-of-range fields.
1042
+ */
1043
+ function sceneProblems(scene, index) {
1044
+ const label = "dedicated.scenes[" + index + "]";
1045
+ const problems = [];
1046
+ for (const field of ["name", "sceneRoot", "sceneFile"]) {
1047
+ if (typeof scene[field] !== "string" || scene[field] === "")
1048
+ problems.push(label + "." + field + " must be non-empty text");
1049
+ }
1050
+ if (!Array.isArray(scene.projectScripts) || !scene.projectScripts.every((script) => typeof script === "string"))
1051
+ problems.push(label + ".projectScripts must be a list of URLs");
1052
+ if (!Number.isInteger(scene.maxPlayers) || scene.maxPlayers <= 0)
1053
+ problems.push(label + ".maxPlayers must be a whole number more than 0, got " + String(scene.maxPlayers));
1054
+ if (typeof scene.onDemand !== "boolean")
1055
+ problems.push(label + ".onDemand must be true or false");
1056
+ if (typeof scene.idleSeconds !== "number" || scene.idleSeconds < 0)
1057
+ problems.push(label + ".idleSeconds must be 0 or more, got " + String(scene.idleSeconds));
1058
+ return problems;
1059
+ }