From 7814d5c0c95f117eb6d553cc98fd754a5096dc60 Mon Sep 17 00:00:00 2001 From: Raymond Kennedy Date: Fri, 9 Oct 2026 22:05:18 -0700 Subject: [PATCH] Add opt-in player notifications when publishing --- src/main.rs | 24 +++++++++++++ src/publish.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1d1418b..4e57cc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,6 +200,11 @@ enum Commands { fixed: Vec, #[arg(long, help = "Adjusted change item", action = clap::ArgAction::Append, num_args = 1)] adjusted: Vec, + #[arg( + long, + help = "Notify players of the update (requires a summary or patch note; once per 24 hours per game)" + )] + notify_players: bool, #[arg( long = "yes", short = 'y', @@ -722,6 +727,7 @@ async fn run() -> Result<()> { removed, fixed, adjusted, + notify_players, yes, } => { handle_publish(PublishArgs { @@ -733,6 +739,7 @@ async fn run() -> Result<()> { removed, fixed, adjusted, + notify_players, yes, }) .await?; @@ -906,6 +913,23 @@ mod tests { use super::*; use clap::CommandFactory; + #[test] + fn publish_notifications_are_opt_in() { + for notify in [false, true] { + let mut argv = vec!["wavedash", "publish", "build-id", "--summary", "New levels"]; + if notify { + argv.push("--notify-players"); + } + let cli = Cli::try_parse_from(argv).expect("publish should parse"); + match cli.command { + Some(Commands::Publish { notify_players, .. }) => { + assert_eq!(notify_players, notify) + } + _ => panic!("parsed the wrong command"), + } + } + } + /// A blank one would reach [`resolve_game_id`], which returns a typed flag /// verbatim, and land in a request URL as `/api/games//…`. /// diff --git a/src/publish.rs b/src/publish.rs index 7c3381c..2ed3493 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -23,6 +23,8 @@ struct ReleaseNotes { #[derive(Debug, Serialize)] struct PublishRequest { + #[serde(rename = "notifyPlayers")] + notify_players: bool, #[serde(skip_serializing_if = "Option::is_none")] notes: Option, } @@ -44,6 +46,7 @@ pub struct PublishArgs { pub removed: Vec, pub fixed: Vec, pub adjusted: Vec, + pub notify_players: bool, pub yes: bool, } @@ -107,9 +110,21 @@ pub async fn handle_publish(args: PublishArgs) -> Result<()> { removed, fixed, adjusted, + notify_players, yes, } = args; + let notes = build_release_notes(title, summary, added, removed, fixed, adjusted); + if notify_players + && !notes + .as_ref() + .is_some_and(|notes| notes.summary.is_some() || notes.changes.is_some()) + { + anyhow::bail!( + "Add --summary or at least one patch note (--added, --removed, --fixed, or --adjusted) to notify players." + ); + } + let wavedash_config = WavedashConfig::load(&config_path)?; let game_id = wavedash_config.game_id()?; @@ -132,6 +147,9 @@ pub async fn handle_publish(args: PublishArgs) -> Result<()> { build_id.bold(), game_id.bold() ); + if notify_players { + println!("Players will be notified of this update."); + } let confirmed = cliclack::confirm("Are you sure you want to continue?") .initial_value(false) .interact()?; @@ -148,13 +166,14 @@ pub async fn handle_publish(args: PublishArgs) -> Result<()> { api_host, game_id, build_id ); - let notes = build_release_notes(title, summary, added, removed, fixed, adjusted); - let response = client .post(&url) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") - .json(&PublishRequest { notes }) + .json(&PublishRequest { + notes, + notify_players, + }) .send() .await?; @@ -168,3 +187,69 @@ pub async fn handle_publish(args: PublishArgs) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publish_request_sends_notification_flag_and_trimmed_notes() { + let request = PublishRequest { + notify_players: true, + notes: build_release_notes( + None, + Some(" New levels ".into()), + vec![], + vec![], + vec![" Fixed a crash ".into()], + vec![], + ), + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({ + "notifyPlayers": true, + "notes": { + "summary": "New levels", + "changes": [{ "kind": "fixed", "text": "Fixed a crash" }] + } + }) + ); + } + + #[tokio::test] + async fn notify_players_rejects_title_only_or_blank_notes_before_loading_config() { + for summary in [None, Some(" \n\t ".into())] { + let error = handle_publish(PublishArgs { + config_path: PathBuf::from("missing-config.toml"), + build_id: "build-id".into(), + title: Some("A title".into()), + summary, + added: vec![" ".into()], + removed: vec![], + fixed: vec![], + adjusted: vec![], + notify_players: true, + yes: true, + }) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("Add --summary or at least one patch note")); + } + } + + #[tokio::test] + async fn publish_cooldown_error_preserves_server_message() { + let message = "Players can only be notified once every 24 hours for this game. Publish without notifying, or try again later."; + let response = axum::http::Response::builder() + .status(400) + .body(serde_json::json!({ "error": message, "code": "invalid_operation" }).to_string()) + .unwrap(); + let error = config::check_api_response(response.into()) + .await + .unwrap_err(); + assert_eq!(error.to_string(), message); + } +}