configs/
config.rs

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    /// GCP project ID that hosts the Pub/Sub topic Play Console publishes
142    /// Real-time Developer Notifications (RTDN) to. The monolith pulls voided
143    /// purchase events from a subscription on that topic to mark refunds and
144    /// ban abusive accounts from further purchases.
145    pub pubsub_project_id: String,
146    /// Pull subscription name on the RTDN topic. Combined with
147    /// `pubsub_project_id` into `projects/<id>/subscriptions/<name>` for the
148    /// Pub/Sub Subscriber API. Empty string keeps the puller disabled at
149    /// startup — useful before the GCP setup is finalized.
150    pub pubsub_rtdn_subscription: String,
151    /// Shared secret required on the Pub/Sub push endpoint
152    /// `POST /api/play_store/rtdn`. The push subscription must send it either
153    /// as a `token` query parameter on the push URL or as an
154    /// `Authorization: Bearer <token>` header. Unset/empty disables the push
155    /// endpoint entirely (the pull path via `pubsub_rtdn_subscription` is
156    /// unaffected) — without it anyone could forge refund notifications.
157    #[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    /// Shared admin secret. Used for two things:
165    ///   * `POST /api/admin/*` middleware (panel → monolith calls).
166    ///   * `X-Admin-API-Key` when calling realms-service `/ack_realm`.
167    ///
168    /// Ops provision the same value into the realms-service secret under
169    /// `admin_api_key` so both services accept each other's requests.
170    #[serde(default)]
171    pub admin_api_key: Option<String>,
172    /// HMAC key for the `overlord-auth` reconnect cookie (JWT). Unset
173    /// disables cookie auth entirely — there is deliberately no built-in
174    /// default, since a publicly known key lets anyone forge a session
175    /// cookie for an arbitrary character.
176    #[serde(default)]
177    pub auth_jwt_secret: Option<String>,
178    /// Realms-service entry point (e.g. `https://realms.overlord.lumex.team`).
179    /// Required on Prestable/Prod so the monolith can acknowledge that the
180    /// connecting user was actually routed here by `/pick_realm`.
181    #[serde(default)]
182    pub realms_service_url: Option<String>,
183    /// Slug that this monolith deploy uses to identify itself to realms-service
184    /// (matches `realms.name` in the realms-service DB, e.g. `r0`).
185    #[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}