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        // Anti-abuse cap, not a pacing tool. One chest-open cycle is up to
33        // ~3 frames (OpenItemCase + Equip/Sell + analytics), so fast tapping
34        // legitimately reaches ~15 msg/s; 5 silently dropped taps here and
35        // PendingActionsJournal turned them into forced reconnects.
36        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    /// GCP project ID that hosts the Pub/Sub topic Play Console publishes
146    /// Real-time Developer Notifications (RTDN) to. The monolith pulls voided
147    /// purchase events from a subscription on that topic to mark refunds and
148    /// ban abusive accounts from further purchases.
149    pub pubsub_project_id: String,
150    /// Pull subscription name on the RTDN topic. Combined with
151    /// `pubsub_project_id` into `projects/<id>/subscriptions/<name>` for the
152    /// Pub/Sub Subscriber API. Empty string keeps the puller disabled at
153    /// startup — useful before the GCP setup is finalized.
154    pub pubsub_rtdn_subscription: String,
155    /// Shared secret required on the Pub/Sub push endpoint
156    /// `POST /api/play_store/rtdn`. The push subscription must send it either
157    /// as a `token` query parameter on the push URL or as an
158    /// `Authorization: Bearer <token>` header. Unset/empty disables the push
159    /// endpoint entirely (the pull path via `pubsub_rtdn_subscription` is
160    /// unaffected) — without it anyone could forge refund notifications.
161    #[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    /// Shared admin secret. Used for two things:
169    ///   * `POST /api/admin/*` middleware (panel → monolith calls).
170    ///   * `X-Admin-API-Key` when calling realms-service `/ack_realm`.
171    ///
172    /// Ops provision the same value into the realms-service secret under
173    /// `admin_api_key` so both services accept each other's requests.
174    #[serde(default)]
175    pub admin_api_key: Option<String>,
176    /// HMAC key for the `overlord-auth` reconnect cookie (JWT). Unset
177    /// disables cookie auth entirely — there is deliberately no built-in
178    /// default, since a publicly known key lets anyone forge a session
179    /// cookie for an arbitrary character.
180    #[serde(default)]
181    pub auth_jwt_secret: Option<String>,
182    /// Realms-service entry point (e.g. `https://realms.overlord.lumex.team`).
183    /// Required on Prestable/Prod so the monolith can acknowledge that the
184    /// connecting user was actually routed here by `/pick_realm`.
185    #[serde(default)]
186    pub realms_service_url: Option<String>,
187    /// Slug that this monolith deploy uses to identify itself to realms-service
188    /// (matches `realms.name` in the realms-service DB, e.g. `r0`).
189    #[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        // Whole-URL override. The config carries a URL-style address, and
276        // DB_HOST/DB_PORT are applied ON TOP of it by deadpool (which fails
277        // with "invalid number of ports"), so pointing a process at another
278        // cluster — a sim DB, or another worktree's dev DB — goes through here.
279        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        // Point the server-under-test at the same cluster testenv clones the
427        // schema from. The whole URL is replaced rather than the DB_PORT field
428        // because deadpool applies `port` ON TOP of the URL's own port, which
429        // fails with "invalid number of ports".
430        //
431        // This only knows the CockroachDB variable. Under
432        // `OVERLORD_TEST_DB_KIND=pg` the right URL is `OVERLORD_TEST_PG_URL`,
433        // which `testenv::db_url()` resolves — monolith's `tests/common` calls
434        // it and overwrites this field straight after. A new test harness must
435        // do the same rather than rely on what is set here.
436        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}