From 5f452f333e26f0ffe28690495734e3883231e141 Mon Sep 17 00:00:00 2001 From: Loong <40141251+wangl-cc@users.noreply.github.com> Date: Tue, 27 Aug 2024 22:10:31 +0100 Subject: [PATCH] feat!: add reclamation and improve fight, roguelike, copilot commands --- .github/actions/install-core/action.yml | 36 +- .github/workflows/ci.yml | 29 +- maa-cli/src/command.rs | 157 +---- maa-cli/src/config/asst.rs | 4 +- maa-cli/src/config/cli/mod.rs | 2 - maa-cli/src/config/mod.rs | 9 +- maa-cli/src/config/task/client_type.rs | 21 + maa-cli/src/config/task/mod.rs | 459 +++++++-------- maa-cli/src/main.rs | 27 +- maa-cli/src/run/mod.rs | 4 + maa-cli/src/run/preset/copilot.rs | 737 +++++++++++++++++------- maa-cli/src/run/preset/fight.rs | 228 ++++++++ maa-cli/src/run/preset/mod.rs | 242 ++++---- maa-cli/src/run/preset/reclamation.rs | 143 +++++ maa-cli/src/run/preset/roguelike.rs | 464 ++++++++++++--- maa-cli/src/value/mod.rs | 17 + 16 files changed, 1738 insertions(+), 841 deletions(-) create mode 100644 maa-cli/src/run/preset/fight.rs create mode 100644 maa-cli/src/run/preset/reclamation.rs diff --git a/.github/actions/install-core/action.yml b/.github/actions/install-core/action.yml index e4012cc7..009fe3a5 100644 --- a/.github/actions/install-core/action.yml +++ b/.github/actions/install-core/action.yml @@ -50,27 +50,23 @@ runs: shell: bash working-directory: ${{ runner.temp }} run: | - ./maa install stable -t0 \ - --api-url https://github.com/MaaAssistantArknights/MaaRelease/raw/main/MaaAssistantArknights/api/version/ - - MAA_CORE_DIR="$(./maa dir lib)" - MAA_RESOURCE_DIR="$(./maa dir resource)" - ls -l "$MAA_CORE_DIR" - ls -l "$MAA_RESOURCE_DIR" - echo "MAA_CORE_DIR=$MAA_CORE_DIR" >> $GITHUB_ENV - echo "MAA_RESOURCE_DIR=$MAA_RESOURCE_DIR" >> $GITHUB_ENV - - package_name=$(basename "$(ls "$(./maa dir cache)")") - echo "Downloaded MaaCore package: $package_name" - core_version=${package_name#MAA-v} - core_version=${core_version%%-*} - if [[ $core_version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Downloaded MaaCore version: $core_version" - echo "MAA_CORE_VERSION=v$core_version" >> "$GITHUB_ENV" - fi - echo "MAA_CORE_INSTALLED=true" >> "$GITHUB_ENV" - - name: Remove Prebuilt CLI + MAA=MaaAssistantArknights + ./maa install beta -t0 \ + --api-url "https://github.com/$MAA/MaaRelease/raw/main/$MAA/api/version/" + core_dir=$(./maa dir lib) + resource_dir=$(./maa dir resource) + version=$(./maa version core) + version=${version#MaaCore v} + ls -l "$core_dir" + ls -l "$resource_dir" + { + echo "MAA_CORE_DIR=$core_dir" + echo "MAA_RESOURCE_DIR=$resource_dir" + echo "MAA_CORE_VERSION=v$version" + } >> "$GITHUB_ENV" + - name: Cleanup shell: bash working-directory: ${{ runner.temp }} run: | + ./maa cleanup --batch rm -vrf maa_cli* stable.json maa* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68bc9fbf..085dc6b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,19 +64,24 @@ jobs: env: MAA_CONFIG_DIR: ${{ github.workspace }}/maa-cli/config_examples run: | - cargo run -- install stable - ls -l "$(cargo run -- dir library)" - ls -l "$(cargo run -- dir resource)" - ls -l "$(cargo run -- dir cache)" - package_name=$(basename "$(ls "$(cargo run -- dir cache)")") - echo "Downloaded MaaCore package: $package_name" + cargo run -- install beta + core_dir=$(cargo run -- dir library) + resource_dir=$(cargo run -- dir resource) + cache_dir=$(cargo run -- dir cache) + package_name=$(basename "$cache_dir"/MAA-v*) version=${package_name#MAA-v} - version=${version%%-*} - if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Downloaded MaaCore version: $version" - echo "MAA_CORE_VERSION=v$version" >> "$GITHUB_ENV" - fi - echo "MAA_CORE_INSTALLED=true" >> "$GITHUB_ENV" + version=${version%%-linux*} + version=${version%%-macos*} + version=${version%%-win*} + ls -l "$core_dir" + ls -l "$resource_dir" + ls -l "$cache_dir" + echo "Downloaded MaaCore version: $version" + { + echo "MAA_CORE_DIR=$core_dir" + echo "MAA_RESOURCE_DIR=$resource_dir" + echo "MAA_CORE_VERSION=v$version" + } >> "$GITHUB_ENV" - name: Test run: | cargo test -- --include-ignored diff --git a/maa-cli/src/command.rs b/maa-cli/src/command.rs index 2262b964..67ab2e3b 100644 --- a/maa-cli/src/command.rs +++ b/maa-cli/src/command.rs @@ -112,53 +112,51 @@ pub(crate) enum Command { /// Startup Game and Enter Main Screen #[command(name = "startup")] StartUp { - /// Client type of the game client - /// - /// The client type of the game client, used to launch the game client. - /// If not specified, the client will not be launched. - client: Option, - /// Account name to switch to - #[arg(long)] - account: Option, + #[command(flatten)] + params: run::preset::StartUpParams, #[command(flatten)] common: run::CommonArgs, }, /// Close game client #[command(name = "closedown")] CloseDown { - /// Client type of the game client - /// - /// The client type of the game client, used to close the game client. - /// If not specified, default to the Official client. - #[arg(default_value = "Official")] - client: config::task::ClientType, + #[command(flatten)] + params: run::preset::CloseDownParams, #[command(flatten)] common: run::CommonArgs, }, /// Run fight task Fight { - /// Stage to fight - #[arg(default_value = "")] - stage: String, - /// medicine to use - #[arg(short, long)] - medicine: Option, + #[command(flatten)] + params: run::preset::FightParams, #[command(flatten)] common: run::CommonArgs, }, /// Run copilot task Copilot { - /// A code copied from or a json file, - /// such as "maa://12345" or "/your/json/path.json". - uri: String, + #[command(flatten)] + params: run::preset::CopilotParams, + #[command(flatten)] + common: run::CommonArgs, + }, + /// Run SSSCopilot task + SSSCopilot { + #[command(flatten)] + params: run::preset::SSSCopilotParams, #[command(flatten)] common: run::CommonArgs, }, /// Run rouge-like task Roguelike { - /// Theme of the game - #[arg(ignore_case = true)] - theme: run::preset::RoguelikeTheme, + #[command(flatten)] + params: run::preset::RoguelikeParams, + #[command(flatten)] + common: run::CommonArgs, + }, + /// Run Reclamation Algorithm task + Reclamation { + #[command(flatten)] + params: run::preset::ReclamationParams, #[command(flatten)] common: run::CommonArgs, }, @@ -566,113 +564,6 @@ mod test { )); } - #[test] - fn startup() { - assert_matches!( - parse_from(["maa", "startup"]).command, - Command::StartUp { - client: None, - account: None, - common: run::CommonArgs { .. }, - } - ); - - assert_matches!( - parse_from(["maa", "startup", "YoStarEN"]).command, - Command::StartUp { - client: Some(client), - .. - } if client == config::task::ClientType::YoStarEN - ); - - assert_matches!( - parse_from(["maa", "startup", "YoStarEN", "--account", "account"]).command, - Command::StartUp { - client: Some(client), - account: Some(account), - .. - } if client == config::task::ClientType::YoStarEN && account == "account" - ); - } - - #[test] - fn closedown() { - assert_matches!( - parse_from(["maa", "closedown"]).command, - Command::CloseDown { - client: config::task::ClientType::Official, - common: run::CommonArgs { .. } - } - ); - - assert_matches!( - parse_from(["maa", "closedown", "YoStarEN"]).command, - Command::CloseDown { - client: config::task::ClientType::YoStarEN, - common: run::CommonArgs { .. } - } - ); - } - - #[test] - fn fight() { - assert_matches!( - parse_from(["maa", "fight", "1-7"]).command, - Command::Fight { - stage, - .. - } if stage == "1-7" - ); - - assert_matches!( - parse_from(["maa", "fight", "1-7", "-m", "1"]).command, - Command::Fight { - stage, - medicine: Some(medicine), - .. - } if stage == "1-7" && medicine == 1 - ); - - assert_matches!( - parse_from(["maa", "fight", "1-7", "--medicine", "1"]).command, - Command::Fight { - stage, - medicine: Some(medicine), - .. - } if stage == "1-7" && medicine == 1 - ); - } - - #[test] - fn copilot() { - assert_matches!( - parse_from(["maa", "copilot", "maa://12345"]).command, - Command::Copilot { - uri, - .. - } if uri == "maa://12345" - ); - - assert_matches!( - parse_from(["maa", "copilot", "/your/json/path.json"]).command, - Command::Copilot { - uri, - common: run::CommonArgs { .. } - } if uri == "/your/json/path.json" - ); - } - - #[test] - fn rougelike() { - assert_matches!( - parse_from(["maa", "roguelike", "phantom"]).command, - Command::Roguelike { - theme, - .. - } if matches!(theme, run::preset::RoguelikeTheme::Phantom) - ); - } - #[test] fn convert() { assert_matches!( diff --git a/maa-cli/src/config/asst.rs b/maa-cli/src/config/asst.rs index e038f6db..a0a2231b 100644 --- a/maa-cli/src/config/asst.rs +++ b/maa-cli/src/config/asst.rs @@ -66,8 +66,6 @@ impl<'de> Deserialize<'de> for AsstConfig { } } -impl super::FromFile for AsstConfig {} - #[cfg_attr(test, derive(Debug, PartialEq))] #[derive(Deserialize, Clone, Default)] pub struct ConnectionConfig { @@ -232,7 +230,7 @@ pub struct ResourceConfig { user_resource: bool, /// Resource base directories, a list of directories containing resource directories /// Not deserialized from config file - resource_base_dirs: Vec, + pub(crate) resource_base_dirs: Vec, } impl<'de> Deserialize<'de> for ResourceConfig { diff --git a/maa-cli/src/config/cli/mod.rs b/maa-cli/src/config/cli/mod.rs index b1875cdc..f0447c0b 100644 --- a/maa-cli/src/config/cli/mod.rs +++ b/maa-cli/src/config/cli/mod.rs @@ -45,8 +45,6 @@ impl CLIConfig { } } -impl super::FromFile for CLIConfig {} - pub fn cli_config() -> &'static CLIConfig { static INSTALLER_CONFIG: OnceLock = OnceLock::new(); INSTALLER_CONFIG.get_or_init(|| { diff --git a/maa-cli/src/config/mod.rs b/maa-cli/src/config/mod.rs index dc1f3394..e03c789f 100644 --- a/maa-cli/src/config/mod.rs +++ b/maa-cli/src/config/mod.rs @@ -3,7 +3,6 @@ use crate::dirs::{self, Ensure}; use std::fs::{self, File}; use std::path::Path; -use clap::ValueEnum; use serde_json::Value as JsonValue; #[derive(Debug)] @@ -79,7 +78,7 @@ fn file_not_found(path: impl AsRef) -> Error { const SUPPORTED_EXTENSION: [&str; 4] = ["json", "yaml", "yml", "toml"]; -#[derive(Clone, Copy, ValueEnum)] +#[derive(Clone, Copy, clap::ValueEnum)] pub enum Filetype { #[clap(alias = "j")] Json, @@ -158,6 +157,8 @@ pub trait FromFile: Sized + serde::de::DeserializeOwned { } } +impl FromFile for T where T: serde::de::DeserializeOwned {} + pub trait FindFile: FromFile { /// Find file with supported extension and deserialize it. /// @@ -193,8 +194,6 @@ impl FindFile for T where T: FromFile {} impl FindFileOrDefault for T where T: FromFile + Default {} -impl FromFile for JsonValue {} - pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result<()> { let ft = ft.or_else(|| { out.and_then(|path| path.extension()) @@ -410,8 +409,6 @@ mod tests { b: String, } - impl FromFile for TestConfig {} - let test_root = temp_dir().join("find_file"); std::fs::create_dir_all(&test_root).unwrap(); diff --git a/maa-cli/src/config/task/client_type.rs b/maa-cli/src/config/task/client_type.rs index 22d9e782..9bf11152 100644 --- a/maa-cli/src/config/task/client_type.rs +++ b/maa-cli/src/config/task/client_type.rs @@ -89,6 +89,17 @@ impl ClientType { YoStarJP | YoStarKR => 5, } } + + /// The server type of sample used in the report. + pub const fn server_report(self) -> Option<&'static str> { + match self { + Official | Bilibili => Some("CN"), + YoStarEN => Some("US"), + YoStarJP => Some("JP"), + YoStarKR => Some("KR"), + _ => None, + } + } } impl<'de> Deserialize<'de> for ClientType { @@ -220,6 +231,16 @@ mod tests { assert_eq!(YoStarKR.server_time_zone(), 5); } + #[test] + fn to_server_report() { + assert_eq!(Official.server_report(), Some("CN")); + assert_eq!(Bilibili.server_report(), Some("CN")); + assert_eq!(Txwy.server_report(), None); + assert_eq!(YoStarEN.server_report(), Some("US")); + assert_eq!(YoStarJP.server_report(), Some("JP")); + assert_eq!(YoStarKR.server_report(), Some("KR")); + } + #[test] fn to_str() { assert_eq!(Official.to_str(), "Official"); diff --git a/maa-cli/src/config/task/mod.rs b/maa-cli/src/config/task/mod.rs index bac9e5b9..7434ecc1 100644 --- a/maa-cli/src/config/task/mod.rs +++ b/maa-cli/src/config/task/mod.rs @@ -40,10 +40,6 @@ impl TaskVariant { } } -fn default_variants() -> Vec { - vec![Default::default()] -} - #[cfg_attr(test, derive(PartialEq))] #[derive(Deserialize, Debug, Default)] #[serde(rename_all = "kebab-case")] @@ -71,47 +67,54 @@ pub struct Task { params: MAAValue, #[serde(default)] strategy: Strategy, - #[serde(default = "default_variants")] + #[serde(default)] variants: Vec, } +// Constructor for Task impl Task { - pub fn new( - name: Option, - task_type: T, - params: V, - strategy: Strategy, - variants: S, - ) -> Self - where - T: Into, - V: Into, - S: IntoIterator, - { + pub fn new(task_type: TaskType, params: MAAValue) -> Self { Self { - name, - task_type: task_type.into(), - strategy, - params: params.into(), - variants: variants.into_iter().collect(), + name: None, + task_type, + strategy: Strategy::default(), + params, + variants: Vec::new(), } } - pub fn new_with_default(task_type: T, params: V) -> Self - where - T: Into, - V: Into, - { - Self::new( - None, - task_type, - params, - Strategy::default(), - default_variants(), - ) + #[cfg(test)] + pub fn with_name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + #[cfg(test)] + pub fn with_strategy(mut self, strategy: Strategy) -> Self { + self.strategy = strategy; + self + } + + #[cfg(test)] + pub fn with_variants(mut self, variants: Vec) -> Self { + self.variants = variants; + self + } + + #[cfg(test)] + pub fn push_variant(&mut self, variants: TaskVariant) -> &mut Self { + self.variants.push(variants); + self + } + + pub fn task_type(&self) -> TaskType { + self.task_type } pub fn is_active(&self) -> bool { + if self.variants.is_empty() { + return true; + } for variant in self.variants.iter() { if variant.is_active() { return true; @@ -120,28 +123,13 @@ impl Task { false } - pub fn task_type(&self) -> TaskType { - self.task_type - } - pub fn params(&self) -> MAAValue { let mut params = self.params.clone(); - match self.strategy { - // Merge params from the first active variant - Strategy::First => { - for variant in &self.variants { - if variant.is_active() { - params.merge_mut(variant.params()); - break; - } - } - } - // Merge params from all active variants - Strategy::Merge => { - for variant in &self.variants { - if variant.is_active() { - params.merge_mut(variant.params()); - } + for variant in &self.variants { + if variant.is_active() { + params.merge_mut(variant.params()); + if matches!(self.strategy, Strategy::First) { + break; } } } @@ -261,7 +249,13 @@ impl TaskConfig { _ => {} } - tasks.push(InitializedTask::new(task.name.clone(), task_type, params)); + let mut inited_task = InitializedTask::new(task_type, params); + + if let Some(name) = &task.name { + inited_task = inited_task.with_name(name.to_owned()); + } + + tasks.push(inited_task) } let client_type = client_type.unwrap_or_default(); @@ -280,7 +274,7 @@ impl TaskConfig { if prepend_startup { tasks.insert( 0, - InitializedTask::new_no_name( + InitializedTask::new( TaskType::StartUp, object!( "start_game_enabled" => true, @@ -291,7 +285,7 @@ impl TaskConfig { } if append_closedown { - tasks.push(InitializedTask::new_no_name( + tasks.push(InitializedTask::new( TaskType::CloseDown, object!( "client_type" => client_type.to_string(), @@ -308,8 +302,6 @@ impl TaskConfig { } } -impl super::FromFile for TaskConfig {} - #[cfg_attr(test, derive(PartialEq, Debug))] pub struct InitializedTaskConfig { pub client_type: ClientType, @@ -326,16 +318,17 @@ pub struct InitializedTask { } impl InitializedTask { - fn new(name: Option, task_type: impl Into, params: MAAValue) -> Self { + const fn new(task_type: TaskType, params: MAAValue) -> Self { Self { - name, - task_type: task_type.into(), + name: None, + task_type, params, } } - fn new_no_name(task_type: impl Into, params: MAAValue) -> Self { - Self::new(None, task_type.into(), params) + fn with_name(mut self, name: String) -> Self { + self.name = Some(name); + self } pub fn name_or_default(&self) -> &str { @@ -351,12 +344,6 @@ mod tests { use crate::object; - impl TaskConfig { - pub fn tasks(&self) -> &[Task] { - &self.tasks - } - } - mod task { use super::*; @@ -364,14 +351,9 @@ mod tests { fn is_active() { fn test_with_veriants(variants: Vec, expected: bool) { assert_eq!( - Task::new( - None, - TaskType::StartUp, - object!(), - Strategy::default(), - variants - ) - .is_active(), + Task::new(TaskType::StartUp, object!()) + .with_variants(variants) + .is_active(), expected ); } @@ -402,7 +384,7 @@ mod tests { #[test] fn get_type() { assert_eq!( - Task::new_with_default(TaskType::StartUp, object!()).task_type(), + Task::new(TaskType::StartUp, object!()).task_type(), TaskType::StartUp, ); } @@ -415,28 +397,31 @@ mod tests { variants: impl IntoIterator, expected: MAAValue, ) { - assert_eq!( - Task::new( - None, - TaskType::StartUp, - base, - strategy, - variants.into_iter().map(|v| TaskVariant { - condition: Condition::Always, - params: v, - }) - ) - .params(), - expected - ); + let mut task = Task::new(TaskType::StartUp, base).with_strategy(strategy); + for v in variants { + task.push_variant(TaskVariant { + condition: Condition::Always, + params: v, + }); + } + + assert_eq!(task.params(), expected); } + test_with_variants( + object!("a" => 1), + Strategy::First, + vec![], + object!("a" => 1), + ); + test_with_variants( object!("a" => 1), Strategy::First, vec![object!()], object!("a" => 1), ); + test_with_variants( object!(), Strategy::First, @@ -487,25 +472,21 @@ mod tests { ); assert_eq!( - Task::new( - None, - TaskType::StartUp, - object!("a" => 1, "c" => 5), - Strategy::First, - vec![ - TaskVariant { - condition: Condition::Not { - condition: Box::new(Condition::Always), - }, - params: object!("a" => 2), - }, - TaskVariant { - condition: Condition::Always, - params: object!("a" => 3, "b" => 4), + { + let mut task = Task::new(TaskType::StartUp, object!("a" => 1, "c" => 5)) + .with_strategy(Strategy::First); + task.push_variant(TaskVariant { + condition: Condition::Not { + condition: Box::new(Condition::Always), }, - ] - ) - .params(), + params: object!("a" => 2), + }); + task.push_variant(TaskVariant { + condition: Condition::Always, + params: object!("a" => 3, "b" => 4), + }); + task.params() + }, object!("a" => 3, "b" => 4, "c" => 5), ); } @@ -544,7 +525,7 @@ mod tests { let mut task_list = TaskConfig::new(); - task_list.push(Task::new_with_default( + task_list.push(Task::new( StartUp, object!( "start_game_enabled" => BoolInput::new( @@ -565,85 +546,84 @@ mod tests { ), )); - task_list.push(Task::new( - Some("Fight Daily".to_string()), - Fight, - object!(), - Strategy::Merge, - vec![ - TaskVariant { - condition: Condition::Weekday { - weekdays: vec![Weekday::Sun], - timezone: TimeOffset::Local, + task_list.push( + Task::new(Fight, object!()) + .with_name("Fight Daily".to_string()) + .with_strategy(Strategy::Merge) + .with_variants(vec![ + TaskVariant { + condition: Condition::Weekday { + weekdays: vec![Weekday::Sun], + timezone: TimeOffset::Local, + }, + params: object!("expiring_medicine" => 5), }, - params: object!("expiring_medicine" => 5), - }, - TaskVariant { - condition: Condition::Always, - params: object!( - "stage" => Input::new( - Some("1-7".to_string()), - Some("a stage to fight"), + TaskVariant { + condition: Condition::Always, + params: object!( + "stage" => Input::new( + Some("1-7".to_string()), + Some("a stage to fight"), + ), ), - ), - }, - TaskVariant { - condition: Condition::Weekday { - weekdays: vec![Weekday::Tue, Weekday::Thu, Weekday::Sat], - timezone: TimeOffset::Client(ClientType::Official), }, - params: object!("stage" => "CE-6"), - }, - TaskVariant { - condition: Condition::DateTime { - start: Some(naive_local_datetime(2023, 8, 1, 16, 0, 0)), - end: Some(naive_local_datetime(2023, 8, 21, 3, 59, 59)), - timezone: TimeOffset::TimeZone(8), + TaskVariant { + condition: Condition::Weekday { + weekdays: vec![Weekday::Tue, Weekday::Thu, Weekday::Sat], + timezone: TimeOffset::Client(ClientType::Official), + }, + params: object!("stage" => "CE-6"), }, - params: object!( - "stage" => SelectD::::new( - [ - "SL-6", - "SL-7", - "SL-8", - ], - Some(2), - Some("a stage to fight in summer event"), - true, - ).unwrap(), - ), - }, - ], - )); + TaskVariant { + condition: Condition::DateTime { + start: Some(naive_local_datetime(2023, 8, 1, 16, 0, 0)), + end: Some(naive_local_datetime(2023, 8, 21, 3, 59, 59)), + timezone: TimeOffset::TimeZone(8), + }, + params: object!( + "stage" => SelectD::::new( + [ + "SL-6", + "SL-7", + "SL-8", + ], + Some(2), + Some("a stage to fight in summer event"), + true, + ).unwrap(), + ), + }, + ]), + ); - task_list.push(Task::new( - None, - Mall, - object!( - "shopping" => true, - "credit_fight" => true, - "buy_first" => [ - "招聘许可", - "龙门币", - ], - "blacklist" => [ - "碳", - "家具", - "加急许可", - ], - ), - Strategy::default(), - vec![TaskVariant { + task_list.push( + Task::new( + Mall, + object!( + "shopping" => true, + "credit_fight" => true, + "buy_first" => [ + "招聘许可", + "龙门币", + ], + "blacklist" => [ + "碳", + "家具", + "加急许可", + ], + ), + ) + .with_variants(vec![TaskVariant { condition: Condition::Time { start: Some(NaiveTime::from_hms_opt(16, 0, 0).unwrap()), end: None, timezone: TimeOffset::Local, }, params: object!(), - }], - )); + }]), + ); - task_list.push(Task::new_with_default(CloseDown, object!())); + task_list.push(Task::new(CloseDown, object!())); task_list } @@ -704,18 +684,13 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new( - None, - StartUp, - object!("start_game_enabled" => true), - Strategy::default(), - vec![TaskVariant { + tasks: vec![Task::new(StartUp, object!("start_game_enabled" => true)) + .with_variants(vec![TaskVariant { condition: Condition::Not { condition: Box::new(Condition::Always), }, params: object!(), - }], - )], + }]),], } .init() .unwrap(), @@ -732,7 +707,7 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new_with_default( + tasks: vec![Task::new( StartUp, object!( "start_game_enabled" => true, @@ -746,7 +721,7 @@ mod tests { client_type: YoStarEN, start_app: true, close_app: false, - tasks: vec![InitializedTask::new_no_name( + tasks: vec![InitializedTask::new( StartUp, object!( "start_game_enabled" => true, @@ -761,7 +736,7 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new_with_default( + tasks: vec![Task::new( StartUp, object!( "start_game_enabled" => false, @@ -775,7 +750,7 @@ mod tests { client_type: YoStarEN, start_app: false, close_app: false, - tasks: vec![InitializedTask::new_no_name( + tasks: vec![InitializedTask::new( StartUp, object!( "start_game_enabled" => false, @@ -791,10 +766,7 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new_with_default( - CloseDown, - object!("client_type" => "YoStarEN") - )], + tasks: vec![Task::new(CloseDown, object!("client_type" => "YoStarEN"))], } .init() .unwrap(), @@ -802,7 +774,7 @@ mod tests { client_type: YoStarEN, start_app: false, close_app: true, - tasks: vec![InitializedTask::new_no_name( + tasks: vec![InitializedTask::new( CloseDown, object!("client_type" => "YoStarEN") )] @@ -814,7 +786,7 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new_with_default( + tasks: vec![Task::new( CloseDown, object!( "enable" => false, @@ -828,7 +800,7 @@ mod tests { client_type: YoStarEN, start_app: false, close_app: false, - tasks: vec![InitializedTask::new_no_name( + tasks: vec![InitializedTask::new( CloseDown, object!( "enable" => false, @@ -843,7 +815,7 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new_with_default(CloseDown, object!())], + tasks: vec![Task::new(CloseDown, object!())], } .init() .unwrap(), @@ -851,7 +823,7 @@ mod tests { client_type: Official, start_app: false, close_app: true, - tasks: vec![InitializedTask::new_no_name( + tasks: vec![InitializedTask::new( CloseDown, object!("client_type" => "Official") )] @@ -863,10 +835,7 @@ mod tests { client_type: None, startup: None, closedown: None, - tasks: vec![Task::new_with_default( - Fight, - object!("client_type" => "YoStarEN") - )], + tasks: vec![Task::new(Fight, object!("client_type" => "YoStarEN"))], } .init() .unwrap(), @@ -874,7 +843,7 @@ mod tests { client_type: YoStarEN, start_app: false, close_app: false, - tasks: vec![InitializedTask::new_no_name( + tasks: vec![InitializedTask::new( Fight, object!("client_type" => "YoStarEN") )] @@ -887,15 +856,15 @@ mod tests { startup: None, closedown: None, tasks: vec![ - Task::new_with_default( + Task::new( StartUp, object!( "start_game_enabled" => true, "client_type" => "Official", ), ), - Task::new_with_default(Fight, object!("stage" => "1-7")), - Task::new_with_default(CloseDown, object!()), + Task::new(Fight, object!("stage" => "1-7")), + Task::new(CloseDown, object!()), ], } .init() @@ -905,24 +874,21 @@ mod tests { start_app: true, close_app: true, tasks: vec![ - InitializedTask::new_no_name( + InitializedTask::new( StartUp, object!( "client_type" => "Official", "start_game_enabled" => true, ) ), - InitializedTask::new_no_name( + InitializedTask::new( Fight, object!( "stage" => "1-7", "client_type" => "Official", ) ), - InitializedTask::new_no_name( - CloseDown, - object!("client_type" => "Official") - ), + InitializedTask::new(CloseDown, object!("client_type" => "Official")), ] } ); @@ -933,9 +899,9 @@ mod tests { startup: Some(true), closedown: Some(true), tasks: vec![ - Task::new_with_default(StartUp, object!( "start_game_enabled" => false)), - Task::new_with_default(Fight, object!("stage" => "1-7")), - Task::new_with_default(CloseDown, object!("enable" => false)), + Task::new(StartUp, object!( "start_game_enabled" => false)), + Task::new(Fight, object!("stage" => "1-7")), + Task::new(CloseDown, object!("enable" => false)), ], } .init() @@ -945,7 +911,7 @@ mod tests { start_app: true, close_app: true, tasks: vec![ - InitializedTask::new_no_name( + InitializedTask::new( StartUp, object!( "enable" => true, @@ -953,14 +919,14 @@ mod tests { "start_game_enabled" => true, ) ), - InitializedTask::new_no_name( + InitializedTask::new( Fight, object!( "stage" => "1-7", "client_type" => "Official", ) ), - InitializedTask::new_no_name( + InitializedTask::new( CloseDown, object!( "enable" => true, @@ -976,7 +942,7 @@ mod tests { client_type: None, startup: Some(true), closedown: Some(true), - tasks: vec![Task::new_with_default(Fight, object!("stage" => "1-7"))], + tasks: vec![Task::new(Fight, object!("stage" => "1-7"))], } .init() .unwrap(), @@ -985,24 +951,21 @@ mod tests { start_app: true, close_app: true, tasks: vec![ - InitializedTask::new_no_name( + InitializedTask::new( StartUp, object!( "client_type" => "Official", "start_game_enabled" => true, ) ), - InitializedTask::new_no_name( + InitializedTask::new( Fight, object!( "stage" => "1-7", "client_type" => "Official", ) ), - InitializedTask::new_no_name( - CloseDown, - object!("client_type" => "Official"), - ), + InitializedTask::new(CloseDown, object!("client_type" => "Official"),), ] }, ); @@ -1012,7 +975,7 @@ mod tests { client_type: Some(YoStarEN), startup: Some(true), closedown: Some(true), - tasks: vec![Task::new_with_default(Fight, object!("stage" => "1-7"))], + tasks: vec![Task::new(Fight, object!("stage" => "1-7"))], } .init() .unwrap(), @@ -1021,24 +984,21 @@ mod tests { start_app: true, close_app: true, tasks: vec![ - InitializedTask::new_no_name( + InitializedTask::new( StartUp, object!( "start_game_enabled" => true, "client_type" => "YoStarEN", ) ), - InitializedTask::new_no_name( + InitializedTask::new( Fight, object!( "stage" => "1-7", "client_type" => "YoStarEN", ) ), - InitializedTask::new_no_name( - CloseDown, - object!("client_type" => "YoStarEN"), - ), + InitializedTask::new(CloseDown, object!("client_type" => "YoStarEN"),), ] } ); @@ -1050,8 +1010,8 @@ mod tests { startup: None, closedown: None, tasks: vec![ - Task::new_with_default(StartUp, object!("client_type" => "YoStarEN")), - Task::new_with_default(CloseDown, object!("client_type" => "YoStarJP")), + Task::new(StartUp, object!("client_type" => "YoStarEN")), + Task::new(CloseDown, object!("client_type" => "YoStarJP")), ], } .init() @@ -1061,11 +1021,8 @@ mod tests { start_app: false, close_app: true, tasks: vec![ - InitializedTask::new_no_name(StartUp, object!("client_type" => "Official")), - InitializedTask::new_no_name( - CloseDown, - object!("client_type" => "Official") - ), + InitializedTask::new(StartUp, object!("client_type" => "Official")), + InitializedTask::new(CloseDown, object!("client_type" => "Official")), ] } ); @@ -1078,8 +1035,8 @@ mod tests { startup: None, closedown: None, tasks: vec![ - Task::new_with_default(Infrast, object!("filename" => "daily.json")), - Task::new_with_default(Infrast, object!("filename" => "/tmp/daily.json")), + Task::new(Infrast, object!("filename" => "daily.json")), + Task::new(Infrast, object!("filename" => "/tmp/daily.json")), ], } .init() @@ -1089,7 +1046,7 @@ mod tests { start_app: false, close_app: false, tasks: vec![ - InitializedTask::new_no_name( + InitializedTask::new( Infrast, object!("filename" => dirs::abs_config("daily.json", Some("infrast")) .unwrap() @@ -1097,10 +1054,7 @@ mod tests { .unwrap() .to_string()) ), - InitializedTask::new_no_name( - Infrast, - object!("filename" => "/tmp/daily.json") - ) + InitializedTask::new(Infrast, object!("filename" => "/tmp/daily.json")) ] } ); @@ -1108,17 +1062,14 @@ mod tests { #[test] fn initialized_task() { - let task = InitializedTask::new( - Some("Fight Daily".to_string()), - Fight, - object!("stage" => "1-7"), - ); + let task = InitializedTask::new(Fight, object!("stage" => "1-7")) + .with_name("Fight Daily".to_string()); assert_eq!(task.name_or_default(), "Fight Daily"); assert_eq!(task.task_type, Fight); assert_eq!(&task.params, &object!("stage" => "1-7")); assert_eq!(task.name, Some(String::from("Fight Daily"))); - let task = InitializedTask::new_no_name(Fight, object!("stage" => "1-7")); + let task = InitializedTask::new(Fight, object!("stage" => "1-7")); assert_eq!(task.name_or_default(), "Fight"); assert_eq!(task.task_type, Fight); assert_eq!(&task.params, &object!("stage" => "1-7")); diff --git a/maa-cli/src/main.rs b/maa-cli/src/main.rs index 95b99ad2..24e8a36e 100644 --- a/maa-cli/src/main.rs +++ b/maa-cli/src/main.rs @@ -75,26 +75,13 @@ fn main() -> Result<()> { } }, Command::Run { task, common } => run::run_custom(task, common)?, - Command::StartUp { - client, - account, - common, - } => run::run(|_| run::preset::startup(client, account), common)?, - Command::CloseDown { client, common } => { - run::run(|_| run::preset::closedown(client), common)? - } - Command::Fight { - stage, - medicine, - common, - } => run::run(|_| run::preset::fight(stage, medicine), common)?, - Command::Copilot { uri, common } => run::run( - |config| run::preset::copilot(uri, config.resource.base_dirs()), - common, - )?, - Command::Roguelike { theme, common } => { - run::run(|_| run::preset::roguelike(theme), common)? - } + Command::StartUp { params, common } => run::run_preset(params, common)?, + Command::CloseDown { params, common } => run::run_preset(params, common)?, + Command::Fight { params, common } => run::run_preset(params, common)?, + Command::Roguelike { params, common } => run::run_preset(params, common)?, + Command::Copilot { params, common } => run::run_preset(params, common)?, + Command::SSSCopilot { params, common } => run::run_preset(params, common)?, + Command::Reclamation { params, common } => run::run_preset(params, common)?, Command::Convert { input, output, diff --git a/maa-cli/src/run/mod.rs b/maa-cli/src/run/mod.rs index 38205101..f8eee3b2 100644 --- a/maa-cli/src/run/mod.rs +++ b/maa-cli/src/run/mod.rs @@ -241,6 +241,10 @@ where Ok(()) } +pub fn run_preset(params: impl preset::IntoTaskConfig, args: CommonArgs) -> Result<()> { + run(|config| params.into_task_config(config), args) +} + pub fn run_custom(path: impl AsRef, args: CommonArgs) -> Result<()> { run( |_| { diff --git a/maa-cli/src/run/preset/copilot.rs b/maa-cli/src/run/preset/copilot.rs index b5b9c825..7e97ee6f 100644 --- a/maa-cli/src/run/preset/copilot.rs +++ b/maa-cli/src/run/preset/copilot.rs @@ -2,193 +2,311 @@ use crate::{ config::task::{Task, TaskConfig}, dirs::{self, Ensure}, object, - value::userinput::{BoolInput, Input}, + value::MAAValue, }; -use std::{ - borrow::Cow, - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::{borrow::Cow, fs, io::Write, path::Path}; use anyhow::{bail, Context, Result}; -use log::{debug, trace, warn}; +use log::{debug, trace}; use maa_sys::TaskType; use prettytable::{format, row, Table}; use serde_json::Value as JsonValue; -const MAA_COPILOT_API: &str = "https://prts.maa.plus/copilot/get/"; +#[cfg_attr(test, derive(Default))] +#[derive(clap::Args)] +pub struct CopilotParams { + uri_list: Vec, + /// Whether to fight stage in raid mode + /// + /// 0 for normal, 1 for raid, 2 run twice for both normal and raid + #[arg(long, default_value = "0")] + raid: u8, + /// Whether to auto formation + /// + /// When multiple uri are provided or the uri is a copilot task set, default to true. + /// Otherwise, default to false. + #[arg(long)] + formation: bool, + /// Whether to use sanity potion to restore sanity when it's not enough + /// + /// When multiple uri are provided or the uri is a copilot task set, default to false. + /// Otherwise, default to true. + #[arg(long)] + use_sanity_potion: bool, + /// Whether to navigate to the stage + /// + /// When multiple uri are provided or the uri is a copilot task set, default to true. + /// Otherwise, default to false. + #[arg(long)] + need_navigation: bool, + /// Whether to add operators to empty slots in the formation to earn trust + #[arg(long)] + add_trust: bool, + /// Select which formation to use [0-4] + /// + /// 0 to the current formation in the game, 1 to 4 to use the corresponding formation. + #[arg(long, default_value = "0")] + select_formation: i32, // use i32 to match MAAValue + /// Use given support unit name, don't use support unit if not provided + #[arg(long)] + support_unit_name: Option, +} -pub fn copilot(uri: impl AsRef, resource_dirs: &Vec) -> Result { - let (value, path) = - CopilotJson::new(uri.as_ref())?.get_json_and_file(dirs::copilot().ensure()?)?; +impl super::IntoTaskConfig for CopilotParams { + fn into_task_config(self, config: &super::AsstConfig) -> Result { + let copilot_dir = dirs::copilot().ensure()?; + let base_dirs = config.resource.base_dirs(); - // Determine type of stage - let task_type = match value["type"].as_str() { - Some("SSS") => CopilotType::SSSCopilot, - _ => CopilotType::Copilot, - }; + let mut copilot_files = Vec::new(); + for uri in &self.uri_list { + let copilot_file = CopilotFile::from_uri(uri)?; - // Print stage info - let stage_id = value["stage_name"] - .as_str() - .context("Failed to get stage ID")?; - let stage_name = task_type.get_stage_name(resource_dirs, stage_id)?; + copilot_file.push_path_to(&mut copilot_files, copilot_dir)?; + } - println!("Copilot Stage: {}", stage_name); + let is_task_list = self.raid == 2 || copilot_files.len() > 1; + let need_formation = self.formation || is_task_list; + let need_navigation = self.need_navigation || is_task_list; + let use_sanity_potion = self.use_sanity_potion || is_task_list; + + let mut task_config = TaskConfig::new(); + for file in copilot_files { + let copilot_info = json_from_file(&file)?; + let stage_id = copilot_info + .get("stage_name") + .context("No stage_name")? + .as_str() + .context("stage_name is not a string")?; + + let stage_info = get_stage_info(stage_id, base_dirs.iter().map(|dir| dir.as_path()))?; + let stage_code = get_str_key(&stage_info, "code")?; + let stage_name = get_str_key(&stage_info, "name")?; + + println!("Fight Stage: {stage_code} {stage_name}"); + println!("Operators:\n{}", operator_table(&copilot_info)?); + + let mut value = object!( + "filename" => String::from(file.to_str().context("Invalid file path")?), + "formation" => need_formation, + "need_navigation" => need_navigation, + "navigate_name" => stage_code, + "use_sanity_potion" => use_sanity_potion, + "add_trust" => self.add_trust, + "select_formation" => self.select_formation, + ); - // Print operators info - println!("Operators:\n{}", operator_table(&value)?); + value.maybe_insert("support_unit_name", self.support_unit_name.clone()); - // Append task - let mut task_config = TaskConfig::new(); + match self.raid { + 0 => { + task_config.push(Task::new(TaskType::Copilot, value)); + } + 1 => { + value.insert("is_raid", true); + task_config.push(Task::new(TaskType::Copilot, value)); + } + 2 => { + task_config.push(Task::new(TaskType::Copilot, value.clone())); - task_config.push(task_type.to_task(path.to_str().context("Invalid path")?)); + value.insert("is_raid", true); + task_config.push(Task::new(TaskType::Copilot, value)); + } + n => bail!("Invalid raid mode {n}, should be 0, 1 or 2"), + } + } + + Ok(task_config) + } +} - Ok(task_config) +fn get_stage_info(stage_id: &str, base_dirs: D) -> Result +where + P: AsRef, + D: IntoIterator, +{ + let stage_files = dirs::global_find(base_dirs, |dir| { + let dir = dir.join("Arknights-Tile-Pos"); + trace!("Searching stage file in {}", dir.display()); + fs::read_dir(dir).ok().and_then(|entries| { + entries + .filter_map(|entry| entry.map(|e| e.path()).ok()) + .find(|file_path| { + file_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .map_or(false, |file_name| { + file_name.starts_with(stage_id) && file_name.ends_with("json") + }) + }) + }) + }); + + if let Some(stage_file) = stage_files.last() { + json_from_file(stage_file) + } else { + bail!("Failed to find Tile-Pos file for {stage_id}, your resources may be outdated"); + } +} + +#[derive(clap::Args)] +pub struct SSSCopilotParams { + uri: String, + /// Loop times + #[arg(long, default_value = "1")] + loop_times: i32, +} + +impl super::ToTaskType for SSSCopilotParams { + fn to_task_type(&self) -> TaskType { + TaskType::SSSCopilot + } +} + +impl TryFrom for MAAValue { + type Error = anyhow::Error; + + fn try_from(params: SSSCopilotParams) -> std::result::Result { + let copilot_dir = dirs::copilot().ensure()?; + + let copilot_file = CopilotFile::from_uri(¶ms.uri)?; + let mut paths = Vec::new(); + copilot_file.push_path_to(&mut paths, copilot_dir)?; + + if paths.len() != 1 { + bail!("SSS Copilot don't support task set"); + } + + let file = paths[0].as_ref(); + let value = json_from_file(file)?; + + if get_str_key(&value, "type")? != "SSS" { + bail!("The given copilot file is not a SSS copilot file"); + } + + let stage_name = get_str_key(&value, "stage_name")?; + + println!("Fight Stage: {stage_name}"); + println!("Core Operators:\n{}", operator_table(&value)?); + + let value = object!( + "filename" => file.to_str().context("Invalid file path")?, + "loop_times" => params.loop_times, + ); + + Ok(value) + } } #[cfg_attr(test, derive(Debug, PartialEq))] -enum CopilotJson<'a> { - Code(&'a str), - File(&'a Path), +enum CopilotFile<'a> { + Remote(i64), + RemoteSet(i64), + Local(&'a Path), } -impl<'a> CopilotJson<'a> { - pub fn new(uri: &str) -> Result { +impl<'a> CopilotFile<'a> { + fn from_uri(uri: &'a str) -> Result { let trimmed = uri.trim(); if let Some(code_str) = trimmed.strip_prefix("maa://") { // just check if it's a number - if code_str.parse::().is_ok() { - return Ok(CopilotJson::Code(code_str)); - } else { - bail!("Invalid code: {}", code_str); + let code_num = code_str.parse::().context("Invalid code")?; + + match code_num { + n if n > 30000 => Ok(CopilotFile::Remote(n)), + n if n > 20000 => Ok(CopilotFile::RemoteSet(n)), + n => bail!("Invalid code {n}"), } + } else if let Some(code) = trimmed.strip_prefix("file://") { + Ok(CopilotFile::Local(Path::new(code))) } else { - Ok(CopilotJson::File(Path::new(trimmed))) + Ok(CopilotFile::Local(Path::new(trimmed))) } } - pub fn get_json_and_file(self, dir: impl AsRef) -> Result<(JsonValue, Cow<'a, Path>)> { + pub fn push_path_to( + self, + paths: &mut Vec>, + base_dir: impl AsRef, + ) -> Result<()> { + let base_dir = base_dir.as_ref(); match self { - CopilotJson::Code(code) => { - let json_file = dir.as_ref().join(code).with_extension("json"); + CopilotFile::Remote(code) => { + let code = code.to_string(); + let json_file = base_dir.join(&code).with_extension("json"); if json_file.is_file() { debug!("Cache hit, using cached json file {}", json_file.display()); - return Ok((json_from_file(&json_file)?, json_file.into())); + paths.push(json_file.into()); + return Ok(()); } - let url = format!("{}{}", MAA_COPILOT_API, code); - debug!("Cache miss, downloading from {}", url); + const COPILOT_API: &str = "https://prts.maa.plus/copilot/get/"; + let url = format!("{}{}", COPILOT_API, code); + debug!("Cache miss, downloading copilot from {url}"); let resp: JsonValue = reqwest::blocking::get(url) .context("Failed to send request")? .json() .context("Failed to parse response")?; if resp["status_code"].as_i64().unwrap() == 200 { - let context = resp["data"]["content"] + let content = resp + .get("data") + .context("No data in response")? + .get("content") + .context("No content in response data")? .as_str() - .context("Failed to get copilot context")?; - let value: JsonValue = - serde_json::from_str(context).context("Failed to parse context")?; + .context("Content is not a string")?; // Save json file fs::File::create(&json_file) .context("Failed to create json file")? - .write_all(context.as_bytes()) + .write_all(content.as_bytes()) .context("Failed to write json file")?; - Ok((value, json_file.into())) + paths.push(json_file.into()); + + Ok(()) } else { bail!("Request Error, code: {}", code); } } - CopilotJson::File(file) => { - if file.is_absolute() { - Ok((json_from_file(file)?, file.into())) + CopilotFile::RemoteSet(code) => { + const COPILOT_SET_API: &str = "https://prts.maa.plus/set/get?id="; + let url = format!("{}{}", COPILOT_SET_API, code); + debug!("Get copilot set from {url}"); + let resp: JsonValue = reqwest::blocking::get(url) + .context("Failed to send request")? + .json() + .context("Failed to parse response")?; + + if resp["status_code"].as_i64().unwrap() == 200 { + let ids = resp + .get("data") + .context("No data in response")? + .get("copilot_ids") + .context("No copilot_ids in response data")? + .as_array() + .context("Copilot_ids is not an array")?; + + for id in ids { + let id = id.as_i64().context("copilot_id is not an integer")?; + CopilotFile::Remote(id).push_path_to(paths, base_dir)?; + } + + Ok(()) } else { - let path = dirs::copilot().join(file); - Ok((json_from_file(&path)?, path.into())) + bail!("Request Error, code: {}", code); } } - } - } -} - -#[derive(Clone, Copy)] -enum CopilotType { - Copilot, - SSSCopilot, -} - -impl CopilotType { - pub fn get_stage_name(self, base_dirs: &Vec, stage_id: &str) -> Result { - match self { - CopilotType::Copilot => { - let stage_files = dirs::global_find(base_dirs, |dir| { - let dir = dir.join("Arknights-Tile-Pos"); - trace!("Searching stage file in {}", dir.display()); - fs::read_dir(dir).ok().and_then(|entries| { - entries - .filter_map(|entry| entry.map(|e| e.path()).ok()) - .find(|file_path| { - file_path - .file_name() - .and_then(|file_name| file_name.to_str()) - .map_or(false, |file_name| { - file_name.starts_with(stage_id) - && file_name.ends_with("json") - }) - }) - }) - }); - - if let Some(stage_file) = stage_files.last() { - let stage_info = json_from_file(stage_file)?; - Ok(format!( - "{} {}", - get_str_key(&stage_info, "code")?, - get_str_key(&stage_info, "name")? - )) + CopilotFile::Local(file) => { + if file.is_absolute() { + paths.push(file.into()); } else { - warn!( - "Failed to find stage file for {}, your resources may be outdated", - stage_id - ); - Ok(stage_id.to_string()) + paths.push(base_dir.join(file).into()); } + Ok(()) } - CopilotType::SSSCopilot => Ok(stage_id.to_string()), - } - } - - pub fn to_task(self, filename: impl AsRef) -> Task { - match self { - CopilotType::Copilot => Task::new_with_default( - TaskType::Copilot, - object!( - "filename" => filename.as_ref(), - "formation" => BoolInput::new(Some(true), Some("auto formation")) - ), - ), - CopilotType::SSSCopilot => Task::new_with_default( - TaskType::SSSCopilot, - object!( - "filename" => filename.as_ref(), - "loop_times" => Input::new(Some(1), Some("loop times")) - ), - ), - } - } -} - -impl AsRef for CopilotType { - fn as_ref(&self) -> &str { - match self { - CopilotType::Copilot => "Copilot", - CopilotType::SSSCopilot => "SSSCopilot", } } } @@ -235,130 +353,333 @@ fn operator_table(value: &JsonValue) -> Result { fn get_str_key(value: &JsonValue, key: impl AsRef) -> Result<&str> { let key = key.as_ref(); - value[key] + value + .get(key) + .with_context(|| format!("{key} not found in {value}"))? .as_str() - .with_context(|| format!("Failed to get {}", key)) + .with_context(|| format!("{key} is not a string in {value}")) } #[cfg(test)] mod tests { use super::*; + use super::super::{AsstConfig, IntoTaskConfig}; + use std::env::temp_dir; + use std::path::PathBuf; + + mod copilot_params { - mod copilot_json { use super::*; #[test] - fn new() { - assert_eq!( - CopilotJson::new("maa://123").unwrap(), - CopilotJson::Code("123") + #[ignore = "need to installed resources and network"] + fn into_task_config() { + if std::env::var_os("SKIP_CORE_TEST").is_some() { + return; // Skip test if resource is not provided + } + + // We don't use dirs::find_resource() here, because it is unreliable in tests + // due to some tests may change return value of it. + let resource_dir = std::env::var_os("MAA_RESOURCE_DIR") + .map(PathBuf::from) + .expect("MAA_RESOURCE_DIR not set"); + let resource_dirs = vec![resource_dir]; + + let mut config = AsstConfig::default(); + config.resource.resource_base_dirs = resource_dirs; + + fn parse(args: I, config: &AsstConfig) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + let command = crate::command::parse_from(args).command; + match command { + crate::Command::Copilot { params, .. } => params.into_task_config(&config), + _ => panic!("Not a Copilot command"), + } + } + + use crate::config::task::InitializedTask; + fn parse_to_taskes(args: I, config: &AsstConfig) -> Vec + where + I: IntoIterator, + T: Into + Clone, + { + parse(args, config).unwrap().init().unwrap().tasks + } + + macro_rules! assert_params { + ($params:expr, $expected:expr $(,)?) => { + let mut params = $params.clone(); + match (params.get_mut("filename"), $expected.get("filename")) { + (Some(filename), Some(expected_filename)) => { + assert!(filename + .as_str() + .unwrap() + .ends_with(expected_filename.as_str().unwrap())); + + *filename = expected_filename.clone(); + } + _ => panic!("filename not found in params"), + } + + assert_eq!(params, $expected); + }; + } + + let tasks = parse_to_taskes(["maa", "copilot", "maa://40051"], &config); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].task_type, TaskType::Copilot); + assert_params!( + tasks[0].params, + object!( + "filename" => "40051.json", + "formation" => false, + "need_navigation" => false, + "use_sanity_potion" => false, + "navigate_name" => "AS-EX-1", + "add_trust" => false, + "select_formation" => 0, + ), ); - assert_eq!( - CopilotJson::new("maa://123 ").unwrap(), - CopilotJson::Code("123") + + // Test no default values + let tasks_no_default = parse_to_taskes( + [ + "maa", + "copilot", + "maa://40051", + "--raid=1", + "--formation", + "--use-sanity-potion", + "--need-navigation", + "--add-trust", + "--select-formation", + "4", + "--support-unit-name", + "维什戴尔", + ], + &config, + ); + assert_eq!(tasks_no_default.len(), 1); + assert_eq!(tasks_no_default[0].task_type, TaskType::Copilot); + assert_params!( + tasks_no_default[0].params, + object!( + "filename" => "40051.json", + "need_navigation" => true, + "formation" => true, + "use_sanity_potion" => true, + "navigate_name" => "AS-EX-1", + "is_raid" => true, + "add_trust" => true, + "select_formation" => 4, + "support_unit_name" => "维什戴尔", + ), ); - assert!(CopilotJson::new("maa:// 123").is_err()); - assert_eq!( - CopilotJson::new("file.json").unwrap(), - CopilotJson::File(Path::new("file.json")) + let tasks_raid_2 = + parse_to_taskes(["maa", "copilot", "maa://40051", "--raid", "2"], &config); + assert_eq!(tasks_raid_2.len(), 2); + assert_eq!(tasks_raid_2[0].task_type, TaskType::Copilot); + assert_eq!(tasks_raid_2[1].task_type, TaskType::Copilot); + assert_params!( + tasks_raid_2[0].params, + object!( + "filename" => "40051.json", + "formation" => true, + "need_navigation" => true, + "use_sanity_potion" => true, + "navigate_name" => "AS-EX-1", + "add_trust" => false, + "select_formation" => 0, + ), + ); + assert_params!( + tasks_raid_2[1].params, + object!( + "filename" => "40051.json", + "formation" => true, + "need_navigation" => true, + "use_sanity_potion" => true, + "navigate_name" => "AS-EX-1", + "is_raid" => true, + "add_trust" => false, + "select_formation" => 0, + ), + ); + + let tasks_multiple = CopilotParams { + uri_list: vec!["maa://40051".into(), "maa://40052".into()], + ..Default::default() + } + .into_task_config(&config) + .unwrap() + .init() + .unwrap() + .tasks; + + assert_eq!(tasks_multiple.len(), 2); + assert_eq!(tasks_multiple[0].task_type, TaskType::Copilot); + assert_eq!(tasks_multiple[1].task_type, TaskType::Copilot); + assert_params!( + tasks_multiple[0].params, + object!( + "filename" => "40051.json", + "formation" => true, + "need_navigation" => true, + "use_sanity_potion" => true, + "navigate_name" => "AS-EX-1", + "add_trust" => false, + "select_formation" => 0, + ), + ); + assert_params!( + tasks_multiple[1].params, + object!( + "filename" => "40052.json", + "formation" => true, + "need_navigation" => true, + "use_sanity_potion" => true, + "navigate_name" => "AS-EX-2", + "add_trust" => false, + "select_formation" => 0, + ), ); } #[test] - fn get_json_and_file() { - let test_root = temp_dir().join("maa-test-get-json-and-file"); - fs::create_dir_all(&test_root).unwrap(); + fn get_stage_info_from_id() { + // We don't use dirs::find_resource() here, because it is unreliable in tests + // due to some tests may change return value of it. + let resource_dir = if let Some(resource) = std::env::var_os("MAA_RESOURCE") { + PathBuf::from(resource) + } else { + return; // Skip test if resource is not provided + }; - let test_file = test_root.join("123.json"); - fs::File::create(&test_file) - .unwrap() - .write_all(b"{\"type\":\"SSS\"}") - .unwrap(); + let arknights_tile_pos = resource_dir.join("Arknights-Tile-Pos"); + arknights_tile_pos.ensure().unwrap(); - // Remote file but cache hit - assert_eq!( - CopilotJson::new("maa://123") - .unwrap() - .get_json_and_file(&test_root) - .unwrap(), - (serde_json::json!({"type": "SSS"}), test_file.clone().into()) - ); + let stage_id = "act35side_ex01"; - // Local file - assert_eq!( - CopilotJson::new(test_file.to_str().unwrap()) - .unwrap() - .get_json_and_file(&test_root) - .unwrap(), - (serde_json::json!({"type": "SSS"}), test_file.clone().into()) - ); + let stage_info = get_stage_info(stage_id, &[resource_dir.clone()]).unwrap(); - fs::remove_dir_all(&test_root).unwrap(); + assert_eq!(stage_info["code"], "AS-EX-1"); + assert_eq!(stage_info["name"], "小偷与收款人"); } } - mod copilot_type { + mod copilot_json { use super::*; #[test] - fn get_stage_name() { - let test_root = temp_dir().join("maa-test-get-stage-name"); - let arknights_tile_pos = test_root.join("Arknights-Tile-Pos"); - arknights_tile_pos.ensure().unwrap(); + fn from_uri() { + assert!(CopilotFile::from_uri("maa://xyz").is_err()); + assert!(CopilotFile::from_uri("maa://123").is_err()); - let stage_id = "act30side_01"; - - let test_file = arknights_tile_pos - .join("act30side_01-activities-act30side-level_act30side_01.json"); - - fs::File::create(test_file) - .unwrap() - .write_all(r#"{ "code": "RS-1", "name": "注意事项" }"#.as_bytes()) - .unwrap(); + assert_eq!( + CopilotFile::from_uri("maa://20001").unwrap(), + CopilotFile::RemoteSet(20001) + ); assert_eq!( - CopilotType::Copilot - .get_stage_name(&vec![test_root.clone()], stage_id) - .unwrap(), - "RS-1 注意事项" + CopilotFile::from_uri("maa://30001").unwrap(), + CopilotFile::Remote(30001) ); assert_eq!( - CopilotType::Copilot - .get_stage_name(&vec![test_root.clone()], "act30side_02") - .unwrap(), - "act30side_02" + CopilotFile::from_uri("file://file.json").unwrap(), + CopilotFile::Local(Path::new("file.json")) ); - fs::remove_dir_all(&test_root).unwrap(); + assert_eq!( + CopilotFile::from_uri("file.json").unwrap(), + CopilotFile::Local(Path::new("file.json")) + ); } #[test] - fn to_task() { + #[ignore = "need to download from internet"] + fn push_path_to() { + let test_root = temp_dir().join("maa-test-push-path-to"); + fs::create_dir_all(&test_root).unwrap(); + + let test_file = test_root.join("123234.json"); + let test_content = serde_json::json!({ + "minimum_required": "v4.0.0", + "stage_name": "act25side_01", + "actions": [ + { "type": "SpeedUp" }, + ], + "groups": [], + "opers": [], + }); + + serde_json::to_writer(fs::File::create(&test_file).unwrap(), &test_content).unwrap(); + + // Remote + assert_eq!( + { + let mut paths = Vec::new(); + CopilotFile::from_uri("maa://40051") + .unwrap() + .push_path_to(&mut paths, &test_root) + .unwrap(); + paths + }, + &[test_root.join("40051.json")], + ); + + // RemoteSet + assert_eq!( + { + let mut paths = Vec::new(); + CopilotFile::from_uri("maa://23125") + .unwrap() + .push_path_to(&mut paths, &test_root) + .unwrap(); + paths + }, + { + let ids = [40051, 40052, 40053, 40055, 40056, 40057, 40058, 40059]; + + ids.iter() + .map(|id| test_root.join(format!("{}.json", id))) + .collect::>() + } + ); + + // Local file (absolute) assert_eq!( - CopilotType::Copilot.to_task("filename"), - Task::new_with_default( - TaskType::Copilot, - object!( - "filename" => "filename", - "formation" => BoolInput::new(Some(true), Some("auto formation")) - ) - ) + { + let mut paths = Vec::new(); + CopilotFile::from_uri(test_file.to_str().unwrap()) + .unwrap() + .push_path_to(&mut paths, &test_root) + .unwrap(); + paths + }, + &[test_file.as_path()] ); + // Local file (relative) assert_eq!( - CopilotType::SSSCopilot.to_task("filename"), - Task::new_with_default( - TaskType::SSSCopilot, - object!( - "filename" => "filename", - "loop_times" => Input::::new(Some(1), Some("loop times")) - ) - ) + { + let mut paths = Vec::new(); + CopilotFile::from_uri("file.json") + .unwrap() + .push_path_to(&mut paths, &test_root) + .unwrap(); + paths + }, + &[test_root.join("file.json")] ); + + fs::remove_dir_all(&test_root).unwrap(); } } diff --git a/maa-cli/src/run/preset/fight.rs b/maa-cli/src/run/preset/fight.rs new file mode 100644 index 00000000..caf74177 --- /dev/null +++ b/maa-cli/src/run/preset/fight.rs @@ -0,0 +1,228 @@ +use super::MAAValue; + +use crate::config::task::ClientType; + +use anyhow::{bail, Context}; + +#[derive(clap::Args)] +pub struct FightParams { + /// Stage to fight, e.g. 1-7, leave empty to fight current/last stage + stage: Option, + #[clap(short, long)] + /// Number of medicine (Sanity Potion) used to fight, default to 0 + medicine: Option, + #[clap(long)] + /// Number of expiring medicine (Sanity Potion) used to fight, default to 0 + expiring_medicine: Option, + #[clap(long)] + /// Number of stone (Originite Prime) used to fight, default to 0 + stone: Option, + #[clap(long)] + /// Exit after fighting given times, default to infinite + times: Option, + #[clap(short = 'D', long, action = clap::ArgAction::Append)] + /// Exit after collecting given number of drops, default to no limit + /// + /// Example: `-D30012=100` to exit after get 100 Orirock Cube, + /// 30012 is the item ID of Orirock Cube, you can find it at `item_index.json`. + /// You can specify multiple drops, by repeating this option, + /// e.g. `-D30012=100 -D30011=100` to exit after get 100 Orirock or 100 Orirock Cube. + drops: Vec, + #[clap(long)] + /// Repeat times of single proxy combat, 1 ~ 6, default to 1 + series: Option, + #[clap(long)] + /// Whether report drops to the Penguin Statistics + report_to_penguin: bool, + #[clap(long)] + /// Penguin Statistics ID to report drops, leave empty to report anonymously + penguin_id: Option, + #[clap(long)] + /// Whether report drops to the yituliu + report_to_yituliu: bool, + #[clap(long)] + /// Whether to report drops to the yituliu + yituliu_id: Option, + #[clap(long)] + /// Client type used to restart the game client if game crashed + client_type: Option, + #[clap(long)] + /// Whether to use Originites like Dr. Grandet + /// + /// In DrGrandet mode, Wait in the using Originites confirmation screen until + /// the 1 point of sanity has been restored and then immediately use the Originite. + dr_grandet: bool, +} + +impl super::ToTaskType for FightParams { + fn to_task_type(&self) -> super::TaskType { + super::TaskType::Fight + } +} + +impl TryFrom for MAAValue { + type Error = anyhow::Error; + + fn try_from(args: FightParams) -> std::result::Result { + let mut params = MAAValue::new(); + + params.insert("stage", args.stage.unwrap_or_default()); + + // Fight conditions + params.maybe_insert("medicine", args.medicine); + params.maybe_insert("expiring_medicine", args.expiring_medicine); + params.maybe_insert("stone", args.stone); + params.maybe_insert("times", args.times); + + let drops = args.drops; + if !drops.is_empty() { + let mut drop_map = std::collections::BTreeMap::new(); + + for drop in drops { + let mut parts = drop.split('='); + let item_id = parts.next(); + let count = parts.next(); + + match (item_id, count) { + (Some(item_id), Some(count)) => { + let count: i32 = count + .parse() + .with_context(|| format!(" Failed to parse drop count: {count}"))?; + + drop_map.insert(item_id.to_owned(), count.into()); + } + _ => { + bail!("Invalid drop format: {}", drop) + } + } + } + + params.insert("drops", MAAValue::Object(drop_map)); + } + + params.maybe_insert("series", args.series); + + if args.report_to_penguin { + params.insert("report_to_penguin", true); + params.maybe_insert("penguin_id", args.penguin_id); + } + + if args.report_to_yituliu { + params.insert("report_to_yituliu", true); + params.maybe_insert("yituliu_id", args.yituliu_id); + } + + if let Some(client_type) = args.client_type { + params.insert("client_type", client_type.to_str()); + params.maybe_insert("server", client_type.server_report()); + } + + params.insert("DrGrandet", args.dr_grandet); + + Ok(params) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::{ + command::{parse_from, Command}, + object, + }; + + #[test] + fn parse_fight_params() { + fn parse(args: I) -> anyhow::Result + where + I: IntoIterator, + T: Into + Clone, + { + let command = parse_from(args).command; + match command { + Command::Fight { params, .. } => params.try_into(), + _ => panic!("Not a Fight command"), + } + } + + let default_params = object!( + "stage" => "", + "DrGrandet" => false, + ); + + assert_eq!(parse(["maa", "fight"]).unwrap(), default_params.clone()); + + assert_eq!( + parse([ + "maa", + "fight", + "1-7", + "-m1", + "-D30012=100", + "--report-to-penguin", + "--penguin-id=123456789", + "--report-to-yituliu", + "--yituliu-id=123456789", + "--client-type=YoStarJP", + ]) + .unwrap(), + default_params.join(object!( + "stage" => "1-7", + "medicine" => 1, + "drops" => object!("30012" => 100), + "report_to_penguin" => true, + "penguin_id" => "123456789", + "report_to_yituliu" => true, + "yituliu_id" => "123456789", + "client_type" => "YoStarJP", + "server" => "JP", + )) + ); + + assert_eq!( + parse([ + "maa", + "fight", + "1-7", + "-m1", + "-D30011=100", + "-D30012=100", + "--client-type=YoStarJP", + ]) + .unwrap(), + default_params.join(object!( + "stage" => "1-7", + "medicine" => 1, + "drops" => object!( + "30011" => 100, + "30012" => 100, + ), + "client_type" => "YoStarJP", + "server" => "JP", + )) + ); + + assert_eq!( + parse([ + "maa", + "fight", + "1-7", + "--series=6", + "--expiring-medicine=100", + "--stone=10", + "--dr-grandet", + ]) + .unwrap(), + object!( + "stage" => "1-7", + "expiring_medicine" => 100, + "stone" => 10, + "series" => 6, + "DrGrandet" => true, + ) + ); + + assert!(parse(["maa", "fight", "1-7", "-D30012=100", "-D30011"]).is_err()); + } +} diff --git a/maa-cli/src/run/preset/mod.rs b/maa-cli/src/run/preset/mod.rs index 49b1ef37..518c12ce 100644 --- a/maa-cli/src/run/preset/mod.rs +++ b/maa-cli/src/run/preset/mod.rs @@ -1,159 +1,181 @@ use crate::{ - config::task::{ClientType, Task, TaskConfig}, - object, + config::{ + asst::AsstConfig, + task::{ClientType, Task, TaskConfig}, + FindFileOrDefault, + }, value::MAAValue, }; -use anyhow::Result; -use maa_sys::TaskType::*; +use anyhow::{Context, Result}; +use maa_sys::TaskType; -pub fn startup(client: Option, account: Option) -> Result { - let mut task_config = TaskConfig::new(); +pub trait IntoTaskConfig { + fn into_task_config(self, config: &AsstConfig) -> Result; +} - let mut params = MAAValue::new(); +trait ToTaskType { + fn to_task_type(&self) -> TaskType; +} - if let Some(client) = client { - params.insert("client_type", client.to_str()); - params.insert("start_game_enabled", true); - }; +impl IntoTaskConfig for T +where + T: ToTaskType + TryInto, + T::Error: Into, +{ + fn into_task_config(self, _: &AsstConfig) -> Result { + let task_type = self.to_task_type(); + let mut params: MAAValue = self.try_into().map_err(Into::into)?; - if let Some(account) = account { - params.insert("account_name", account); - }; + let default = MAAValue::find_file_or_default(task_type.to_str().to_lowercase()) + .context("Failed to load default task config")?; - task_config.push(Task::new_with_default(StartUp, params)); + params.merge_mut(&default); - Ok(task_config) -} + let mut task_config = TaskConfig::new(); + + task_config.push(Task::new(task_type, params)); -pub fn closedown(client: ClientType) -> Result { - let mut task_config = TaskConfig::new(); + Ok(task_config) + } +} - task_config.push(Task::new_with_default( - CloseDown, - object!("client_type" => client.to_str()), - )); +#[derive(clap::Args)] +pub(crate) struct StartUpParams { + client_type: Option, + #[arg(long, alias = "account")] + account_name: Option, +} - Ok(task_config) +impl ToTaskType for StartUpParams { + fn to_task_type(&self) -> TaskType { + TaskType::StartUp + } } -pub fn fight(stage: String, medicine: Option) -> Result { - let mut task_config = TaskConfig::new(); +impl From for MAAValue { + fn from(args: StartUpParams) -> Self { + let mut value = MAAValue::new(); - let mut params = MAAValue::new(); + if let Some(client_type) = args.client_type { + value.insert("start_game_enabled", true); + value.insert("client_type", client_type.to_str()); + } - params.insert("stage", stage); + value.maybe_insert("account_name", args.account_name); - if let Some(medicine) = medicine { - params.insert("medicine", medicine); - }; + value + } +} - task_config.push(Task::new_with_default(Fight, params)); +#[derive(clap::Args)] +pub(crate) struct CloseDownParams { + #[arg(default_value = "Official")] + client: ClientType, +} - Ok(task_config) +impl ToTaskType for CloseDownParams { + fn to_task_type(&self) -> TaskType { + TaskType::CloseDown + } } +impl From for MAAValue { + fn from(args: CloseDownParams) -> Self { + let mut value = MAAValue::new(); + value.insert("client_type", args.client.to_str()); + value + } +} + +mod fight; +pub use fight::FightParams; + mod copilot; -pub use copilot::copilot; +pub use copilot::{CopilotParams, SSSCopilotParams}; mod roguelike; -pub use roguelike::{roguelike, Theme as RoguelikeTheme}; +pub use roguelike::RoguelikeParams; + +mod reclamation; +pub use reclamation::ReclamationParams; #[cfg(test)] mod tests { use super::*; - #[test] - fn test_startup() { - let task_config = startup(None, None).unwrap(); - let tasks = task_config.tasks(); - - assert_eq!(tasks.len(), 1); - let startup_task = tasks.first().unwrap(); - - assert_eq!(startup_task.task_type(), StartUp); - assert_eq!(startup_task.params().get("client_type"), None); - assert_eq!(startup_task.params().get("start_game_enabled"), None); + use crate::{ + command::{parse_from, Command}, + object, + }; - let task_config = startup(Some(ClientType::Official), None).unwrap(); - let tasks = task_config.tasks(); - let startup_task = tasks.first().unwrap(); - assert_eq!( - startup_task - .params() - .get("client_type") - .unwrap() - .as_str() - .unwrap(), - "Official" - ); - assert!(startup_task - .params() - .get("start_game_enabled") - .unwrap() - .as_bool() - .unwrap()); - - let task_config = startup(None, Some("test".to_owned())).unwrap(); - let tasks = task_config.tasks(); - let startup_task = tasks.first().unwrap(); - assert_eq!( - startup_task - .params() - .get("account_name") - .unwrap() - .as_str() - .unwrap(), - "test" - ); + impl MAAValue { + /// Merge another value into this default value. + /// + /// Common use for test with default value. + pub(super) fn join(&self, other: MAAValue) -> MAAValue { + let mut value = self.clone(); + value.merge_mut(&other); + value + } } #[test] - fn test_closedown() { - let task_config = closedown(ClientType::YoStarEN).unwrap(); - let tasks = task_config.tasks(); + fn parse_startup_params() { + fn parse(args: I) -> MAAValue + where + I: IntoIterator, + T: Into + Clone, + { + let command = parse_from(args).command; + match command { + Command::StartUp { params, .. } => params.into(), + _ => panic!("Not a StartUp command"), + } + } + + assert_eq!(parse(["maa", "startup"]), object!()); - assert_eq!(tasks.len(), 1); - let closedown_task = tasks.first().unwrap(); + assert_eq!( + parse(["maa", "startup", "Official"]), + object!( + "client_type" => "Official", + "start_game_enabled" => true + ) + ); - assert_eq!(closedown_task.task_type(), CloseDown); assert_eq!( - closedown_task - .params() - .get("client_type") - .unwrap() - .as_str() - .unwrap(), - "YoStarEN" + parse(["maa", "startup", "YoStarEN", "--account", "account"]), + object!( + "client_type" => "YoStarEN", + "start_game_enabled" => true, + "account_name" => "account" + ) ); } #[test] - fn test_fight() { - let task_config = fight("1-1".to_owned(), None).unwrap(); - let tasks = task_config.tasks(); - - assert_eq!(tasks.len(), 1); - let fight_task = tasks.first().unwrap(); + fn parse_closedown_params() { + fn parse(args: I) -> MAAValue + where + I: IntoIterator, + T: Into + Clone, + { + let cmd = parse_from(args).command; + match cmd { + Command::CloseDown { params, .. } => params.into(), + _ => panic!("Not a CloseDown command"), + } + } - assert_eq!(fight_task.task_type(), Fight); assert_eq!( - fight_task.params().get("stage").unwrap().as_str().unwrap(), - "1-1" + parse(["maa", "closedown"]), + object!("client_type" => "Official") ); - assert_eq!(fight_task.params().get("medicine"), None); - let task_config = fight("1-1".to_owned(), Some(1)).unwrap(); - let tasks = task_config.tasks(); - let fight_task = tasks.first().unwrap(); assert_eq!( - fight_task - .params() - .get("medicine") - .unwrap() - .as_int() - .unwrap(), - 1 + parse(["maa", "closedown", "YoStarEN"]), + object!("client_type" => "YoStarEN") ); } } diff --git a/maa-cli/src/run/preset/reclamation.rs b/maa-cli/src/run/preset/reclamation.rs new file mode 100644 index 00000000..4f0752c2 --- /dev/null +++ b/maa-cli/src/run/preset/reclamation.rs @@ -0,0 +1,143 @@ +use super::MAAValue; + +#[repr(u8)] +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Clone, Copy)] +enum Theme { + Tales, +} + +impl Theme { + const fn to_str(self) -> &'static str { + match self { + Theme::Tales => "Tales", + } + } +} + +impl clap::ValueEnum for Theme { + fn value_variants<'a>() -> &'a [Self] { + &[Theme::Tales] + } + + fn to_possible_value(&self) -> Option { + Some(clap::builder::PossibleValue::new(self.to_str())) + } +} + +#[derive(clap::Args)] +pub struct ReclamationParams { + /// Theme of the reclamation algorithm + theme: Theme, + /// Mode of the reclamation algorithm + /// + /// 0: build and restart again and again. **This mode will discard all the progress**. + /// 1: craft some products and fall back to the previous checkpoint. + #[arg(short = 'm', long, default_value = "1", verbatim_doc_comment)] + mode: i32, + /// Tool to craft in mode 1 + #[arg(short = 'C', long)] + tool_to_craft: Option, + /// Mode to increase the number of tools to craft + /// + /// 0: increase the number by clicking the button. + /// 1: increase the number by holding the button. + #[arg(long, default_value = "0", verbatim_doc_comment)] + increase_mode: i32, + /// Number of craft each batch + #[arg(long, default_value = "16")] + num_craft_batches: i32, +} + +impl super::ToTaskType for ReclamationParams { + fn to_task_type(&self) -> super::TaskType { + super::TaskType::ReclamationAlgorithm + } +} + +impl From for MAAValue { + fn from(params: ReclamationParams) -> Self { + let mut value = MAAValue::new(); + value.insert("theme", params.theme.to_str()); + value.insert("mode", params.mode); + value.maybe_insert("tool_to_craft", params.tool_to_craft); + value.insert("increase_mode", params.increase_mode); + value.insert("num_craft_batches", params.num_craft_batches); + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod theme { + use super::*; + + use clap::ValueEnum; + + #[test] + fn to_str() { + assert_eq!(Theme::Tales.to_str(), "Tales"); + } + + #[test] + fn value_variants() { + assert_eq!(Theme::value_variants(), &[Theme::Tales]); + } + + #[test] + fn to_possible_value() { + assert_eq!( + Theme::Tales.to_possible_value(), + Some(clap::builder::PossibleValue::new("Tales")) + ); + } + } + + #[test] + fn parse_reclamation_params() { + fn parse(args: I) -> MAAValue + where + I: IntoIterator, + T: Into + Clone, + { + let command = crate::command::parse_from(args).command; + match command { + crate::Command::Reclamation { params, .. } => params.into(), + _ => panic!("Not a Reclamation command"), + } + } + + use crate::object; + + let default_params = object!( + "theme" => "Tales", + "mode" => 1, + "increase_mode" => 0, + "num_craft_batches" => 16, + ); + + assert_eq!( + parse(["maa", "reclamation", "Tales"]), + default_params.clone() + ); + assert_eq!( + parse([ + "maa", + "reclamation", + "Tales", + "-m0", + "-C荧光棒", + "--increase-mode=1", + "--num-craft-batches=32" + ]), + default_params.clone().join(object!( + "mode" => 0, + "tool_to_craft" => "荧光棒", + "increase_mode" => 1, + "num_craft_batches" => 32, + )), + ); + } +} diff --git a/maa-cli/src/run/preset/roguelike.rs b/maa-cli/src/run/preset/roguelike.rs index 9c9ae73b..bb92ec17 100644 --- a/maa-cli/src/run/preset/roguelike.rs +++ b/maa-cli/src/run/preset/roguelike.rs @@ -1,13 +1,9 @@ -use crate::{ - config::task::{Task, TaskConfig}, - object, - value::userinput::{BoolInput, Input, SelectD, ValueWithDesc}, -}; +use super::MAAValue; -use anyhow::Result; +use anyhow::bail; use clap::ValueEnum; -use maa_sys::TaskType::Roguelike; +#[repr(i8)] #[cfg_attr(test, derive(PartialEq, Debug))] #[derive(Clone, Copy)] pub enum Theme { @@ -18,7 +14,7 @@ pub enum Theme { } impl Theme { - fn to_str(self) -> &'static str { + const fn to_str(self) -> &'static str { match self { Self::Phantom => "Phantom", Self::Mizuki => "Mizuki", @@ -38,94 +34,416 @@ impl ValueEnum for Theme { } } -pub fn roguelike(theme: Theme) -> Result { - let mut task_config = TaskConfig::new(); - - let params = object!( - "theme" => theme.to_str(), - "mode" => SelectD::::new([ - ValueWithDesc::new(0, Some("Clear as many stages as possible with stable strategy")), - ValueWithDesc::new(1, Some("Invest ingots and exits after first level")), - ValueWithDesc::new(3, Some("Clear as many stages as possible with agrressive strategy")), - ValueWithDesc::new(4, Some("Exit after entering 3rd level")), - ], Some(1), Some("Roguelike mode"), false).unwrap(), - "start_count" => Input::::new(Some(999), Some("number of times to start a new run")), - "investment_disabled" => BoolInput::new(Some(false), Some("disable investment")), - "investments_count" if "investment_disabled" == false => - Input::::new(Some(999), Some("number of times to invest")), - "stop_when_investment_full" if "investment_disabled" == false => - BoolInput::new(Some(false), Some("stop when investment is full")), - "squad" => Input::::new(None, Some("squad name")), - "roles" => Input::::new(None, Some("roles")), - "core_char" => SelectD::::new( - ["百炼嘉维尔", "焰影苇草", "锏", "维什戴尔"], - None, - Some("core operator"), - true, - ).unwrap(), - "use_support" => BoolInput::new(Some(false), Some("use support operator")), - "use_nonfriend_support" if "use_support" == true => - BoolInput::new(Some(false), Some("use non-friend support operator")), - "refresh_trader_with_dice" if "theme" == "Mizuki" => - BoolInput::new(Some(false), Some("refresh trader with dice")), - ); - - task_config.push(Task::new_with_default(Roguelike, params)); - - Ok(task_config) +#[derive(clap::Args)] +pub struct RoguelikeParams { + /// Theme of the roguelike + theme: Theme, + /// Mode of the roguelike + /// + /// 0: mode for score; + /// 1: mode for ingots; + /// 2: combination of 0 and 1, deprecated; + /// 3: mode for pass, not implemented yet; + /// 4: mode that exist after 3rd floor; + /// 5: mode for collapsal paradigms, only for Sami, use with `expected_collapsal_paradigms` + #[arg(long, default_value = "0")] + mode: i32, + + // TODO: input localized names, maybe during initialization of tasks + + // Start related parameters + /// Squad to start with in Chinese, e.g. "指挥分队" (default), "后勤分队" + #[arg(long)] + squad: Option, + /// Starting core operator in Chinese, e.g. "维什戴尔" + #[arg(long)] + core_char: Option, + /// Starting operators recruitment combination in Chinese, e.g. "取长补短", "先手必胜" (default) + #[arg(long)] + roles: Option, + + /// Stop after given count, if not given, never stop + #[arg(long)] + start_count: Option, + + // Investment related parameters + /// Disable investment + #[arg(long)] + disable_investment: bool, + /// Try to gain more score in investment mode + /// + /// By default, some actions will be skipped in investment mode to save time. + /// If this option is enabled, try to gain exp score in investment mode. + #[arg(long)] + investment_with_more_score: bool, + /// Stop exploration investment reaches given count + #[arg(long)] + investments_count: Option, + /// Do not stop exploration when investment is full + #[arg(long)] + no_stop_when_investment_full: bool, + + // Support related parameters + /// Use support operator + #[arg(long)] + use_support: bool, + /// Use non-friend support operator + #[arg(long)] + use_nonfriend_support: bool, + + // Elite related parameters + /// Start with elite two + #[arg(long)] + start_with_elite_two: bool, + /// Only start with elite two + #[arg(long)] + only_start_with_elite_two: bool, + + /// Stop exploration before final boss + #[arg(long)] + stop_at_final_boss: bool, + + // Mizuki specific parameters + /// Whether to refresh trader with dice (only available in Mizuki theme) + #[arg(long)] + refresh_trader_with_dice: bool, + + // Sami specific parameters + // Foldartal related parameters + /// Whether to use Foldartal in Sami theme + #[arg(long)] + use_foldartal: bool, + /// A list of expected Foldartal to be check at first floor + #[arg(short = 'F', long)] + first_floor_foldartal: Vec, + // Collapsal paradigm related parameters + /// Whether to check collapsal paradigms + #[arg(long)] + check_collapsal_paradigms: bool, + /// Whether to double check collapsal paradigms + #[arg(long)] + double_check_collapsal_paradigms: bool, + /// A list of expected collapsal paradigms + #[arg(short = 'P', long)] + expected_collapsal_paradigms: Vec, +} + +impl super::ToTaskType for RoguelikeParams { + fn to_task_type(&self) -> super::TaskType { + super::TaskType::Roguelike + } +} + +impl TryFrom for MAAValue { + type Error = anyhow::Error; + + fn try_from(params: RoguelikeParams) -> Result { + let mut value = MAAValue::new(); + + let theme = params.theme; + let mode = params.mode; + + match mode { + 5 if !matches!(theme, Theme::Sami) => { + bail!("Mode 5 is only available in Sami theme"); + } + 0..=5 => {} + _ => bail!("Mode must be in range between 0 and 5"), + } + + value.insert("theme", params.theme.to_str()); + value.insert("mode", params.mode); + + value.maybe_insert("squad", params.squad); + value.maybe_insert("roles", params.roles); + value.maybe_insert("core_char", params.core_char); + + value.maybe_insert("start_count", params.start_count); + + if params.disable_investment { + value.insert("investment_enabled", false); + } else { + value.insert("investment_enabled", true); + value.maybe_insert("investments_count", params.investments_count); + value.insert( + "investment_with_more_score", + params.investment_with_more_score, + ); + value.insert( + "stop_when_investment_full", + !params.no_stop_when_investment_full, + ); + } + + if params.use_support { + value.insert("use_support", true); + value.insert("use_nonfriend_support", params.use_nonfriend_support); + } + + if params.start_with_elite_two { + value.insert("start_with_elite_two", true); + value.insert( + "only_start_with_elite_two", + params.only_start_with_elite_two, + ); + } + + value.insert("stop_at_final_boss", params.stop_at_final_boss); + + // Theme specific parameters + match theme { + Theme::Mizuki => { + value.insert("refresh_trader_with_dice", params.refresh_trader_with_dice); + } + Theme::Sami => { + value.insert("use_foldartal", params.use_foldartal); + if !params.first_floor_foldartal.is_empty() { + value.insert( + "first_floor_foldartal", + MAAValue::Array( + params + .first_floor_foldartal + .into_iter() + .map(MAAValue::from) + .collect(), + ), + ); + } + + value.insert( + "check_collapsal_paradigms", + params.check_collapsal_paradigms, + ); + value.insert( + "double_check_collapsal_paradigms", + params.double_check_collapsal_paradigms, + ); + if !params.expected_collapsal_paradigms.is_empty() { + value.insert( + "expected_collapsal_paradigms", + MAAValue::Array( + params + .expected_collapsal_paradigms + .into_iter() + .map(MAAValue::from) + .collect(), + ), + ); + } + } + _ => {} + } + + Ok(value) + } } #[cfg(test)] mod tests { use super::*; - use crate::value::MAAValue; + use crate::{ + command::{parse_from, Command}, + object, + value::MAAValue, + }; - #[test] - fn theme_to_str() { - assert_eq!(Theme::Phantom.to_str(), "Phantom"); - assert_eq!(Theme::Mizuki.to_str(), "Mizuki"); - assert_eq!(Theme::Sami.to_str(), "Sami"); - assert_eq!(Theme::Sarkaz.to_str(), "Sarkaz"); + mod theme { + use super::*; + + #[test] + fn to_str() { + assert_eq!(Theme::Phantom.to_str(), "Phantom"); + assert_eq!(Theme::Mizuki.to_str(), "Mizuki"); + assert_eq!(Theme::Sami.to_str(), "Sami"); + assert_eq!(Theme::Sarkaz.to_str(), "Sarkaz"); + } + + #[test] + fn value_variants() { + assert_eq!( + Theme::value_variants(), + &[Theme::Phantom, Theme::Mizuki, Theme::Sami, Theme::Sarkaz] + ); + } + + #[test] + fn to_possible_value() { + assert_eq!( + Theme::Phantom.to_possible_value(), + Some(clap::builder::PossibleValue::new("Phantom")) + ); + assert_eq!( + Theme::Mizuki.to_possible_value(), + Some(clap::builder::PossibleValue::new("Mizuki")) + ); + assert_eq!( + Theme::Sami.to_possible_value(), + Some(clap::builder::PossibleValue::new("Sami")) + ); + assert_eq!( + Theme::Sarkaz.to_possible_value(), + Some(clap::builder::PossibleValue::new("Sarkaz")) + ); + } } #[test] - fn theme_value_variants() { + fn parse_roguellike_params() { + fn parse(args: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + let command = parse_from(args).command; + match command { + Command::Roguelike { params, .. } => params.try_into(), + _ => panic!("Not a Roguelike command"), + } + } + + let default_params = object!( + "mode" => 0, + "investment_enabled" => true, + "investment_with_more_score" => false, + "stop_when_investment_full" => true, + "stop_at_final_boss" => false, + ); + assert_eq!( - Theme::value_variants(), - &[Theme::Phantom, Theme::Mizuki, Theme::Sami, Theme::Sarkaz] + parse(["maa", "roguelike", "Phantom"]).unwrap(), + default_params.join(object!("theme" => "Phantom")), ); - } + assert!(parse(["maa", "roguelike", "Phantom", "--mode", "5"]).is_err()); + assert!(parse(["maa", "roguelike", "Phantom", "--mode", "7"]).is_err()); - #[test] - fn theme_to_possible_value() { assert_eq!( - Theme::Phantom.to_possible_value(), - Some(clap::builder::PossibleValue::new("Phantom")) + parse([ + "maa", + "roguelike", + "Sarkaz", + "--squad", + "蓝图测绘分队", + "--roles", + "取长补短", + "--core-char", + "维什戴尔", + "--start-count=100", + ]) + .unwrap(), + default_params.join(object!( + "theme" => "Sarkaz", + "squad" => "蓝图测绘分队", + "roles" => "取长补短", + "core_char" => "维什戴尔", + "start_count" => 100, + )), ); + assert_eq!( - Theme::Mizuki.to_possible_value(), - Some(clap::builder::PossibleValue::new("Mizuki")) + parse(["maa", "roguelike", "Sarkaz", "--disable-investment"]).unwrap(), + // Can't use default_params here because some fields are removed in this case + object!( + "theme" => "Sarkaz", + "mode" => 0, + "investment_enabled" => false, + "stop_at_final_boss" => false, + ), ); assert_eq!( - Theme::Sami.to_possible_value(), - Some(clap::builder::PossibleValue::new("Sami")) + parse([ + "maa", + "roguelike", + "Sarkaz", + "--investment-with-more-score", + "--investments-count=100", + "--no-stop-when-investment-full" + ]) + .unwrap(), + default_params.join(object!( + "theme" => "Sarkaz", + "investment_with_more_score" => true, + "investments_count" => 100, + "stop_when_investment_full" => false, + )), ); + assert_eq!( - Theme::Sarkaz.to_possible_value(), - Some(clap::builder::PossibleValue::new("Sarkaz")) + parse([ + "maa", + "roguelike", + "Sarkaz", + "--use-support", + "--use-nonfriend-support", + "--start-with-elite-two", + "--only-start-with-elite-two", + "--stop-at-final-boss", + ]) + .unwrap(), + default_params.join(object!( + "theme" => "Sarkaz", + "use_support" => true, + "use_nonfriend_support" => true, + "start_with_elite_two" => true, + "only_start_with_elite_two" => true, + "stop_at_final_boss" => true, + )), ); - } - #[test] - fn roguelike_task_config() { - let task_config = roguelike(Theme::Phantom).unwrap(); - let tasks = task_config.tasks(); - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0].task_type(), Roguelike); assert_eq!( - tasks[0].params().get("theme").unwrap(), - &MAAValue::from("Phantom") + parse(["maa", "roguelike", "Mizuki"]).unwrap(), + default_params.join(object!( + "theme" => "Mizuki", + "refresh_trader_with_dice" => false, + )), + ); + + assert_eq!( + parse(["maa", "roguelike", "Mizuki", "--refresh-trader-with-dice"]).unwrap(), + default_params.join(object!( + "theme" => "Mizuki", + "refresh_trader_with_dice" => true, + )), + ); + + let sami_params = default_params.join(object!( + "theme" => "Sami", + "use_foldartal" => false, + "check_collapsal_paradigms" => false, + "double_check_collapsal_paradigms" => false, + )); + + assert_eq!( + parse(["maa", "roguelike", "Sami", "--mode", "5"]).unwrap(), + sami_params.join(object!("mode" => 5)), + ); + assert_eq!( + parse([ + "maa", + "roguelike", + "Sami", + "--use-foldartal", + "-F英雄", + "-F大地", + "--check-collapsal-paradigms", + "--double-check-collapsal-paradigms", + "-P目空一些", + "-P图像损坏", + ]) + .unwrap(), + sami_params.join(object!( + "use_foldartal" => true, + "first_floor_foldartal" => MAAValue::Array(vec![ + MAAValue::from("英雄"), + MAAValue::from("大地"), + ]), + "check_collapsal_paradigms" => true, + "double_check_collapsal_paradigms" => true, + "expected_collapsal_paradigms" => MAAValue::Array(vec![ + MAAValue::from("目空一些"), + MAAValue::from("图像损坏"), + ]), + )), ); } } diff --git a/maa-cli/src/value/mod.rs b/maa-cli/src/value/mod.rs index 6ce89ca2..edf78f9c 100644 --- a/maa-cli/src/value/mod.rs +++ b/maa-cli/src/value/mod.rs @@ -11,6 +11,7 @@ use std::io; use serde::{Deserialize, Serialize}; +/// TODO: Zero-copy deserialization and reduce clone in init #[cfg_attr(test, derive(PartialEq, Debug))] #[derive(Deserialize, Clone)] #[serde(untagged)] @@ -266,6 +267,12 @@ impl MAAValue { } } + pub fn maybe_insert(&mut self, key: impl Into, value: Option>) { + if let Some(value) = value { + self.insert(key, value); + } + } + /// Get the value if the value is primate /// /// A primate value can be a bool, int, float or string. @@ -743,6 +750,16 @@ mod tests { value.insert("int", 1); } + #[test] + fn maybe_insert() { + let mut value = MAAValue::new(); + assert_eq!(value.get("int"), None); + value.maybe_insert("int", Some(1)); + assert_eq!(value.get("int").unwrap().as_int().unwrap(), 1); + value.maybe_insert("float", None::); + assert_eq!(value.get("float"), None); + } + #[test] fn try_from() { // Bool