diff --git a/src/config.rs b/src/config.rs index 526b900..83b5072 100644 --- a/src/config.rs +++ b/src/config.rs @@ -152,7 +152,7 @@ pub async fn check_api_response(response: reqwest::Response) -> Result { + (_, "Game not found", _) => { anyhow::bail!( "Game not found. The game_id in your wavedash.toml may be incorrect.\nRun `wavedash init` to reconfigure." ); diff --git a/src/leaderboards.rs b/src/leaderboards.rs new file mode 100644 index 0000000..d7c630d --- /dev/null +++ b/src/leaderboards.rs @@ -0,0 +1,371 @@ +use crate::auth::require_api_key; +use crate::config; +use anyhow::Result; +use clap::ValueEnum; +use comfy_table::modifiers::UTF8_ROUND_CORNERS; +use comfy_table::presets::UTF8_FULL; +use comfy_table::{Cell, ContentArrangement, Table}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SortOrder { + /// Higher scores rank first (points, kills, distance) + #[value(alias = "descending")] + Desc, + /// Lower scores rank first (speedruns, golf, time) + #[value(alias = "ascending")] + Asc, +} + +impl SortOrder { + fn api_value(self) -> u8 { + match self { + SortOrder::Asc => 0, + SortOrder::Desc => 1, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum DisplayType { + /// Plain number + Numeric, + /// Time, with the score in seconds + Seconds, + /// Time, with the score in milliseconds + Milliseconds, + /// Time, with the score in 60fps game ticks + GameTicks, +} + +impl DisplayType { + fn api_value(self) -> u8 { + match self { + DisplayType::Numeric => 0, + DisplayType::Seconds => 1, + DisplayType::Milliseconds => 2, + DisplayType::GameTicks => 3, + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Leaderboard { + id: String, + name: String, + #[serde(rename = "displayName")] + display_name: String, + #[serde(rename = "sortOrder")] + sort_order: u8, + #[serde(rename = "displayType")] + display_type: u8, + visible: bool, +} + +impl Leaderboard { + fn sort_order_label(&self) -> String { + match self.sort_order { + 0 => "lower is better".to_string(), + 1 => "higher is better".to_string(), + other => other.to_string(), + } + } + + fn display_type_label(&self) -> String { + match self.display_type { + 0 => "numeric".to_string(), + 1 => "seconds".to_string(), + 2 => "milliseconds".to_string(), + 3 => "game ticks".to_string(), + other => other.to_string(), + } + } +} + +#[derive(Debug, Deserialize)] +struct LeaderboardResponse { + leaderboard: Leaderboard, +} + +#[derive(Debug, Deserialize)] +struct LeaderboardPage { + leaderboards: Vec, + #[serde(rename = "nextCursor")] + next_cursor: Option, +} + +/// How `leaderboard update` finds the leaderboard to change. +pub enum LeaderboardSelector<'a> { + Id(&'a str), + Name(&'a str), +} + +pub struct CreateLeaderboardArgs<'a> { + pub game_id: &'a str, + pub name: &'a str, + pub display_name: &'a str, + pub sort_order: SortOrder, + pub display_type: DisplayType, + pub hidden: bool, +} + +pub struct UpdateLeaderboardArgs<'a> { + pub game_id: &'a str, + pub selector: LeaderboardSelector<'a>, + pub display_name: Option<&'a str>, + pub sort_order: Option, + pub display_type: Option, + pub hidden: Option, +} + +/// Names go in the path as one encoded segment, so `Daily Scores` or a name +/// containing `/` can't escape into another route. +fn update_url(api_host: &str, game_id: &str, selector: &LeaderboardSelector) -> String { + match selector { + LeaderboardSelector::Id(id) => format!( + "{}/api/games/{}/leaderboards/{}", + api_host, + game_id, + urlencoding::encode(id) + ), + LeaderboardSelector::Name(name) => format!( + "{}/api/games/{}/leaderboards/by-name/{}", + api_host, + game_id, + urlencoding::encode(name) + ), + } +} + +fn update_body(args: &UpdateLeaderboardArgs) -> serde_json::Map { + let mut body = serde_json::Map::new(); + if let Some(display_name) = args.display_name { + body.insert("displayName".into(), json!(display_name)); + } + if let Some(sort_order) = args.sort_order { + body.insert("sortOrder".into(), json!(sort_order.api_value())); + } + if let Some(display_type) = args.display_type { + body.insert("displayType".into(), json!(display_type.api_value())); + } + if let Some(hidden) = args.hidden { + body.insert("visible".into(), json!(!hidden)); + } + body +} + +pub async fn handle_leaderboard_list(game_id: &str, json: bool) -> Result<()> { + let api_key = require_api_key()?; + let client = config::create_http_client()?; + let api_host = config::get("api_host")?; + let url = format!("{}/api/games/{}/leaderboards", api_host, game_id); + + let mut leaderboards = Vec::new(); + let mut cursor: Option = None; + loop { + let mut request = client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)); + if let Some(cursor) = &cursor { + request = request.query(&[("cursor", cursor)]); + } + let resp = request.send().await?; + let resp = config::check_api_response(resp).await?; + let page: LeaderboardPage = resp.json().await?; + leaderboards.extend(page.leaderboards); + match page.next_cursor { + Some(next) => cursor = Some(next), + None => break, + } + } + + if json { + println!("{}", serde_json::to_string_pretty(&leaderboards)?); + return Ok(()); + } + + if leaderboards.is_empty() { + println!("No leaderboards found."); + return Ok(()); + } + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("ID"), + Cell::new("Name"), + Cell::new("Display Name"), + Cell::new("Sort"), + Cell::new("Display"), + Cell::new("Visible"), + ]); + + for leaderboard in leaderboards { + let sort = leaderboard.sort_order_label(); + let display = leaderboard.display_type_label(); + table.add_row(vec![ + leaderboard.id, + leaderboard.name, + leaderboard.display_name, + sort, + display, + (if leaderboard.visible { "yes" } else { "no" }).to_string(), + ]); + } + + println!("{table}"); + Ok(()) +} + +pub async fn handle_leaderboard_create(args: CreateLeaderboardArgs<'_>) -> Result<()> { + let api_key = require_api_key()?; + let client = config::create_http_client()?; + let api_host = config::get("api_host")?; + let url = format!("{}/api/games/{}/leaderboards", api_host, args.game_id); + + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .json(&json!({ + "name": args.name, + "displayName": args.display_name, + "sortOrder": args.sort_order.api_value(), + "displayType": args.display_type.api_value(), + "visible": !args.hidden, + })) + .send() + .await?; + + let resp = config::check_api_response(resp).await?; + let LeaderboardResponse { leaderboard } = resp.json().await?; + println!( + "✓ Created leaderboard \"{}\" (id: {}, name: {})", + leaderboard.display_name, leaderboard.id, leaderboard.name + ); + Ok(()) +} + +pub async fn handle_leaderboard_update(args: UpdateLeaderboardArgs<'_>) -> Result<()> { + let body = update_body(&args); + if body.is_empty() { + anyhow::bail!("No fields provided to update."); + } + + let api_key = require_api_key()?; + let client = config::create_http_client()?; + let api_host = config::get("api_host")?; + let url = update_url(&api_host, args.game_id, &args.selector); + + let resp = client + .patch(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .json(&serde_json::Value::Object(body)) + .send() + .await?; + + let resp = config::check_api_response(resp).await?; + let LeaderboardResponse { leaderboard } = resp.json().await?; + println!( + "✓ Updated leaderboard \"{}\" (id: {}, name: {})", + leaderboard.display_name, leaderboard.id, leaderboard.name + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn update_args(selector: LeaderboardSelector<'_>) -> UpdateLeaderboardArgs<'_> { + UpdateLeaderboardArgs { + game_id: "game-id", + selector, + display_name: None, + sort_order: None, + display_type: None, + hidden: None, + } + } + + #[test] + fn enum_values_match_the_api() { + assert_eq!(SortOrder::Asc.api_value(), 0); + assert_eq!(SortOrder::Desc.api_value(), 1); + assert_eq!(DisplayType::Numeric.api_value(), 0); + assert_eq!(DisplayType::Seconds.api_value(), 1); + assert_eq!(DisplayType::Milliseconds.api_value(), 2); + assert_eq!(DisplayType::GameTicks.api_value(), 3); + } + + #[test] + fn update_url_addresses_by_id_or_encoded_name() { + let host = "https://api.example.com"; + assert_eq!( + update_url(host, "game-id", &LeaderboardSelector::Id("lb-id")), + "https://api.example.com/api/games/game-id/leaderboards/lb-id" + ); + assert_eq!( + update_url( + host, + "game-id", + &LeaderboardSelector::Name("Daily Scores/2") + ), + "https://api.example.com/api/games/game-id/leaderboards/by-name/Daily%20Scores%2F2" + ); + } + + #[test] + fn update_body_sends_only_the_fields_passed() { + assert!(update_body(&update_args(LeaderboardSelector::Id("lb-id"))).is_empty()); + + let body = update_body(&UpdateLeaderboardArgs { + display_name: Some("Daily Challenge"), + hidden: Some(false), + ..update_args(LeaderboardSelector::Name("daily")) + }); + assert_eq!( + serde_json::Value::Object(body), + json!({ "displayName": "Daily Challenge", "visible": true }) + ); + + let body = update_body(&UpdateLeaderboardArgs { + sort_order: Some(SortOrder::Asc), + display_type: Some(DisplayType::Milliseconds), + hidden: Some(true), + ..update_args(LeaderboardSelector::Id("lb-id")) + }); + assert_eq!( + serde_json::Value::Object(body), + json!({ "sortOrder": 0, "displayType": 2, "visible": false }) + ); + } + + #[test] + fn parses_a_page_and_round_trips_api_field_names() { + let page: LeaderboardPage = serde_json::from_value(json!({ + "leaderboards": [{ + "id": "lb-id", + "name": "high_scores", + "displayName": "High Scores", + "sortOrder": 1, + "displayType": 0, + "visible": true + }], + "nextCursor": null + })) + .expect("the list response should deserialize"); + + assert_eq!(page.next_cursor, None); + let leaderboard = &page.leaderboards[0]; + assert_eq!(leaderboard.sort_order_label(), "higher is better"); + assert_eq!(leaderboard.display_type_label(), "numeric"); + + let value = serde_json::to_value(leaderboard).expect("leaderboard should serialize"); + assert_eq!(value["displayName"], "High Scores"); + assert!(value.get("display_name").is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 4e57cc1..1d12699 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod config; mod dev; mod file_staging; mod init; +mod leaderboards; mod publish; mod stats; mod updater; @@ -27,6 +28,10 @@ use dev::handle_dev; use init::{ handle_init, handle_project_create, handle_project_list, handle_team_create, handle_team_list, }; +use leaderboards::{ + handle_leaderboard_create, handle_leaderboard_list, handle_leaderboard_update, + CreateLeaderboardArgs, DisplayType, LeaderboardSelector, SortOrder, UpdateLeaderboardArgs, +}; use publish::{handle_publish, PublishArgs}; use stats::{handle_stat_create, handle_stat_delete, handle_stat_update}; use std::io::{IsTerminal, Read}; @@ -229,6 +234,10 @@ enum Commands { #[command(subcommand)] action: AchievementCommands, }, + Leaderboard { + #[command(subcommand)] + action: LeaderboardCommands, + }, #[command( name = "clear-playtest-data", about = "Delete playtest data (achievements, saves, stats, leaderboards, entitlements, UGC) for a game" @@ -567,6 +576,110 @@ enum AchievementCommands { }, } +#[derive(Subcommand)] +enum LeaderboardCommands { + #[command(about = "List leaderboards for a game")] + List { + #[arg( + long = "game-id", + value_parser = parse_non_empty_arg, + help = "Game ID (defaults to game_id in wavedash.toml. override with WAVEDASH_GAME_ID)" + )] + game_id: Option, + #[arg( + short = 'c', + long = "config", + help = "Path to wavedash.toml config file", + default_value = "./wavedash.toml" + )] + config: PathBuf, + #[arg(long, help = "Output as JSON")] + json: bool, + }, + #[command(about = "Create a new leaderboard for a game")] + Create { + #[arg( + long = "game-id", + value_parser = parse_non_empty_arg, + help = "Game ID (defaults to game_id in wavedash.toml. override with WAVEDASH_GAME_ID)" + )] + game_id: Option, + #[arg( + short = 'c', + long = "config", + help = "Path to wavedash.toml config file", + default_value = "./wavedash.toml" + )] + config: PathBuf, + #[arg( + long, + help = "Leaderboard name your game passes to getOrCreateLeaderboard (e.g. high_scores)" + )] + name: String, + #[arg( + long = "display-name", + help = "Name players see (e.g. \"High Scores\")" + )] + display_name: String, + #[arg( + long = "sort-order", + value_enum, + default_value_t = SortOrder::Desc, + help = "Which scores rank first" + )] + sort_order: SortOrder, + #[arg( + long = "display-type", + value_enum, + default_value_t = DisplayType::Numeric, + help = "How scores are displayed" + )] + display_type: DisplayType, + #[arg(long, help = "Hide the leaderboard from the game page")] + hidden: bool, + }, + #[command(about = "Update a leaderboard")] + #[command(group( + clap::ArgGroup::new("leaderboard") + .required(true) + .args(["id", "name"]) + ))] + Update { + #[arg( + long = "game-id", + value_parser = parse_non_empty_arg, + help = "Game ID (defaults to game_id in wavedash.toml. override with WAVEDASH_GAME_ID)" + )] + game_id: Option, + #[arg( + short = 'c', + long = "config", + help = "Path to wavedash.toml config file", + default_value = "./wavedash.toml" + )] + config: PathBuf, + #[arg(long, value_parser = parse_non_empty_arg, help = "Leaderboard ID")] + id: Option, + #[arg( + long, + value_parser = parse_non_empty_arg, + help = "Leaderboard name your game passes to getOrCreateLeaderboard" + )] + name: Option, + #[arg(long = "display-name", help = "New display name")] + display_name: Option, + #[arg(long = "sort-order", value_enum, help = "Which scores rank first")] + sort_order: Option, + #[arg(long = "display-type", value_enum, help = "How scores are displayed")] + display_type: Option, + #[arg( + long, + help = "Hide (true) or show (false) the leaderboard on the game page" + )] + hidden: Option, + }, +} + fn env_flag_enabled(name: &str) -> bool { std::env::var(name) .map(|value| { @@ -870,6 +983,63 @@ async fn run() -> Result<()> { } } } + Commands::Leaderboard { action } => match action { + LeaderboardCommands::List { + game_id, + config, + json, + } => { + let game_id = resolve_game_id(game_id.as_deref(), &config)?; + handle_leaderboard_list(&game_id, json).await?; + } + LeaderboardCommands::Create { + game_id, + config, + name, + display_name, + sort_order, + display_type, + hidden, + } => { + let game_id = resolve_game_id(game_id.as_deref(), &config)?; + handle_leaderboard_create(CreateLeaderboardArgs { + game_id: &game_id, + name: &name, + display_name: &display_name, + sort_order, + display_type, + hidden, + }) + .await?; + } + LeaderboardCommands::Update { + game_id, + config, + id, + name, + display_name, + sort_order, + display_type, + hidden, + } => { + // clap's required group guarantees exactly one of --id / --name + let selector = match (id.as_deref(), name.as_deref()) { + (Some(id), _) => LeaderboardSelector::Id(id), + (None, Some(name)) => LeaderboardSelector::Name(name), + (None, None) => unreachable!(), + }; + let game_id = resolve_game_id(game_id.as_deref(), &config)?; + handle_leaderboard_update(UpdateLeaderboardArgs { + game_id: &game_id, + selector, + display_name: display_name.as_deref(), + sort_order, + display_type, + hidden, + }) + .await?; + } + }, Commands::ClearPlaytestData { game_id, config, @@ -1013,6 +1183,84 @@ mod tests { } } + #[test] + fn leaderboard_update_takes_exactly_one_of_id_or_name() { + let cli = Cli::try_parse_from([ + "wavedash", + "leaderboard", + "update", + "--name", + "daily_scores", + "--hidden", + "false", + ]) + .expect("update by name should parse"); + match cli.command { + Some(Commands::Leaderboard { + action: + LeaderboardCommands::Update { + id, name, hidden, .. + }, + }) => { + assert_eq!(id, None); + assert_eq!(name.as_deref(), Some("daily_scores")); + assert_eq!(hidden, Some(false)); + } + _ => panic!("parsed the wrong command"), + } + + for argv in [ + vec!["wavedash", "leaderboard", "update", "--hidden", "true"], + vec![ + "wavedash", + "leaderboard", + "update", + "--id", + "lb-id", + "--name", + "daily_scores", + "--hidden", + "true", + ], + ] { + assert!( + Cli::command().try_get_matches_from(&argv).is_err(), + "`{}` should be rejected", + argv.join(" ") + ); + } + } + + #[test] + fn leaderboard_create_defaults_to_a_visible_higher_is_better_number() { + let cli = Cli::try_parse_from([ + "wavedash", + "leaderboard", + "create", + "--name", + "high_scores", + "--display-name", + "High Scores", + ]) + .expect("leaderboard create should parse"); + match cli.command { + Some(Commands::Leaderboard { + action: + LeaderboardCommands::Create { + sort_order, + display_type, + hidden, + .. + }, + }) => { + assert_eq!(sort_order, SortOrder::Desc); + assert_eq!(display_type, DisplayType::Numeric); + assert!(!hidden); + } + _ => panic!("parsed the wrong command"), + } + } + #[test] fn auth_status_accepts_json_output() { let cli = Cli::try_parse_from(["wavedash", "auth", "status", "--json"])