diff --git a/src/lib/config.rs b/src/lib/config.rs index c35cbf8..7729772 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -486,12 +486,13 @@ pub struct ExperienceTargetConfig { /// ``` /// /// :::caution - /// By default, Mantle does not have permission to make purchases with Robux. Since creating badges - /// costs Robux, you will need to use the `--allow-purchases` flag when you want to create them. + /// Each user can create up to 5 badges for free every day. After that, badges cost 100 Robux each. By + /// default, Mantle does not have permission to make purchases with Robux, so if you go over your daily + /// quota, you will need to use the `--allow-purchases` flag to create them. /// ::: /// - /// Because Roblox does not offer any way to delete badges, when a badge is "deleted" by - /// Mantle, it is updated in the following ways: + /// Because Roblox does not offer any way to delete badges, when a badge is "deleted" by Mantle, it is + /// updated in the following ways: /// /// 1. It is disabled /// 2. Its description is updated to: `Name: \nEnabled: \nDescription:\n` diff --git a/src/lib/resource_graph.rs b/src/lib/resource_graph.rs index 4ecc11a..9b6e39d 100644 --- a/src/lib/resource_graph.rs +++ b/src/lib/resource_graph.rs @@ -66,6 +66,7 @@ pub trait ResourceManager { &self, inputs: TInputs, dependency_outputs: Vec, + price: Option, ) -> Result; async fn get_update_price( @@ -80,6 +81,7 @@ pub trait ResourceManager { inputs: TInputs, outputs: TOutputs, dependency_outputs: Vec, + price: Option, ) -> Result; async fn delete( @@ -143,8 +145,7 @@ where pub fn get_outputs(&self, resource_id: &str) -> Option { self.resources .get(resource_id) - .map(|resource| resource.get_outputs()) - .flatten() + .and_then(|resource| resource.get_outputs()) } fn get_dependency_graph(&self) -> BTreeMap> { @@ -416,7 +417,7 @@ where .get_outputs() .expect("Existing resource should have outputs."); - match manager + let price = match manager .get_update_price( resource.get_inputs(), outputs.clone(), @@ -430,7 +431,8 @@ where logger::log(Paint::yellow(format!( "{} Robux will be charged from your account.", price - ))) + ))); + Some(price) } else { return OperationResult::Skipped(format!( "Resource would cost {} Robux to create. Give Mantle permission to make purchases with --allow-purchases.", @@ -439,11 +441,11 @@ where } } Err(error) => return OperationResult::Failed(error), - Ok(_) => {} + Ok(_) => None, }; match manager - .update(resource.get_inputs(), outputs, dependency_outputs) + .update(resource.get_inputs(), outputs, dependency_outputs, price) .await { Ok(outputs) => OperationResult::SucceededUpdate(outputs), @@ -468,7 +470,7 @@ where logger::log("Inputs:"); logger::log_changeset(get_changeset("", &inputs_hash)); - match manager + let price = match manager .get_create_price(resource.get_inputs(), dependency_outputs.clone()) .await { @@ -478,7 +480,8 @@ where logger::log(Paint::yellow(format!( "{} Robux will be charged from your account.", price - ))) + ))); + Some(price) } else { return OperationResult::Skipped(format!( "Resource would cost {} Robux to create. Give Mantle permission to make purchases with --allow-purchases.", @@ -487,11 +490,11 @@ where } } Err(error) => return OperationResult::Failed(error), - Ok(_) => {} + Ok(_) => None, }; match manager - .create(resource.get_inputs(), dependency_outputs) + .create(resource.get_inputs(), dependency_outputs, price) .await { Ok(outputs) => OperationResult::SucceededCreate(outputs), diff --git a/src/lib/roblox_api.rs b/src/lib/roblox_api.rs index 4a414a0..a767792 100644 --- a/src/lib/roblox_api.rs +++ b/src/lib/roblox_api.rs @@ -1532,6 +1532,7 @@ impl RobloxApi { description: String, icon_file_path: PathBuf, payment_source: CreatorType, + expected_cost: u32, ) -> Result { let req = self .client @@ -1544,7 +1545,8 @@ impl RobloxApi { .part("request.files", Self::get_file_part(icon_file_path).await?) .text("request.name", name) .text("request.description", description) - .text("request.paymentSourceType", payment_source.to_string()), + .text("request.paymentSourceType", payment_source.to_string()) + .text("request.expectedCost", expected_cost.to_string()), ); Self::handle_as_json(req).await @@ -1571,6 +1573,15 @@ impl RobloxApi { Ok(()) } + pub async fn get_create_badge_free_quota(&self, experience_id: AssetId) -> Result { + let req = self.client.get(&format!( + "https://badges.roblox.com/v1/universes/{}/free-badges-quota", + experience_id + )); + + Self::handle_as_json(req).await + } + pub async fn list_badges( &self, experience_id: AssetId, @@ -1742,7 +1753,7 @@ impl RobloxApi { let file_name = format!( "Images/{}", - file_path.file_stem().map(OsStr::to_str).flatten().unwrap() + file_path.file_stem().and_then(OsStr::to_str).unwrap() ); let mut req = self @@ -1777,7 +1788,7 @@ impl RobloxApi { let file_name = format!( "Audio/{}", - file_path.file_stem().map(OsStr::to_str).flatten().unwrap() + file_path.file_stem().and_then(OsStr::to_str).unwrap() ); let req = self @@ -1811,7 +1822,7 @@ impl RobloxApi { let file_name = format!( "Audio/{}", - file_path.file_stem().map(OsStr::to_str).flatten().unwrap() + file_path.file_stem().and_then(OsStr::to_str).unwrap() ); let req = self diff --git a/src/lib/roblox_resource_manager.rs b/src/lib/roblox_resource_manager.rs index ccfe788..4b0362b 100644 --- a/src/lib/roblox_resource_manager.rs +++ b/src/lib/roblox_resource_manager.rs @@ -3,6 +3,9 @@ use std::path::{Path, PathBuf}; use async_trait::async_trait; use chrono::Utc; use serde::{Deserialize, Serialize}; +use yansi::Paint; + +use crate::logger; use super::{ resource_graph::{ @@ -302,10 +305,36 @@ impl ResourceManager for RobloxResourceManager { async fn get_create_price( &self, inputs: RobloxInputs, - _dependency_outputs: Vec, + dependency_outputs: Vec, ) -> Result, String> { match inputs { - RobloxInputs::Badge(_) => Ok(Some(100)), + RobloxInputs::Badge(_) => { + let experience = single_output!(dependency_outputs, RobloxOutputs::Experience); + let free_quota = self + .roblox_api + .get_create_badge_free_quota(experience.asset_id) + .await?; + if free_quota > 0 { + let utc_now = Utc::now(); + let utc_reset = (utc_now + chrono::Duration::days(1)) + .date() + .and_hms(0, 0, 0); + let duration = utc_reset.signed_duration_since(utc_now); + let duration_str = format!( + "{:02}:{:02}:{:02}", + duration.num_hours(), + duration.num_minutes() - duration.num_hours() * 60, + duration.num_seconds() - duration.num_minutes() * 60 + ); + logger::log(""); + logger::log(Paint::yellow( + format!("You will have {} free badge(s) remaining in the current period after creation. Your quota will reset in {}.", free_quota - 1, duration_str), + )); + Ok(None) + } else { + Ok(Some(100)) + } + } RobloxInputs::AudioAsset(inputs) => { let GetCreateAudioAssetPriceResponse { price, can_afford, .. @@ -329,6 +358,7 @@ impl ResourceManager for RobloxResourceManager { &self, inputs: RobloxInputs, dependency_outputs: Vec, + price: Option, ) -> Result { match inputs { RobloxInputs::Experience(inputs) => { @@ -531,6 +561,7 @@ impl ResourceManager for RobloxResourceManager { inputs.description, self.get_path(inputs.icon_file_path), self.payment_source.clone(), + price.unwrap_or(0), ) .await?; @@ -615,37 +646,38 @@ impl ResourceManager for RobloxResourceManager { inputs: RobloxInputs, outputs: RobloxOutputs, dependency_outputs: Vec, + price: Option, ) -> Result { match (inputs.clone(), outputs.clone()) { (RobloxInputs::Experience(_), RobloxOutputs::Experience(_)) => { self.delete(outputs, dependency_outputs.clone()).await?; - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::ExperienceConfiguration(_), RobloxOutputs::ExperienceConfiguration) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::ExperienceActivation(_), RobloxOutputs::ExperienceActivation) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::ExperienceIcon(_), RobloxOutputs::ExperienceIcon(_)) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::ExperienceThumbnail(_), RobloxOutputs::ExperienceThumbnail(_)) => { self.delete(outputs, dependency_outputs.clone()).await?; - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::ExperienceThumbnailOrder, RobloxOutputs::ExperienceThumbnailOrder) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } // TODO: is this correct? (RobloxInputs::Place(_), RobloxOutputs::Place(_)) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::PlaceFile(_), RobloxOutputs::PlaceFile(_)) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::PlaceConfiguration(_), RobloxOutputs::PlaceConfiguration) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::SocialLink(inputs), RobloxOutputs::SocialLink(outputs)) => { let experience = single_output!(dependency_outputs, RobloxOutputs::Experience); @@ -663,7 +695,7 @@ impl ResourceManager for RobloxResourceManager { Ok(RobloxOutputs::SocialLink(outputs)) } (RobloxInputs::ProductIcon(_), RobloxOutputs::ProductIcon(_)) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::Product(inputs), RobloxOutputs::Product(outputs)) => { let experience = single_output!(dependency_outputs, RobloxOutputs::Experience); @@ -731,10 +763,10 @@ impl ResourceManager for RobloxResourceManager { })) } (RobloxInputs::ImageAsset(_), RobloxOutputs::ImageAsset(_)) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::AudioAsset(_), RobloxOutputs::AudioAsset(_)) => { - self.create(inputs, dependency_outputs).await + self.create(inputs, dependency_outputs, price).await } (RobloxInputs::AssetAlias(inputs), RobloxOutputs::AssetAlias(outputs)) => { let experience = single_output!(dependency_outputs, RobloxOutputs::Experience); diff --git a/src/lib/state/mod.rs b/src/lib/state/mod.rs index 20132f5..59df836 100644 --- a/src/lib/state/mod.rs +++ b/src/lib/state/mod.rs @@ -435,8 +435,7 @@ fn get_desired_experience_graph( let path = path.map_err(|e| format!("Glob pattern invalid: {}", e))?; let name = path .file_stem() - .map(OsStr::to_str) - .flatten() + .and_then(OsStr::to_str) .ok_or(format!("Asset path is not a file: {}", path.display()))? .to_owned();