configs/
config.rs

1use crate::tests_game_config::generate_game_config_for_tests;
2use anyhow::Context;
3use serde::{Deserialize, Serialize};
4use std::{num::NonZeroU32, 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
40pub use axum_service_kit::Version;
41
42#[derive(Clone, Debug, Deserialize)]
43pub struct ModerationConfig {
44    pub language_service_enabled: bool,
45    pub min_confidence_to_block: f32,
46}
47
48#[derive(Clone, Debug, Deserialize)]
49pub struct InfraConfig {
50    #[serde(default = "InfraConfig::port_default")]
51    pub port: u16,
52    pub env: Environment,
53    pub ws_config: WebsocketConfig,
54    pub tg_bot_token: String,
55    pub db_pool_config: deadpool_postgres::Config,
56    pub db_use_tls: bool,
57    #[serde(default = "InfraConfig::txn_max_retries_default")]
58    pub txn_max_retries: u16,
59    #[serde(default = "InfraConfig::db_request_referrals_limit")]
60    pub db_request_referrals_limit: u16,
61    #[serde(default = "InfraConfig::db_request_abilities_limit")]
62    pub db_request_abilities_limit: u16,
63    pub hostname: String,
64    pub web_hostname: Option<String>,
65    pub db_sync_period: f64,
66    #[serde(default = "InfraConfig::state_updater_enabled_default")]
67    pub state_updater_enabled: bool,
68    #[serde(default = "InfraConfig::game_tick_enabled_default")]
69    pub game_tick_enabled: bool,
70    pub cron_trigger_period: f64,
71    pub webauthn_rp_id: String,
72    pub webauthn_rp_origin: String,
73    pub allowed_usernames: Vec<String>,
74    pub otlp_http_export_endpoint: String,
75    pub otlp_username: Option<String>,
76    pub otlp_password: Option<String>,
77    #[serde(default = "InfraConfig::otlp_sampling_rate_default")]
78    pub otlp_sampling_rate: f64,
79    pub show_metrics_logs: bool,
80    pub locale_folder_path: String,
81    pub default_locale: String,
82    pub analytics_gcs_logs_enabled: bool,
83    pub analytics_gcs_bucket: Option<String>,
84    pub analytics_gcs_folder: Option<String>,
85    pub analytics_gcs_upload_interval_secs: u64,
86    pub pyroscope_url: Option<String>,
87    pub firebase_project_id: String,
88    pub backend_version: Version,
89    #[serde(default = "InfraConfig::disconnect_log_events_count_default")]
90    pub disconnect_log_events_count: usize,
91    pub android_package_name: String,
92    /// GCP project ID that hosts the Pub/Sub topic Play Console publishes
93    /// Real-time Developer Notifications (RTDN) to. The monolith pulls voided
94    /// purchase events from a subscription on that topic to mark refunds and
95    /// ban abusive accounts from further purchases.
96    pub pubsub_project_id: String,
97    /// Pull subscription name on the RTDN topic. Combined with
98    /// `pubsub_project_id` into `projects/<id>/subscriptions/<name>` for the
99    /// Pub/Sub Subscriber API. Empty string keeps the puller disabled at
100    /// startup — useful before the GCP setup is finalized.
101    pub pubsub_rtdn_subscription: String,
102    /// Shared secret required on the Pub/Sub push endpoint
103    /// `POST /api/play_store/rtdn`. The push subscription must send it either
104    /// as a `token` query parameter on the push URL or as an
105    /// `Authorization: Bearer <token>` header. Unset/empty disables the push
106    /// endpoint entirely (the pull path via `pubsub_rtdn_subscription` is
107    /// unaffected) — without it anyone could forge refund notifications.
108    #[serde(default)]
109    pub rtdn_push_token: Option<String>,
110    #[serde(default = "InfraConfig::google_play_max_retries_default")]
111    pub google_play_max_retries: u16,
112    #[serde(default = "InfraConfig::google_play_retry_base_ms_default")]
113    pub google_play_retry_base_ms: u64,
114    pub moderation_config: ModerationConfig,
115    /// Shared admin secret. Used for two things:
116    ///   * `POST /api/admin/*` middleware (panel → monolith calls).
117    ///   * `X-Admin-API-Key` when calling realms-service `/ack_realm`.
118    ///
119    /// Ops provision the same value into the realms-service secret under
120    /// `admin_api_key` so both services accept each other's requests.
121    #[serde(default)]
122    pub admin_api_key: Option<String>,
123    /// HMAC key for the `overlord-auth` reconnect cookie (JWT). Unset
124    /// disables cookie auth entirely — there is deliberately no built-in
125    /// default, since a publicly known key lets anyone forge a session
126    /// cookie for an arbitrary character.
127    #[serde(default)]
128    pub auth_jwt_secret: Option<String>,
129    /// Realms-service entry point (e.g. `https://realms.overlord.lumex.team`).
130    /// Required on Prestable/Prod so the monolith can acknowledge that the
131    /// connecting user was actually routed here by `/pick_realm`.
132    #[serde(default)]
133    pub realms_service_url: Option<String>,
134    /// Slug that this monolith deploy uses to identify itself to realms-service
135    /// (matches `realms.name` in the realms-service DB, e.g. `r0`).
136    #[serde(default)]
137    pub realm_name: Option<String>,
138}
139
140impl InfraConfig {
141    pub const fn port_default() -> u16 {
142        3000
143    }
144
145    pub const fn txn_max_retries_default() -> u16 {
146        5
147    }
148
149    pub const fn db_request_referrals_limit() -> u16 {
150        10
151    }
152
153    pub const fn db_request_abilities_limit() -> u16 {
154        120
155    }
156
157    pub const fn google_play_max_retries_default() -> u16 {
158        3
159    }
160
161    pub const fn google_play_retry_base_ms_default() -> u64 {
162        500
163    }
164
165    pub const fn disconnect_log_events_count_default() -> usize {
166        20
167    }
168
169    pub fn otlp_sampling_rate_default() -> f64 {
170        1.0
171    }
172
173    pub const fn state_updater_enabled_default() -> bool {
174        true
175    }
176
177    pub const fn game_tick_enabled_default() -> bool {
178        true
179    }
180}
181
182#[derive(Clone, Debug, Deserialize)]
183pub struct Config {
184    pub infra_config: InfraConfig,
185    #[serde(deserialize_with = "deserialize_game_config_arc")]
186    pub game_config: Arc<crate::game_config::GameConfig>,
187    #[serde(skip)]
188    pub config_path: std::path::PathBuf,
189}
190
191fn deserialize_game_config_arc<'de, D>(
192    deserializer: D,
193) -> Result<Arc<crate::game_config::GameConfig>, D::Error>
194where
195    D: serde::Deserializer<'de>,
196{
197    let game_config = crate::game_config::GameConfig::deserialize(deserializer)?;
198    Ok(Arc::new(game_config))
199}
200
201impl Config {
202    pub fn load(
203        path: impl AsRef<std::path::Path> + Send + Sync + Clone + 'static,
204        port: Option<u16>,
205    ) -> anyhow::Result<Self> {
206        let config_path = path.as_ref().to_path_buf();
207        let mut config = schema_kit::parse_from_file::<Self>(path)?;
208
209        config.game_config.validate();
210        config.config_path = config_path;
211
212        if let Some(port) = port {
213            config.infra_config.port = port;
214        }
215
216        if let Ok(v) = std::env::var("ENVIRONMENT") {
217            config.infra_config.env = v
218                .parse::<Environment>()
219                .map_err(|e| anyhow::anyhow!("Invalid ENVIRONMENT value `{v}`: {e}"))?;
220        }
221
222        // Whole-URL override. The config carries a URL-style address, and
223        // DB_HOST/DB_PORT are applied ON TOP of it by deadpool (which fails
224        // with "invalid number of ports"), so pointing a process at another
225        // cluster — a sim DB, or another worktree's dev DB — goes through here.
226        if let Ok(v) = std::env::var("DB_URL") {
227            config.infra_config.db_pool_config.url = Some(v);
228        }
229
230        if let Ok(v) = std::env::var("DB_HOST") {
231            config.infra_config.db_pool_config.host = Some(v);
232        }
233
234        if let Ok(v) = std::env::var("DB_PORT") {
235            let parsed = v
236                .parse::<u16>()
237                .with_context(|| format!("`{v}` is not a valid u16 for DB_PORT"))?;
238            config.infra_config.db_pool_config.port = Some(parsed);
239        }
240
241        if let Ok(v) = std::env::var("DB_NAME") {
242            config.infra_config.db_pool_config.dbname = Some(v);
243        }
244        if let Ok(v) = std::env::var("DB_USER") {
245            config.infra_config.db_pool_config.user = Some(v);
246        }
247        if let Ok(v) = std::env::var("DB_PASSWORD") {
248            config.infra_config.db_pool_config.password = Some(v);
249        }
250
251        if let Ok(v) = std::env::var("FIREBASEE_PROJECT_ID") {
252            config.infra_config.firebase_project_id = v;
253        }
254
255        if let Ok(v) = std::env::var("PUBSUB_PROJECT_ID") {
256            config.infra_config.pubsub_project_id = v;
257        }
258        if let Ok(v) = std::env::var("PUBSUB_RTDN_SUBSCRIPTION") {
259            config.infra_config.pubsub_rtdn_subscription = v;
260        }
261        if let Ok(v) = std::env::var("RTDN_PUSH_TOKEN") {
262            let trimmed = v.trim();
263            config.infra_config.rtdn_push_token = if trimmed.is_empty() {
264                None
265            } else {
266                Some(trimmed.to_string())
267            };
268        }
269        if let Ok(v) = std::env::var("AUTH_JWT_SECRET") {
270            let trimmed = v.trim();
271            config.infra_config.auth_jwt_secret = if trimmed.is_empty() {
272                None
273            } else {
274                Some(trimmed.to_string())
275            };
276        }
277
278        if let Ok(v) = std::env::var("REALMS_SERVICE_URL") {
279            let trimmed = v.trim();
280            config.infra_config.realms_service_url = if trimmed.is_empty() {
281                None
282            } else {
283                Some(trimmed.to_string())
284            };
285        }
286        if let Ok(v) = std::env::var("REALM_NAME") {
287            let trimmed = v.trim();
288            config.infra_config.realm_name = if trimmed.is_empty() {
289                None
290            } else {
291                Some(trimmed.to_string())
292            };
293        }
294
295        if std::env::var_os("ALLOWED_PUBLIC").is_some() {
296            config.infra_config.allowed_usernames.clear();
297        }
298
299        if let Ok(v) = std::env::var("BACKEND_VERSION") {
300            let version: Version = v.parse().map_err(|e: String| {
301                anyhow::anyhow!("Invalid BACKEND_VERSION in ENV `{v}`: {e}")
302            })?;
303            config.infra_config.backend_version = version;
304        }
305
306        if let Ok(v) = std::env::var("GRAFANA_OTLP_ENDPOINT") {
307            config.infra_config.otlp_http_export_endpoint = v;
308        }
309
310        if let Ok(v) = std::env::var("GRAFANA_OTLP_USERNAME") {
311            config.infra_config.otlp_username = Some(v);
312        }
313
314        if let Ok(v) = std::env::var("GRAFANA_OTLP_PASSWORD") {
315            config.infra_config.otlp_password = Some(v);
316        }
317
318        if let Ok(v) = std::env::var("GRAFANA_OTLP_SAMPLING_RATE") {
319            config.infra_config.otlp_sampling_rate = v.parse::<f64>().with_context(|| {
320                format!("`{v}` is not a valid f64 for GRAFANA_OTLP_SAMPLING_RATE")
321            })?;
322        }
323
324        if let Ok(v) = std::env::var("GRAFANA_PYROSCOPE_URL") {
325            config.infra_config.pyroscope_url = Some(v);
326        }
327
328        if let Ok(v) = std::env::var("ANALYTICS_GCS_BUCKET") {
329            let bucket = v.trim();
330            config.infra_config.analytics_gcs_bucket = if bucket.is_empty() {
331                None
332            } else {
333                Some(bucket.to_string())
334            };
335        }
336
337        if let Ok(v) = std::env::var("ANALYTICS_GCS_FOLDER") {
338            let folder = v.trim().trim_matches('/');
339            config.infra_config.analytics_gcs_folder = if folder.is_empty() {
340                None
341            } else {
342                Some(folder.to_string())
343            };
344        }
345
346        if let Ok(v) = std::env::var("ANALYTICS_GCS_LOGS_ENABLED") {
347            config.infra_config.analytics_gcs_logs_enabled =
348                v.parse::<bool>().with_context(|| {
349                    format!("`{v}` is not a valid bool for ANALYTICS_GCS_LOGS_ENABLED")
350                })?;
351        }
352
353        if let Ok(v) = std::env::var("ANALYTICS_GCS_UPLOAD_INTERVAL_SECS") {
354            config.infra_config.analytics_gcs_upload_interval_secs =
355                v.parse::<u64>().with_context(|| {
356                    format!("`{v}` is not a valid u64 for ANALYTICS_GCS_UPLOAD_INTERVAL_SECS")
357                })?;
358        }
359
360        if let Ok(v) = std::env::var("ADMIN_API_KEY") {
361            config.infra_config.admin_api_key = Some(v);
362        }
363
364        Ok(config)
365    }
366
367    pub fn load_for_testing(
368        path: impl AsRef<std::path::Path> + Send + Sync + Clone + 'static,
369    ) -> anyhow::Result<Self> {
370        let config_path = path.as_ref().to_path_buf();
371        let mut infra_config = schema_kit::parse_from_file::<InfraConfig>(config_path.clone())?;
372
373        // Point the server-under-test at the same cluster testenv clones the
374        // schema from. The whole URL is replaced rather than the DB_PORT field
375        // because deadpool applies `port` ON TOP of the URL's own port, which
376        // fails with "invalid number of ports".
377        //
378        // This only knows the CockroachDB variable. Under
379        // `OVERLORD_TEST_DB_KIND=pg` the right URL is `OVERLORD_TEST_PG_URL`,
380        // which `testenv::db_url()` resolves — monolith's `tests/common` calls
381        // it and overwrites this field straight after. A new test harness must
382        // do the same rather than rely on what is set here.
383        if let Ok(url) = std::env::var("OVERLORD_TEST_DB_URL") {
384            infra_config.db_pool_config.url = Some(url);
385        }
386
387        Ok(Config {
388            infra_config,
389            game_config: Arc::new(generate_game_config_for_tests()),
390            config_path,
391        })
392    }
393}