1use crate::tests_game_config::generate_game_config_for_tests;
2use anyhow::Context;
3use serde::{Deserialize, Serialize};
4use std::{cmp::Ordering, fmt, num::NonZeroU32, str::FromStr, sync::Arc};
5
6#[derive(
7 Debug, Clone, PartialEq, Eq, Deserialize, strum_macros::Display, strum_macros::EnumString,
8)]
9#[serde(rename_all = "lowercase")]
10#[strum(serialize_all = "lowercase")]
11pub enum Environment {
12 Test,
13 Dev,
14 Prestable,
15 Prod,
16}
17
18#[derive(Serialize, Deserialize, Debug, Clone)]
19pub struct WebsocketConfig {
20 #[serde(default = "WebsocketConfig::write_queue_size")]
21 pub write_queue_size: usize,
22 #[serde(default = "WebsocketConfig::max_request_per_sec")]
23 pub max_request_per_sec: NonZeroU32,
24}
25
26impl WebsocketConfig {
27 fn write_queue_size() -> usize {
28 100
29 }
30
31 fn max_request_per_sec() -> NonZeroU32 {
32 NonZeroU32::new(20u32).unwrap()
37 }
38}
39
40#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
41pub struct Version {
42 pub major: u8,
43 pub minor: u8,
44 pub patch: u8,
45}
46
47impl FromStr for Version {
48 type Err = String;
49
50 fn from_str(s: &str) -> Result<Self, Self::Err> {
51 let parts: Vec<&str> = s.split('.').collect();
52 if parts.len() != 3 {
53 return Err(format!("Invalid version string: {}", s));
54 }
55 Ok(Version {
56 major: parts[0].parse().map_err(|_| "Bad major")?,
57 minor: parts[1].parse().map_err(|_| "Bad minor")?,
58 patch: parts[2].parse().map_err(|_| "Bad patch")?,
59 })
60 }
61}
62
63impl Ord for Version {
64 fn cmp(&self, other: &Self) -> Ordering {
65 (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
66 }
67}
68
69impl PartialOrd for Version {
70 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
71 Some(self.cmp(other))
72 }
73}
74
75impl fmt::Display for Version {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
78 }
79}
80
81impl Version {
82 pub fn is_compatible_with(&self, other: &Version) -> bool {
83 if self.major != other.major {
84 return false;
85 }
86
87 if self.minor != other.minor {
88 return false;
89 }
90
91 true
92 }
93}
94
95#[derive(Clone, Debug, Deserialize)]
96pub struct ModerationConfig {
97 pub language_service_enabled: bool,
98 pub min_confidence_to_block: f32,
99}
100
101#[derive(Clone, Debug, Deserialize)]
102pub struct InfraConfig {
103 #[serde(default = "InfraConfig::port_default")]
104 pub port: u16,
105 pub env: Environment,
106 pub ws_config: WebsocketConfig,
107 pub tg_bot_token: String,
108 pub db_pool_config: deadpool_postgres::Config,
109 pub db_use_tls: bool,
110 #[serde(default = "InfraConfig::txn_max_retries_default")]
111 pub txn_max_retries: u16,
112 #[serde(default = "InfraConfig::db_request_referrals_limit")]
113 pub db_request_referrals_limit: u16,
114 #[serde(default = "InfraConfig::db_request_abilities_limit")]
115 pub db_request_abilities_limit: u16,
116 pub hostname: String,
117 pub web_hostname: Option<String>,
118 pub db_sync_period: f64,
119 #[serde(default = "InfraConfig::state_updater_enabled_default")]
120 pub state_updater_enabled: bool,
121 #[serde(default = "InfraConfig::game_tick_enabled_default")]
122 pub game_tick_enabled: bool,
123 pub cron_trigger_period: f64,
124 pub webauthn_rp_id: String,
125 pub webauthn_rp_origin: String,
126 pub allowed_usernames: Vec<String>,
127 pub otlp_http_export_endpoint: String,
128 pub otlp_username: Option<String>,
129 pub otlp_password: Option<String>,
130 #[serde(default = "InfraConfig::otlp_sampling_rate_default")]
131 pub otlp_sampling_rate: f64,
132 pub show_metrics_logs: bool,
133 pub locale_folder_path: String,
134 pub default_locale: String,
135 pub analytics_gcs_logs_enabled: bool,
136 pub analytics_gcs_bucket: Option<String>,
137 pub analytics_gcs_folder: Option<String>,
138 pub analytics_gcs_upload_interval_secs: u64,
139 pub pyroscope_url: Option<String>,
140 pub firebase_project_id: String,
141 pub backend_version: Version,
142 #[serde(default = "InfraConfig::disconnect_log_events_count_default")]
143 pub disconnect_log_events_count: usize,
144 pub android_package_name: String,
145 pub pubsub_project_id: String,
150 pub pubsub_rtdn_subscription: String,
155 #[serde(default)]
162 pub rtdn_push_token: Option<String>,
163 #[serde(default = "InfraConfig::google_play_max_retries_default")]
164 pub google_play_max_retries: u16,
165 #[serde(default = "InfraConfig::google_play_retry_base_ms_default")]
166 pub google_play_retry_base_ms: u64,
167 pub moderation_config: ModerationConfig,
168 #[serde(default)]
175 pub admin_api_key: Option<String>,
176 #[serde(default)]
181 pub auth_jwt_secret: Option<String>,
182 #[serde(default)]
186 pub realms_service_url: Option<String>,
187 #[serde(default)]
190 pub realm_name: Option<String>,
191}
192
193impl InfraConfig {
194 pub const fn port_default() -> u16 {
195 3000
196 }
197
198 pub const fn txn_max_retries_default() -> u16 {
199 5
200 }
201
202 pub const fn db_request_referrals_limit() -> u16 {
203 10
204 }
205
206 pub const fn db_request_abilities_limit() -> u16 {
207 120
208 }
209
210 pub const fn google_play_max_retries_default() -> u16 {
211 3
212 }
213
214 pub const fn google_play_retry_base_ms_default() -> u64 {
215 500
216 }
217
218 pub const fn disconnect_log_events_count_default() -> usize {
219 20
220 }
221
222 pub fn otlp_sampling_rate_default() -> f64 {
223 1.0
224 }
225
226 pub const fn state_updater_enabled_default() -> bool {
227 true
228 }
229
230 pub const fn game_tick_enabled_default() -> bool {
231 true
232 }
233}
234
235#[derive(Clone, Debug, Deserialize)]
236pub struct Config {
237 pub infra_config: InfraConfig,
238 #[serde(deserialize_with = "deserialize_game_config_arc")]
239 pub game_config: Arc<crate::game_config::GameConfig>,
240 #[serde(skip)]
241 pub config_path: std::path::PathBuf,
242}
243
244fn deserialize_game_config_arc<'de, D>(
245 deserializer: D,
246) -> Result<Arc<crate::game_config::GameConfig>, D::Error>
247where
248 D: serde::Deserializer<'de>,
249{
250 let game_config = crate::game_config::GameConfig::deserialize(deserializer)?;
251 Ok(Arc::new(game_config))
252}
253
254impl Config {
255 pub fn load(
256 path: impl AsRef<std::path::Path> + Send + Sync + Clone + 'static,
257 port: Option<u16>,
258 ) -> anyhow::Result<Self> {
259 let config_path = path.as_ref().to_path_buf();
260 let mut config = config_parser::parse_from_file::<Self>(path)?;
261
262 config.game_config.validate();
263 config.config_path = config_path;
264
265 if let Some(port) = port {
266 config.infra_config.port = port;
267 }
268
269 if let Ok(v) = std::env::var("ENVIRONMENT") {
270 config.infra_config.env = v
271 .parse::<Environment>()
272 .map_err(|e| anyhow::anyhow!("Invalid ENVIRONMENT value `{v}`: {e}"))?;
273 }
274
275 if let Ok(v) = std::env::var("DB_URL") {
280 config.infra_config.db_pool_config.url = Some(v);
281 }
282
283 if let Ok(v) = std::env::var("DB_HOST") {
284 config.infra_config.db_pool_config.host = Some(v);
285 }
286
287 if let Ok(v) = std::env::var("DB_PORT") {
288 let parsed = v
289 .parse::<u16>()
290 .with_context(|| format!("`{v}` is not a valid u16 for DB_PORT"))?;
291 config.infra_config.db_pool_config.port = Some(parsed);
292 }
293
294 if let Ok(v) = std::env::var("DB_NAME") {
295 config.infra_config.db_pool_config.dbname = Some(v);
296 }
297 if let Ok(v) = std::env::var("DB_USER") {
298 config.infra_config.db_pool_config.user = Some(v);
299 }
300 if let Ok(v) = std::env::var("DB_PASSWORD") {
301 config.infra_config.db_pool_config.password = Some(v);
302 }
303
304 if let Ok(v) = std::env::var("FIREBASEE_PROJECT_ID") {
305 config.infra_config.firebase_project_id = v;
306 }
307
308 if let Ok(v) = std::env::var("PUBSUB_PROJECT_ID") {
309 config.infra_config.pubsub_project_id = v;
310 }
311 if let Ok(v) = std::env::var("PUBSUB_RTDN_SUBSCRIPTION") {
312 config.infra_config.pubsub_rtdn_subscription = v;
313 }
314 if let Ok(v) = std::env::var("RTDN_PUSH_TOKEN") {
315 let trimmed = v.trim();
316 config.infra_config.rtdn_push_token = if trimmed.is_empty() {
317 None
318 } else {
319 Some(trimmed.to_string())
320 };
321 }
322 if let Ok(v) = std::env::var("AUTH_JWT_SECRET") {
323 let trimmed = v.trim();
324 config.infra_config.auth_jwt_secret = if trimmed.is_empty() {
325 None
326 } else {
327 Some(trimmed.to_string())
328 };
329 }
330
331 if let Ok(v) = std::env::var("REALMS_SERVICE_URL") {
332 let trimmed = v.trim();
333 config.infra_config.realms_service_url = if trimmed.is_empty() {
334 None
335 } else {
336 Some(trimmed.to_string())
337 };
338 }
339 if let Ok(v) = std::env::var("REALM_NAME") {
340 let trimmed = v.trim();
341 config.infra_config.realm_name = if trimmed.is_empty() {
342 None
343 } else {
344 Some(trimmed.to_string())
345 };
346 }
347
348 if std::env::var_os("ALLOWED_PUBLIC").is_some() {
349 config.infra_config.allowed_usernames.clear();
350 }
351
352 if let Ok(v) = std::env::var("BACKEND_VERSION") {
353 let version: Version = v.parse().map_err(|e: String| {
354 anyhow::anyhow!("Invalid BACKEND_VERSION in ENV `{v}`: {e}")
355 })?;
356 config.infra_config.backend_version = version;
357 }
358
359 if let Ok(v) = std::env::var("GRAFANA_OTLP_ENDPOINT") {
360 config.infra_config.otlp_http_export_endpoint = v;
361 }
362
363 if let Ok(v) = std::env::var("GRAFANA_OTLP_USERNAME") {
364 config.infra_config.otlp_username = Some(v);
365 }
366
367 if let Ok(v) = std::env::var("GRAFANA_OTLP_PASSWORD") {
368 config.infra_config.otlp_password = Some(v);
369 }
370
371 if let Ok(v) = std::env::var("GRAFANA_OTLP_SAMPLING_RATE") {
372 config.infra_config.otlp_sampling_rate = v.parse::<f64>().with_context(|| {
373 format!("`{v}` is not a valid f64 for GRAFANA_OTLP_SAMPLING_RATE")
374 })?;
375 }
376
377 if let Ok(v) = std::env::var("GRAFANA_PYROSCOPE_URL") {
378 config.infra_config.pyroscope_url = Some(v);
379 }
380
381 if let Ok(v) = std::env::var("ANALYTICS_GCS_BUCKET") {
382 let bucket = v.trim();
383 config.infra_config.analytics_gcs_bucket = if bucket.is_empty() {
384 None
385 } else {
386 Some(bucket.to_string())
387 };
388 }
389
390 if let Ok(v) = std::env::var("ANALYTICS_GCS_FOLDER") {
391 let folder = v.trim().trim_matches('/');
392 config.infra_config.analytics_gcs_folder = if folder.is_empty() {
393 None
394 } else {
395 Some(folder.to_string())
396 };
397 }
398
399 if let Ok(v) = std::env::var("ANALYTICS_GCS_LOGS_ENABLED") {
400 config.infra_config.analytics_gcs_logs_enabled =
401 v.parse::<bool>().with_context(|| {
402 format!("`{v}` is not a valid bool for ANALYTICS_GCS_LOGS_ENABLED")
403 })?;
404 }
405
406 if let Ok(v) = std::env::var("ANALYTICS_GCS_UPLOAD_INTERVAL_SECS") {
407 config.infra_config.analytics_gcs_upload_interval_secs =
408 v.parse::<u64>().with_context(|| {
409 format!("`{v}` is not a valid u64 for ANALYTICS_GCS_UPLOAD_INTERVAL_SECS")
410 })?;
411 }
412
413 if let Ok(v) = std::env::var("ADMIN_API_KEY") {
414 config.infra_config.admin_api_key = Some(v);
415 }
416
417 Ok(config)
418 }
419
420 pub fn load_for_testing(
421 path: impl AsRef<std::path::Path> + Send + Sync + Clone + 'static,
422 ) -> anyhow::Result<Self> {
423 let config_path = path.as_ref().to_path_buf();
424 let mut infra_config = config_parser::parse_from_file::<InfraConfig>(config_path.clone())?;
425
426 if let Ok(url) = std::env::var("OVERLORD_TEST_DB_URL") {
437 infra_config.db_pool_config.url = Some(url);
438 }
439
440 Ok(Config {
441 infra_config,
442 game_config: Arc::new(generate_game_config_for_tests()),
443 config_path,
444 })
445 }
446}