use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// One `dosdevices` entry to create inside the Wine prefix before launching. /// `link` is relative to the prefix (e.g. `dosdevices/w:`). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PrefixLink { pub link: String, pub target: String, } /// A DRM licence file the game refuses to start without, and the executable /// that recreates it. A crashed launch deletes the licence, so this is a /// per-launch precondition rather than a one-time setup step. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LicenseCheck { /// Absolute, or relative to the Wine prefix. pub path: String, /// Executable run through the profile's runner to regenerate it. pub generator: String, #[serde(default = "default_license_timeout")] pub timeout_secs: u64, } /// Everything needed to start one game, as data. /// /// This is what keeps the launcher game-independent: FIFA 17's runner, prefix, /// executable, `w:` drive and licence id live here in the user's config, never /// in launcher code. An unconfigured profile means "fall back to /// `game_launch_command`", so upgrading cannot break a working setup. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GameProfile { /// Program that starts the game (e.g. `umu-run`). Empty = profile unused. #[serde(default)] pub runner: String, /// Argument passed to the runner (e.g. `FIFA17.exe`). #[serde(default)] pub executable: String, /// Working directory the runner is started from. #[serde(default)] pub game_dir: String, /// `WINEPREFIX` for the game. Exported automatically when set. #[serde(default)] pub wine_prefix: String, /// Extra environment for the runner (`GAMEID`, `PROTONPATH`, …). #[serde(default)] pub env: BTreeMap, #[serde(default)] pub prefix_links: Vec, #[serde(default)] pub license: Option, } impl GameProfile { /// Whether this profile is filled in enough to launch from. pub fn configured(&self) -> bool { !self.runner.trim().is_empty() && !self.executable.trim().is_empty() && !self.game_dir.trim().is_empty() } /// Reject a half-filled profile rather than launching something surprising. pub fn validate(&self) -> Result<(), String> { if self.runner.trim().is_empty() { return Err("Game profile has no runner (e.g. umu-run).".into()); } if self.executable.trim().is_empty() { return Err("Game profile has no executable.".into()); } if self.game_dir.trim().is_empty() { return Err("Game profile has no game directory.".into()); } if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() { return Err("Game profile defines prefix links but no wine_prefix.".into()); } for l in &self.prefix_links { if l.link.trim().is_empty() || l.target.trim().is_empty() { return Err("Game profile has a prefix link with an empty link or target.".into()); } if std::path::Path::new(&l.link).is_absolute() { return Err(format!( "Prefix link {:?} must be relative to the Wine prefix.", l.link )); } } if let Some(lic) = &self.license { if lic.path.trim().is_empty() || lic.generator.trim().is_empty() { return Err("Game profile licence needs both a path and a generator.".into()); } } Ok(()) } } fn default_license_timeout() -> u64 { 60 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherConfig { pub core_binary: String, pub bridge_binary: String, pub core_database_url: String, pub core_data_dir: String, pub core_listen_addr: String, pub bridge_listen_addr: String, pub bridge_captures_dir: String, pub bridge_core_url: String, pub bridge_tls_enabled: bool, /// Path to the built openfut_hook.dll (Windows DLL for Proton injection). pub hook_dll_path: String, /// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed). pub fifa_game_dir: String, /// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or /// hostname. Empty means "not configured" — launching is blocked until set. /// There is intentionally NO loopback default. #[serde(default, alias = "hook_redirect_ip")] pub openfut_server_host: String, /// OpenFUT destination port for intercepted EA :443 (bridge HTTPS). #[serde(default = "default_https_port")] pub openfut_https_port: u16, /// OpenFUT destination port for intercepted EA :10041 (Blaze redirector). #[serde(default = "default_blaze_redirector_port")] pub openfut_blaze_redirector_port: u16, /// OpenFUT destination port for intercepted EA :42127 (Blaze main). #[serde(default = "default_blaze_main_port")] pub openfut_blaze_main_port: u16, /// Plain HTTP UTAS/control-plane port used to select the active account. #[serde(default = "default_account_sync_port")] pub openfut_account_sync_port: u16, /// EA/Origin persona selected for this local single-player profile. #[serde(default)] pub fut_persona_id: u64, #[serde(default)] pub fut_persona_name: String, /// EASFC/POW account-bar state (separate from FUT club coins). #[serde(default = "default_account_level")] pub fut_account_level: u32, #[serde(default)] pub fut_account_experience: u32, #[serde(default = "default_account_experience_max")] pub fut_account_experience_max: u32, #[serde(default)] pub fut_account_funds: u32, #[serde(default = "default_account_funds_cap")] pub fut_account_funds_cap: u32, /// Shell command the launcher runs to start the game. Run via `sh -c`, from /// `game_launch_workdir` if set. Empty means "not configured" — the Launch /// Game button is disabled until the user provides one. This keeps the /// launcher agnostic to Steam vs umu-run vs a custom script. #[serde(default)] pub game_launch_command: String, /// Optional working directory for `game_launch_command`. Empty = inherit. #[serde(default)] pub game_launch_workdir: String, /// Native launch definition. When [`GameProfile::configured`], the launcher /// starts the game itself and `game_launch_command` is not used; the command /// remains as a fallback so an existing setup keeps working after upgrade. #[serde(default)] pub game_profile: GameProfile, // ── Pre-launch checks (see `preflight`) ───────────────────────────────── /// EA's hardcoded redirector IP, probed to confirm the client-side DNAT is /// armed. Empty = the check is skipped. A game fact, so it is configuration. #[serde(default)] pub ea_redirect_probe_ip: String, /// Dead EA hostnames that must resolve to `openfut_server_host`. #[serde(default)] pub ea_hostnames: Vec, // ── FIFA 17 local companion services (client-side, run on THIS machine) ── // FIFA 17's FUT flow needs two pieces that are inherently local to the game // box and cannot move to the server: the LSX Origin emulator (the game dials // it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches // FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages // both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run // in the server container; these two stay here. /// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`). /// Empty means the local-services feature is unconfigured and its controls /// stay disabled. #[serde(default)] pub fifa17_tools_dir: String, /// Python interpreter used to run the local companion services. #[serde(default = "default_python")] pub fifa17_python: String, } fn default_python() -> String { "python3".to_string() } fn default_https_port() -> u16 { openfut_common::default_ports::HTTPS } fn default_blaze_redirector_port() -> u16 { openfut_common::default_ports::BLAZE_REDIRECTOR } fn default_blaze_main_port() -> u16 { openfut_common::default_ports::BLAZE_MAIN } fn default_account_sync_port() -> u16 { 8099 } fn default_account_level() -> u32 { 1 } fn default_account_experience_max() -> u32 { 1000 } fn default_account_funds_cap() -> u32 { 100_000 } impl Default for LauncherConfig { fn default() -> Self { let base = dirs::home_dir() .map(|h| h.join("Documents/OpenFUT")) .unwrap_or_default(); Self { core_binary: base .join("openfut-core/target/release/openfut-core") .to_string_lossy() .into(), bridge_binary: base .join("openfut-bridge/target/release/openfut-bridge") .to_string_lossy() .into(), core_database_url: "sqlite://openfut.db".into(), core_data_dir: base .join("openfut-core/data") .to_string_lossy() .into(), core_listen_addr: "127.0.0.1:8080".into(), bridge_listen_addr: "0.0.0.0:8765".into(), bridge_captures_dir: base .join("openfut-bridge/captures") .to_string_lossy() .into(), bridge_core_url: "http://127.0.0.1:8080".into(), bridge_tls_enabled: true, hook_dll_path: dirs::home_dir() .map(|h| h.join("Documents/openfut-launcher/openfut-hook/target/x86_64-pc-windows-gnu/release/openfut_hook.dll")) .unwrap_or_default() .to_string_lossy() .into(), fifa_game_dir: dirs::home_dir() .map(|h| h.join(".steam/steam/steamapps/common/FIFA 23")) .unwrap_or_default() .to_string_lossy() .into(), // No server configured by default — the user MUST enter one. There // is deliberately no loopback/localhost default. openfut_server_host: String::new(), openfut_https_port: default_https_port(), openfut_blaze_redirector_port: default_blaze_redirector_port(), openfut_blaze_main_port: default_blaze_main_port(), openfut_account_sync_port: default_account_sync_port(), fut_persona_id: 0, fut_persona_name: String::new(), fut_account_level: default_account_level(), fut_account_experience: 0, fut_account_experience_max: default_account_experience_max(), fut_account_funds: 0, fut_account_funds_cap: default_account_funds_cap(), game_launch_command: String::new(), game_launch_workdir: String::new(), // Empty by default, exactly like the server host: the launcher must // never invent a path to somebody's game install. game_profile: GameProfile::default(), ea_redirect_probe_ip: String::new(), ea_hostnames: Vec::new(), fifa17_tools_dir: base .join("fifa17-recon/tools") .to_string_lossy() .into(), fifa17_python: default_python(), } } } impl LauncherConfig { pub fn config_path() -> std::path::PathBuf { dirs::config_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("openfut-launcher") .join("config.json") } pub fn load() -> Self { let path = Self::config_path(); std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } pub fn save(&self) { let path = Self::config_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } if let Ok(json) = serde_json::to_string_pretty(self) { let _ = std::fs::write(path, json); } } /// The (host, port) the health monitor should poll, or None when no server /// is configured. Uses the bridge HTTPS port — the port the FIFA client /// actually connects to — so "reachable" means what the game will see. pub fn health_target(&self) -> Option<(String, u16)> { let host = self.openfut_server_host.trim(); if host.is_empty() { None } else { Some((host.to_string(), self.openfut_https_port)) } } /// The config the account monitor should poll with, or None when no server /// is configured. Returns a clone so the background thread owns its own /// snapshot and never races the UI's live config. pub fn account_target(&self) -> Option { if self.openfut_server_host.trim().is_empty() { None } else { Some(self.clone()) } } /// Build the shared [`ServerConfig`] from the launcher's configured server /// host + destination ports. This is the single place the launcher turns UI /// fields into the canonical config consumed by the hook. pub fn server_config(&self) -> openfut_common::ServerConfig { openfut_common::ServerConfig { host: self.openfut_server_host.trim().to_string(), ports: openfut_common::OpenFutPorts { https: self.openfut_https_port, blaze_redirector: self.openfut_blaze_redirector_port, blaze_main: self.openfut_blaze_main_port, }, } } /// Validate the configured server (syntax only, no DNS). Returns the same /// user-facing message the task specifies when nothing is configured. pub fn validate_server(&self) -> Result<(), String> { if self.openfut_server_host.trim().is_empty() { return Err("No OpenFUT server configured. Please enter the hostname \ or IP address of your OpenFUT server." .to_string()); } self.server_config().validate().map_err(|e| e.to_string()) } /// Validate the client-local FIFA 17 service configuration. Filesystem /// existence is checked by the process launcher immediately before spawn; /// this ensures required user configuration is never silently invented. pub fn validate_local_services(&self) -> Result<(), String> { if self.fifa17_tools_dir.trim().is_empty() { return Err("No FIFA 17 tools dir configured. Set it in Settings.".into()); } if self.fifa17_python.trim().is_empty() { return Err("No Python interpreter configured. Set it in Settings.".into()); } Ok(()) } /// Validate every configuration value required by the one-button FIFA 17 /// launch path. Runtime state such as hook deployment is checked by the UI. pub fn validate_launch_config(&self) -> Result<(), String> { self.validate_server()?; self.validate_account()?; // Either launch route is acceptable, but a half-filled profile is not: // silently falling back to the shell command would hide the mistake, so // ANY profile that has been touched must be complete. if self.game_profile != GameProfile::default() { self.game_profile.validate()?; } else if self.game_launch_command.trim().is_empty() { return Err( "No game configured. Fill in the game profile, or set a launch command, \ in Settings." .into(), ); } self.validate_local_services() } pub fn validate_account(&self) -> Result<(), String> { if self.fut_persona_id == 0 { return Err("No account yet. Create one from the Get started tab.".into()); } if self.fut_persona_name.trim().is_empty() { return Err("Account has no persona name. Recreate it from Get started.".into()); } if self.fut_account_level == 0 { return Err("EA account level must be at least 1.".into()); } if self.fut_account_experience_max == 0 || self.fut_account_experience > self.fut_account_experience_max { return Err("EA account XP must not exceed a nonzero XP maximum.".into()); } if self.fut_account_funds > self.fut_account_funds_cap { return Err("EA account funds must not exceed the funds cap.".into()); } Ok(()) } /// Whether an account has been claimed from the server (see /// [`crate::account_sync::discover`]). Distinct from /// [`Self::validate_account`], which also polices the derived EASFC values: /// this answers only "does this install know who is playing?". pub fn account_configured(&self) -> bool { self.fut_persona_id != 0 && !self.fut_persona_name.trim().is_empty() } /// Whether the launcher should open on the guided first-run flow instead of /// the dashboard. Keyed on the two things a new user cannot be expected to /// guess: where the server is, and who they are. pub fn needs_onboarding(&self) -> bool { self.validate_server().is_err() || !self.account_configured() } /// The exact `openfut.cfg` bytes to write for the hook, or an error if the /// server isn't validly configured (never emits a loopback fallback). /// /// The deployed FIFA 17 hook reads this structured format through the same /// shared parser, so changing a destination port never requires recompiling /// the DLL. Fixed EA source ports remain protocol signatures in the hook. pub fn hook_cfg_contents(&self) -> Result { self.validate_server()?; Ok(self.server_config().to_cfg_string()) } } #[cfg(test)] mod tests { use super::*; #[test] fn default_has_no_server_and_blocks_launch() { let c = LauncherConfig::default(); assert!(c.openfut_server_host.is_empty()); let err = c.validate_server().unwrap_err(); assert!(err.contains("No OpenFUT server configured")); assert!(c.hook_cfg_contents().is_err()); } #[test] fn configured_server_roundtrips_into_hook_cfg() { let c = LauncherConfig { openfut_server_host: "192.168.1.50".into(), openfut_https_port: 9443, openfut_blaze_redirector_port: 43127, openfut_blaze_main_port: 43130, ..LauncherConfig::default() }; let cfg = c .hook_cfg_contents() .expect("valid server should produce cfg"); let parsed = openfut_common::ServerConfig::parse(&cfg).unwrap(); assert_eq!(parsed.host, "192.168.1.50"); assert_eq!(parsed.ports, c.server_config().ports); } #[test] fn changing_server_changes_hook_cfg_no_rebuild() { // Models the Server A -> Server B acceptance test at the config layer: // only the value changes; the same code path produces the new cfg. let mut c = LauncherConfig { openfut_server_host: "10.0.0.1".into(), ..LauncherConfig::default() }; let a = c.hook_cfg_contents().unwrap(); c.openfut_server_host = "10.0.0.2".into(); let b = c.hook_cfg_contents().unwrap(); assert_ne!(a, b); assert_eq!( openfut_common::ServerConfig::parse(&b).unwrap().host, "10.0.0.2" ); } #[test] fn health_target_none_until_configured() { let mut c = LauncherConfig::default(); assert!(c.health_target().is_none()); c.openfut_server_host = "10.10.0.120".into(); let (host, port) = c.health_target().expect("configured host yields a target"); assert_eq!(host, "10.10.0.120"); assert_eq!(port, c.openfut_https_port); } #[test] fn legacy_hook_redirect_ip_field_is_read() { // Old configs stored the address under `hook_redirect_ip`; serde alias // must map it onto the new field so upgrades keep working. let json = r#"{ "core_binary":"","bridge_binary":"","core_database_url":"", "core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"", "bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true, "hook_dll_path":"","fifa_game_dir":"","hook_redirect_ip":"192.168.5.5" }"#; let c: LauncherConfig = serde_json::from_str(json).unwrap(); assert_eq!(c.openfut_server_host, "192.168.5.5"); } #[test] fn local_services_require_tools_dir_and_python() { let mut c = LauncherConfig::default(); c.fifa17_tools_dir.clear(); assert!(c .validate_local_services() .unwrap_err() .contains("tools dir")); c.fifa17_tools_dir = "/tmp/fifa17-tools".into(); c.fifa17_python.clear(); assert!(c.validate_local_services().unwrap_err().contains("Python")); } #[test] fn local_services_accept_explicit_configuration() { let c = LauncherConfig { fifa17_tools_dir: "/tmp/fifa17-tools".into(), fifa17_python: "/usr/bin/python3".into(), ..LauncherConfig::default() }; assert!(c.validate_local_services().is_ok()); } #[test] fn launch_config_requires_server_local_services_and_command() { let mut c = LauncherConfig::default(); assert!(c.validate_launch_config().is_err()); c.openfut_server_host = "10.10.0.120".into(); c.fut_persona_id = 12345678; c.fut_persona_name = "TEST_USER".into(); assert!(c .validate_launch_config() .unwrap_err() .contains("launch command")); c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into(); c.fifa17_tools_dir.clear(); assert!(c .validate_launch_config() .unwrap_err() .contains("tools dir")); c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into(); c.fifa17_python = "/usr/bin/python3".into(); assert!(c.validate_launch_config().is_ok()); } /// An old config.json has no `game_profile` key at all. It must keep /// launching exactly as before rather than failing to parse or silently /// switching route. #[test] fn a_config_without_a_game_profile_still_uses_the_shell_command() { let json = r#"{ "core_binary":"","bridge_binary":"","core_database_url":"", "core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"", "bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true, "hook_dll_path":"","fifa_game_dir":"","openfut_server_host":"10.0.0.1", "game_launch_command":"/home/u/launch.sh" }"#; let c: LauncherConfig = serde_json::from_str(json).unwrap(); assert!(!c.game_profile.configured()); assert_eq!(c.game_profile, GameProfile::default()); assert!(c.ea_hostnames.is_empty()); } #[test] fn a_configured_profile_satisfies_launch_without_a_shell_command() { let mut c = LauncherConfig { openfut_server_host: "10.10.0.120".into(), fut_persona_id: 1, fut_persona_name: "X".into(), fifa17_tools_dir: "/tmp/tools".into(), fifa17_python: "/usr/bin/python3".into(), ..LauncherConfig::default() }; c.game_launch_command.clear(); assert!( c.validate_launch_config().is_err(), "neither route configured" ); c.game_profile = GameProfile { runner: "umu-run".into(), executable: "FIFA17.exe".into(), game_dir: "/mnt/games/FIFA 17".into(), ..GameProfile::default() }; assert!(c.game_profile.configured()); assert!(c.validate_launch_config().is_ok()); } /// The trap this guards: a profile filled in halfway would fail /// `configured()` and quietly fall through to the shell command, so the user /// edits the profile and nothing they change has any effect. #[test] fn a_half_filled_profile_is_an_error_not_a_silent_fallback() { let mut c = LauncherConfig { openfut_server_host: "10.10.0.120".into(), fut_persona_id: 1, fut_persona_name: "X".into(), fifa17_tools_dir: "/tmp/tools".into(), fifa17_python: "/usr/bin/python3".into(), game_launch_command: "/home/u/launch.sh".into(), ..LauncherConfig::default() }; c.game_profile.runner = "umu-run".into(); // and nothing else let err = c.validate_launch_config().unwrap_err(); assert!(err.contains("executable"), "{err}"); } /// The exact profile block deployed to the FIFA 17 machine. /// /// `load()` swallows a parse error and returns `Default` — so a config this /// binary cannot read would not produce an error, it would silently discard /// the user's persona, server and ports. That makes "the shipped config /// actually deserializes" a property worth asserting, not assuming. #[test] fn the_deployed_fifa17_profile_parses_exactly() { let json = r#"{ "core_binary":"","bridge_binary":"","core_database_url":"", "core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"", "bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true, "hook_dll_path":"","fifa_game_dir":"", "openfut_server_host":"10.10.0.120", "ea_hostnames":["easw.easports.com"], "ea_redirect_probe_ip":"159.153.51.20", "game_profile":{ "env":{"GAMEID":"fifa17","PROTONPATH":"UMU-Proton-10.0-4","STEAM_COMPAT_CONFIG":"sdlinput"}, "executable":"FIFA17.exe", "game_dir":"/mnt/games/FIFA 17", "license":{ "generator":"_fifa17.exe", "path":"drive_c/ProgramData/Electronic Arts/EA Services/License/1027460.dlf", "timeout_secs":60 }, "prefix_links":[{"link":"dosdevices/w:","target":"/mnt"}], "runner":"umu-run", "wine_prefix":"/home/alex/Games/umu/fifa17" } }"#; let c: LauncherConfig = serde_json::from_str(json).expect("deployed config must parse"); let p = &c.game_profile; assert!(p.configured()); assert!(p.validate().is_ok()); assert_eq!(p.runner, "umu-run"); assert_eq!( p.env.get("STEAM_COMPAT_CONFIG").map(String::as_str), Some("sdlinput") ); assert_eq!(p.prefix_links.len(), 1); let lic = p.license.as_ref().expect("licence block"); assert_eq!(lic.timeout_secs, 60); assert!(lic.path.ends_with("1027460.dlf")); assert_eq!(c.ea_redirect_probe_ip, "159.153.51.20"); } /// `configured()` alone decides which launch route runs, so it is pinned /// directly rather than only through `validate_launch_config`. All three /// fields are required: a profile missing any of them cannot start a game. #[test] fn configured_requires_runner_executable_and_dir() { let mut p = GameProfile::default(); assert!(!p.configured()); p.runner = "umu-run".into(); assert!(!p.configured(), "runner alone is not launchable"); p.executable = "G.exe".into(); assert!(!p.configured(), "no game_dir is not launchable"); p.game_dir = "/games/G".into(); assert!(p.configured()); // Whitespace is not configuration. p.executable = " ".into(); assert!(!p.configured()); } #[test] fn prefix_links_must_be_relative_and_have_a_prefix() { let mut p = GameProfile { runner: "umu-run".into(), executable: "G.exe".into(), game_dir: "/games/G".into(), prefix_links: vec![PrefixLink { link: "dosdevices/w:".into(), target: "/mnt".into(), }], ..GameProfile::default() }; assert!( p.validate().unwrap_err().contains("wine_prefix"), "links without a prefix have nowhere to go" ); p.wine_prefix = "/prefix".into(); assert!(p.validate().is_ok()); // An absolute link would be created outside the prefix entirely. p.prefix_links[0].link = "/etc/w:".into(); assert!(p.validate().unwrap_err().contains("relative")); } #[test] fn launch_requires_a_valid_ea_account() { let mut c = LauncherConfig::default(); // A fresh install has no account, and must say so rather than launching // FIFA as persona 0. assert!(!c.account_configured()); assert!(c.validate_account().is_err()); c.fut_persona_id = 12345678; assert!( !c.account_configured(), "an id without a name is not an account" ); assert!(c.validate_account().unwrap_err().contains("persona name")); c.fut_persona_name = "TEST_USER".into(); assert!(c.account_configured()); assert!(c.validate_account().is_ok()); c.fut_account_experience = 1001; assert!(c.validate_account().unwrap_err().contains("XP")); } #[test] fn onboarding_is_needed_until_both_server_and_account_are_known() { // Drives which tab the launcher opens on, so the two halves must both // count: a server with no account is still a dead end for a new user. let mut c = LauncherConfig::default(); assert!(c.needs_onboarding()); c.openfut_server_host = "10.10.0.120".into(); assert!( c.needs_onboarding(), "a server alone cannot launch anything" ); c.fut_persona_id = 33_068_179; c.fut_persona_name = "CAGE".into(); assert!(!c.needs_onboarding()); c.openfut_server_host.clear(); assert!(c.needs_onboarding(), "losing the server reopens the flow"); } }