//! Environment → [`HostConfig`]. Client-visible bind and the Python upstream are //! REQUIRED with no default (host-family discipline: a defaulted port could //! collide with the live oracle). `core_url` defaults to Bridge's convention. use std::env; const SBC_FAULT_ACK: &str = "staging-only-sbc-receipt-loss"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SbcPostCommitFault { #[default] Off, Drop, Malformed, Delay { millis: u64, }, } #[derive(Debug, Clone)] pub struct HostConfig { /// Where this host listens (the address FIFA reaches for UTAS). Required. pub listen_addr: String, /// Base URL of the Python UTAS oracle for fallback, e.g. /// `http://127.0.0.1:8199`. Required — must NOT be this host's own address. pub python_upstream: String, /// OpenFUT Core base URL. Default `http://127.0.0.1:8080` (Bridge convention). pub core_url: String, /// Directory holding `leagues.json`/`nations.json`/`teams.json`. pub tables_dir: String, /// FIFA 17 card-definition **identity catalog** (card id → FIFA asset id). /// Required production identity source: a `/club` item's `resourceId` comes /// from here. Startup fails if it cannot be loaded — never a placeholder. pub catalog_path: String, /// Persistent external-identity **store** file (owned-instance → stable wire /// id). Required: the wire `id` of every owned item is allocated/resolved /// here so it survives restart and reverses exactly. pub identity_store_path: String, /// The launcher-selected FIFA persona id, injected via `OPENFUT_PERSONA_ID`. /// Required, non-zero: it stamps `personaId` on the Core-backed /// `GET /squad/active`, and must match the persona LSX/Blaze/POW/UTAS use. pub persona_id: i64, /// Durable FIFA17 transfer-market listing DB (host-owned SQLite). Required /// for the economy cutover; must survive host restart (a real path, not a /// temp file). Env `OPENFUT_MARKET_DB`. pub market_db_path: String, /// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must /// survive host restart. Env `OPENFUT_PILE_DB`. pub pile_db_path: String, /// Durable client-data blob store (`clientdata`/`userHubData`), host-owned /// JSON file. NOT required: defaults to env `OPENFUT_CLIENTDATA_DB`, else the /// identity store's parent directory + `clientdata.json`. The blobs are /// non-authoritative client UI state, so a default path is safe. pub clientdata_path: String, /// Shared FIFA17 account JSON (`fut_account.py` shape), used for club-name /// reads and the Rust-owned rename mutation. Defaults to /// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the /// identity store's parent directory + `active_account.json`. pub account_path: String, /// Disabled by default. Non-off values require three explicit staging guards; /// see [`parse_sbc_post_commit_fault`]. pub sbc_post_commit_fault: SbcPostCommitFault, } #[derive(Debug)] pub struct ConfigError(pub String); impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl std::error::Error for ConfigError {} fn required(key: &str) -> Result { match env::var(key) { Ok(v) if !v.is_empty() => Ok(v), _ => Err(ConfigError(format!("{key} is required (no default)"))), } } /// Parse a required, non-zero i64 env var. A zero identity id is invalid (the /// client rejects a zero persona), so unset/empty/non-integer/zero is a hard error. fn required_i64_nonzero(key: &str) -> Result { let raw = required(key)?; let val: i64 = raw .parse() .map_err(|_| ConfigError(format!("{key} must be an integer, got {raw:?}")))?; if val == 0 { return Err(ConfigError(format!("{key} must be non-zero"))); } Ok(val) } fn parse_sbc_post_commit_fault( raw: Option<&str>, environment: Option<&str>, ack: Option<&str>, listen_addr: &str, ) -> Result { let mode = match raw.filter(|value| !value.is_empty()).unwrap_or("off") { "off" => return Ok(SbcPostCommitFault::Off), "drop" => SbcPostCommitFault::Drop, "malformed" => SbcPostCommitFault::Malformed, value if value.starts_with("delay:") => { let millis = value["delay:".len()..].parse::().map_err(|_| { ConfigError( "OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:" .into(), ) })?; if !(1..=30_000).contains(&millis) { return Err(ConfigError( "OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be 1..=30000 ms".into(), )); } SbcPostCommitFault::Delay { millis } } value => { return Err(ConfigError(format!( "OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT must be off, drop, malformed, or delay:; got {value:?}" ))); } }; if environment != Some("staging") { return Err(ConfigError( "SBC post-commit faults require OPENFUT_ENVIRONMENT=staging".into(), )); } if ack != Some(SBC_FAULT_ACK) { return Err(ConfigError(format!( "SBC post-commit faults require OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK={SBC_FAULT_ACK}" ))); } if listen_addr.rsplit_once(':').map(|(_, port)| port) == Some("8099") { return Err(ConfigError( "SBC post-commit faults refuse the production UTAS port 8099".into(), )); } Ok(mode) } impl HostConfig { pub fn from_env() -> Result { let identity_store_path = required("OPENFUT_IDENTITY_STORE")?; let listen_addr = required("OPENFUT_UTAS_HOST_ADDR")?; let sbc_post_commit_fault = parse_sbc_post_commit_fault( env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT") .ok() .as_deref(), env::var("OPENFUT_ENVIRONMENT").ok().as_deref(), env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK") .ok() .as_deref(), &listen_addr, )?; let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB") .ok() .filter(|v| !v.is_empty()) .unwrap_or_else(|| default_clientdata_path(&identity_store_path)); let account_path = env::var("OPENFUT_ACCOUNT_PATH") .ok() .filter(|v| !v.is_empty()) .or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty())) .unwrap_or_else(|| default_account_path(&identity_store_path)); Ok(HostConfig { listen_addr, python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, core_url: env::var("OPENFUT_CORE_URL") .unwrap_or_else(|_| "http://127.0.0.1:8080".into()), tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR") .unwrap_or_else(|_| "fifa17-recon/data/tables".into()), catalog_path: required("OPENFUT_FIFA17_CATALOG")?, persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?, market_db_path: required("OPENFUT_MARKET_DB")?, pile_db_path: required("OPENFUT_PILE_DB")?, identity_store_path, clientdata_path, account_path, sbc_post_commit_fault, }) } } /// Default client-data blob path: the identity store's parent directory + /// `clientdata.json` (co-located with the other host-owned durable state). fn default_clientdata_path(identity_store_path: &str) -> String { std::path::Path::new(identity_store_path) .parent() .map(|p| p.join("clientdata.json")) .unwrap_or_else(|| std::path::PathBuf::from("clientdata.json")) .to_string_lossy() .into_owned() } fn default_account_path(identity_store_path: &str) -> String { std::path::Path::new(identity_store_path) .parent() .map(|p| p.join("active_account.json")) .unwrap_or_else(|| std::path::PathBuf::from("active_account.json")) .to_string_lossy() .into_owned() } #[cfg(test)] mod tests { use super::*; #[test] fn sbc_post_commit_faults_require_all_staging_guards() { assert_eq!( parse_sbc_post_commit_fault(None, None, None, "0.0.0.0:8099").unwrap(), SbcPostCommitFault::Off ); assert!(parse_sbc_post_commit_fault( Some("drop"), None, Some(SBC_FAULT_ACK), "127.0.0.1:18199" ) .is_err()); assert!(parse_sbc_post_commit_fault( Some("drop"), Some("staging"), None, "127.0.0.1:18199" ) .is_err()); assert!(parse_sbc_post_commit_fault( Some("drop"), Some("staging"), Some(SBC_FAULT_ACK), "0.0.0.0:8099" ) .is_err()); assert_eq!( parse_sbc_post_commit_fault( Some("drop"), Some("staging"), Some(SBC_FAULT_ACK), "0.0.0.0:18199" ) .unwrap(), SbcPostCommitFault::Drop ); assert_eq!( parse_sbc_post_commit_fault( Some("delay:25"), Some("staging"), Some(SBC_FAULT_ACK), "0.0.0.0:18199" ) .unwrap(), SbcPostCommitFault::Delay { millis: 25 } ); } }