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