use crate::config::LauncherConfig; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync"; const TIMEOUT: Duration = Duration::from_secs(3); /// The launcher's view of the account, sent on every sync. /// /// `persona_id`/`persona_name` are `Option` because omitting them is meaningful: /// the server then answers with the persona *it* is configured for, which is how /// first-run account creation learns an identity instead of inventing one. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AccountSyncRequest<'a> { #[serde(skip_serializing_if = "Option::is_none")] persona_id: Option, #[serde(skip_serializing_if = "Option::is_none")] persona_name: Option<&'a str>, level: u32, experience: u32, experience_max: u32, account_funds: u32, account_funds_cap: u32, } #[derive(Debug, Deserialize)] pub struct AccountSyncResult { pub account: AccountSummary, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccountSummary { pub persona_id: u64, pub persona_name: String, /// Club identity for the account bar. Optional in older envelopes. #[serde(default)] pub club_name: String, #[serde(default)] pub club_abbr: String, pub level: u32, pub experience: u32, /// XP required for the next level. Optional; 0 means "unknown". #[serde(default)] pub experience_max: u32, pub account_funds: u32, /// EASFC funds ceiling. Optional; 0 means "unknown". #[serde(default)] pub account_funds_cap: u32, pub coins: i64, pub unopened_packs: usize, } /// Select the persistent EA/FUT account before LSX and FIFA start. /// /// This deliberately uses a tiny stdlib HTTP client so the launcher does not /// acquire an async runtime solely for one bounded control-plane request. pub fn sync(config: &LauncherConfig) -> Result { config.validate_server()?; config.validate_account()?; let account = post( config, &AccountSyncRequest { persona_id: Some(config.fut_persona_id), persona_name: Some(config.fut_persona_name.trim()), level: config.fut_account_level, experience: config.fut_account_experience, experience_max: config.fut_account_experience_max, account_funds: config.fut_account_funds, account_funds_cap: config.fut_account_funds_cap, }, )?; // The server echoes the persona it selected. A different one means the two // sides disagree about who is playing, which must never pass silently. if account.persona_id != config.fut_persona_id { return Err(format!( "account server selected persona {} instead of {}", account.persona_id, config.fut_persona_id )); } Ok(account) } /// Ask the server which account it serves, for first-run account creation. /// /// Sending no persona makes the server fall back to the one it was started with /// and answer with its real club and Core coin balance. That is the whole reason /// the launcher never has to invent a persona id: the identity that matters is /// the server's, and this is how it is claimed. pub fn discover(config: &LauncherConfig) -> Result { config.validate_server()?; let account = post( config, &AccountSyncRequest { persona_id: None, persona_name: None, level: config.fut_account_level.max(1), experience: config.fut_account_experience, experience_max: config.fut_account_experience_max.max(1), account_funds: config.fut_account_funds, account_funds_cap: config.fut_account_funds_cap, }, )?; if account.persona_id == 0 { return Err( "account server returned no persona — is it configured with \ a persona id?" .to_string(), ); } if account.persona_name.trim().is_empty() { return Err("account server returned an empty persona name".to_string()); } Ok(account) } /// One bounded POST to `/openfut/account/sync`, returning the account summary. fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result { let host = config.openfut_server_host.trim(); let port = config.openfut_account_sync_port; let address = (host, port) .to_socket_addrs() .map_err(|error| format!("cannot resolve account server {host}:{port}: {error}"))? .next() .ok_or_else(|| format!("account server {host}:{port} resolved to no addresses"))?; let mut stream = TcpStream::connect_timeout(&address, TIMEOUT) .map_err(|error| format!("cannot connect to account server {host}:{port}: {error}"))?; stream .set_read_timeout(Some(TIMEOUT)) .map_err(|error| format!("cannot set account sync timeout: {error}"))?; stream .set_write_timeout(Some(TIMEOUT)) .map_err(|error| format!("cannot set account sync timeout: {error}"))?; let payload = serde_json::to_vec(body) .map_err(|error| format!("cannot encode account sync request: {error}"))?; let request = format!( "POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", payload.len() ); stream .write_all(request.as_bytes()) .and_then(|()| stream.write_all(&payload)) .map_err(|error| format!("cannot send account sync request: {error}"))?; let mut response = Vec::new(); stream .read_to_end(&mut response) .map_err(|error| format!("cannot read account sync response: {error}"))?; let separator = response .windows(4) .position(|window| window == b"\r\n\r\n") .ok_or_else(|| "account server returned a malformed HTTP response".to_string())?; let headers = std::str::from_utf8(&response[..separator]) .map_err(|_| "account server returned non-UTF-8 headers".to_string())?; let status = headers .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .and_then(|value| value.parse::().ok()) .ok_or_else(|| "account server returned a malformed status line".to_string())?; let response_body = &response[separator + 4..]; if !(200..300).contains(&status) { let detail = String::from_utf8_lossy(response_body); return Err(format!( "account server rejected sync (HTTP {status}): {detail}" )); } let envelope: AccountSyncResult = serde_json::from_slice(response_body) .map_err(|error| format!("account server returned invalid JSON: {error}"))?; Ok(envelope.account) } #[cfg(test)] mod tests { use super::*; use std::net::TcpListener; use std::thread; #[test] fn sync_posts_account_and_reads_selected_profile() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let server = thread::spawn(move || { let (mut socket, _) = listener.accept().unwrap(); let mut request = Vec::new(); loop { let mut chunk = [0; 1024]; let count = socket.read(&mut chunk).unwrap(); assert!(count > 0); request.extend_from_slice(&chunk[..count]); if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") { let headers = String::from_utf8_lossy(&request[..separator]); let length = headers .lines() .find_map(|line| line.strip_prefix("Content-Length: ")) .unwrap() .parse::() .unwrap(); if request.len() >= separator + 4 + length { break; } } } let request = String::from_utf8_lossy(&request); assert!(request.starts_with("POST /openfut/account/sync HTTP/1.1")); assert!(request.contains("\"personaId\":12345678")); assert!(request.contains("\"personaName\":\"TEST_USER\"")); let body = r#"{"status":"OK","account":{"personaId":12345678,"personaName":"TEST_USER","level":7,"experience":200,"accountFunds":50,"coins":15000,"unopenedPacks":1}}"#; write!( socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ) .unwrap(); }); let config = LauncherConfig { openfut_server_host: "127.0.0.1".into(), openfut_account_sync_port: port, fut_persona_id: 12345678, fut_persona_name: "TEST_USER".into(), fut_account_level: 7, fut_account_experience: 200, ..LauncherConfig::default() }; let selected = sync(&config).unwrap(); assert_eq!(selected.persona_name, "TEST_USER"); assert_eq!(selected.coins, 15000); assert_eq!(selected.unopened_packs, 1); server.join().unwrap(); } /// Serve exactly one `/openfut/account/sync` POST, handing the decoded /// request text to `inspect` and replying with `body`. fn serve_once( inspect: impl FnOnce(&str) + Send + 'static, body: &'static str, ) -> (u16, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let handle = thread::spawn(move || { let (mut socket, _) = listener.accept().unwrap(); let mut request = Vec::new(); loop { let mut chunk = [0; 1024]; let count = socket.read(&mut chunk).unwrap(); assert!(count > 0); request.extend_from_slice(&chunk[..count]); if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") { let headers = String::from_utf8_lossy(&request[..separator]); let length = headers .lines() .find_map(|line| line.strip_prefix("Content-Length: ")) .unwrap() .parse::() .unwrap(); if request.len() >= separator + 4 + length { break; } } } inspect(&String::from_utf8_lossy(&request)); write!( socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ) .unwrap(); }); (port, handle) } #[test] fn discover_omits_the_persona_so_the_server_names_its_own() { // The point of first-run discovery: the launcher must not send a guessed // persona, because the server would echo the guess straight back. let (port, server) = serve_once( |request| { assert!(!request.contains("personaId"), "{request}"); assert!(!request.contains("personaName"), "{request}"); }, r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#, ); let config = LauncherConfig { openfut_server_host: "127.0.0.1".into(), openfut_account_sync_port: port, ..LauncherConfig::default() }; // Deliberately an unconfigured account: discovery must work before one // exists, which is the whole reason it does not call `validate_account`. assert_eq!(config.fut_persona_id, 0); let found = discover(&config).unwrap(); assert_eq!(found.persona_id, 33_068_179); assert_eq!(found.persona_name, "CAGE"); assert_eq!(found.club_name, "OpenFUT"); assert_eq!(found.coins, 29_876_776); server.join().unwrap(); } #[test] fn discover_rejects_a_server_that_names_no_persona() { // A zero persona would otherwise be written into the config as a real // account and fail much later, at launch, as a mismatch. let (port, server) = serve_once( |_| {}, r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#, ); let config = LauncherConfig { openfut_server_host: "127.0.0.1".into(), openfut_account_sync_port: port, ..LauncherConfig::default() }; let error = discover(&config).unwrap_err(); assert!(error.contains("no persona"), "{error}"); server.join().unwrap(); } #[test] fn sync_refuses_a_server_that_selects_a_different_persona() { let (port, server) = serve_once( |_| {}, r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#, ); let config = LauncherConfig { openfut_server_host: "127.0.0.1".into(), openfut_account_sync_port: port, fut_persona_id: 12345678, fut_persona_name: "TEST_USER".into(), ..LauncherConfig::default() }; let error = sync(&config).unwrap_err(); assert!(error.contains("999"), "{error}"); server.join().unwrap(); } }