diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 634e47d2..286b945a 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -11,6 +11,7 @@ //! app-scoped, not component-scoped. use std::path::PathBuf; +use std::time::{Duration, Instant}; use freya::prelude::spawn_forever; use freya::radio::RadioStation; @@ -29,6 +30,12 @@ use crate::notifications::{ }; use crate::state::{AppChannel, AppState, AsyncStatus}; +/// How long the launch button stays disabled after a click, at minimum. +/// +/// Over-disabling is the cheaper mistake: a button that is dead for two seconds +/// after a failed launch beats one that lets a second game start. +const LAUNCH_HOLD: Duration = Duration::from_secs(2); + /// Asks the event pump to do something it alone owns. /// /// The pump holds the toast timers, so an action that adds or removes a toast @@ -578,33 +585,40 @@ impl Actions { // --- game -------------------------------------------------------------- + /// Starts a launch, at most one per cluster. + /// + /// The pending flag is raised synchronously, before the first `await`, so + /// the button is already disabled by the time a second click of a + /// double-click could be dispatched; waiting for core to report `Checking` + /// left a few hundred ms where every click spawned its own game. The claim + /// itself is the guard, so a click that bypasses the disabled attribute + /// (keyboard activation, replayed events) is dropped here too. pub fn launch_cluster(&self, cluster_id: ClusterId) { + let claimed = self + .station + .clone() + .write_channel(AppChannel::Game) + .game + .begin_launch(cluster_id); + if !claimed { + return; + } + + let station = self.station; spawn_forever(async move { - let Ok(state) = launcher::state() else { return }; - let events = state.services.events.clone(); + let started = Instant::now(); + launch(cluster_id).await; - // Renews a lapsed token before launching. - let account = match state.auth.default_account_for_launch().await { - Ok(account) => account, - Err(err) => { - events.game_failed(cluster_id, format!("{err:#}")); - return; - } - }; - - match account { - Some(account) => { - if let Err(err) = - oneclient_core::launch_cluster(&state, cluster_id, &account, true).await - { - events.game_failed(cluster_id, format!("{err:#}")); - } - } - None => events.game_failed( - cluster_id, - "Add a Minecraft account before launching.".to_string(), - ), + // A launch that fails instantly would hand the button back inside + // the same click burst, so hold it for the floor either way. + if let Some(remaining) = LAUNCH_HOLD.checked_sub(started.elapsed()) { + tokio::time::sleep(remaining).await; } + station + .clone() + .write_channel(AppChannel::Game) + .game + .finish_launch(cluster_id); }); } @@ -870,6 +884,36 @@ impl Actions { } } +/// The launch itself. Failures are reported as game events, so the caller only +/// has to know that the attempt is over. +async fn launch(cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + // Renews a lapsed token before launching. + let account = match state.auth.default_account_for_launch().await { + Ok(account) => account, + Err(err) => { + events.game_failed(cluster_id, format!("{err:#}")); + return; + } + }; + + match account { + Some(account) => { + if let Err(err) = + oneclient_core::launch_cluster(&state, cluster_id, &account, true).await + { + events.game_failed(cluster_id, format!("{err:#}")); + } + } + None => events.game_failed( + cluster_id, + "Add a Minecraft account before launching.".to_string(), + ), + } +} + #[must_use = "the notification is not raised until `.send()` is called"] pub struct NotificationBuilder { actions: Actions, diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 2d511f0b..fa4e9906 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -130,6 +130,12 @@ pub struct GameState { pub stages: HashMap, pub error: Option, pub logs: HashMap>>>, + /// Clusters whose launch was started from the UI but has not been answered + /// by core yet. + /// + /// Core takes a few hundred ms to report [`LaunchStage::Checking`], and + /// every click that lands in that window used to spawn its own game. + pending: HashSet, } impl GameState { @@ -138,6 +144,25 @@ impl GameState { self.stages.get(&cluster_id).copied() } + /// Claims the launch for this cluster, returning false if one is already in + /// flight — the re-entrancy guard behind the button's disabled state. + pub fn begin_launch(&mut self, cluster_id: i64) -> bool { + if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + return false; + } + self.pending.insert(cluster_id); + true + } + + pub fn finish_launch(&mut self, cluster_id: i64) { + self.pending.remove(&cluster_id); + } + + #[must_use] + pub fn is_launch_pending(&self, cluster_id: i64) -> bool { + self.pending.contains(&cluster_id) + } + #[must_use] pub fn is_busy(&self, cluster_id: i64) -> bool { self.stage(cluster_id).is_some_and(LaunchStage::is_busy) @@ -158,3 +183,51 @@ impl GameState { self.logs.get(&cluster_id).cloned().unwrap_or_default() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::app::launch_button_state; + + #[test] + fn a_second_click_is_refused_before_core_answers() { + let mut game = GameState::default(); + + assert!(game.begin_launch(1)); + assert!(!game.begin_launch(1)); + assert!(game.begin_launch(2)); + } + + #[test] + fn a_running_cluster_cannot_be_launched_again() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Running); + + assert!(!game.begin_launch(1)); + } + + #[test] + fn the_claim_is_released_when_the_launch_settles() { + let mut game = GameState::default(); + + assert!(game.begin_launch(1)); + game.finish_launch(1); + assert!(game.begin_launch(1)); + } + + #[test] + fn the_button_disables_on_the_claim_alone() { + let mut game = GameState::default(); + assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + + game.begin_launch(1); + assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + + // Held past a failure, which parks the stage at `Exited`. + game.stages.insert(1, LaunchStage::Exited); + assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + + game.finish_launch(1); + assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + } +} diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index c8fefbed..2bb71180 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -23,6 +23,9 @@ pub fn launch_button_state( Some(LaunchStage::Downloading) => ("Downloading", false), Some(LaunchStage::Launching) => ("Launching", false), Some(LaunchStage::Running) => ("Running", false), + // The click's own flag, raised before core has said anything and held + // for a beat after a failure so a burst of clicks starts one game. + _ if game.is_launch_pending(cluster_id) => ("Launching", false), _ => ("Launch", true), }; // Block launching while the startup bundle download is still running.