From 3020f6856a6ff0acc8e8b8566c0ae389f2b5c557 Mon Sep 17 00:00:00 2001 From: Loong Date: Wed, 3 Jan 2024 15:22:31 +0800 Subject: [PATCH] feat: better input (#163) - Optional input, where certain parameters are used only when other parameters satisfy specific conditions. - Description for alternatives of select. - Default value of select. - Allow user input a custom value instead of index for select. - Slightly update for fight and roguelike subcommand. --- README-EN.md | 20 +- README.md | 26 +- maa-cli/config_examples/tasks/daily.json | 12 +- maa-cli/config_examples/tasks/daily.toml | 7 +- maa-cli/config_examples/tasks/daily.yml | 8 +- maa-cli/schemas/task.schema.json | 37 +- maa-cli/src/config/task/mod.rs | 156 +++-- maa-cli/src/config/task/task_type.rs | 129 ++-- maa-cli/src/config/task/value/input.rs | 653 ------------------ maa-cli/src/config/task/value/mod.rs | 794 ---------------------- maa-cli/src/main.rs | 61 +- maa-cli/src/run/callback/summary.rs | 9 +- maa-cli/src/run/copilot.rs | 43 +- maa-cli/src/run/fight.rs | 102 +-- maa-cli/src/run/roguelike.rs | 140 ++-- maa-cli/src/value/input.rs | 229 +++++++ maa-cli/src/value/mod.rs | 671 ++++++++++++++++++ maa-cli/src/value/primate.rs | 164 +++++ maa-cli/src/value/userinput/bool_input.rs | 226 ++++++ maa-cli/src/value/userinput/input.rs | 230 +++++++ maa-cli/src/value/userinput/mod.rs | 216 ++++++ maa-cli/src/value/userinput/select.rs | 572 ++++++++++++++++ 22 files changed, 2752 insertions(+), 1753 deletions(-) delete mode 100644 maa-cli/src/config/task/value/input.rs delete mode 100644 maa-cli/src/config/task/value/mod.rs create mode 100644 maa-cli/src/value/input.rs create mode 100644 maa-cli/src/value/mod.rs create mode 100644 maa-cli/src/value/primate.rs create mode 100644 maa-cli/src/value/userinput/bool_input.rs create mode 100644 maa-cli/src/value/userinput/input.rs create mode 100644 maa-cli/src/value/userinput/mod.rs create mode 100644 maa-cli/src/value/userinput/select.rs diff --git a/README-EN.md b/README-EN.md index 669a71c4..1d2db283 100644 --- a/README-EN.md +++ b/README-EN.md @@ -314,8 +314,15 @@ condition = { type = "DateTime", start = "2023-08-01T16:00:00", end = "2023-08-2 # Set the stage to a `Select` type with alternatives and description [tasks.variants.params.stage] -alternatives = ["SL-6", "SL-7", "SL-8"] # the alternatives of stage, at least one alternative should be given +# the alternatives of stage, at least one alternative should be given +# the element of alternatives can be a single value or a table with `value` and `desc` fields· +alternatives = [ + "SL-7", # will be displayed as "1. SL-7" + { value = "SL-8", desc = "Manganese Ore" } # will be displayed as "2. SL-8 (Manganese Ore)" +] +default_index = 1 # the index of default value, start from 1, if not given, empty value will be re-prompt description = "a stage to fight in summer event" # description of the input, optional +allow_custom = true # whether allow input custom value, default to false, if allow, non-integer value will be treated as custom value # Task without input [[tasks.variants]] @@ -329,9 +336,18 @@ params = { stage = "CE-6" } [tasks.variants.params.stage] default = "1-7" # default value of stage, optional (if not given, user can input empty value to re-prompt) description = "a stage to fight" # description of the input, optional +[tasks.variants.params.medicine] +# dependency of parameters, the key is the name of parameter, the value is the expected value of dependency parameter +# when set, the parameter will be required to input only if all dependency parameters are satisfied +deps = { stage = "1-7" } +default = 1000 +description = "medicine to use" ``` -For `Input` type, a prompt will be shown to ask user to input a value. If the default value is given, it will be used if user input empty value, otherwise it will re-prompt. For `Select` type, a prompt will be shown to ask user to select a value from alternatives (by index). If user input is not a valid index, it will re-prompt. To promote and input can be disabled by `--batch` option, which is useful for running tasks in Schedule. +For `Input` type, a prompt will be shown to ask user to input a value. If the default value is given, it will be used if user input empty value, otherwise it will re-prompt. +For `Select` type, a prompt will be shown to ask user to input a index or custom value (if `allow_custom` is `true`). If the default index is given, it will be used if user input empty value, otherwise it will re-prompt. + +`--batch` option can be used to run tasks in batch mode, which will use default value for all inputs and panic if no default value is given. ### `MaaCore` related configurations diff --git a/README.md b/README.md index 18f9e035..2783cf41 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ OpenSSL 库是 `git2` 在所有平台和 `reqwest` 在 Linux 上的依赖。如 #### 预定义任务 -- `maa fight [stage]`: 运行战斗任务,`[stage]` 是关卡名称,例如 `1-7`;如果 `stage` 为空,那么 `maa` 将询问你要刷的关卡,然后运行战斗任务; +- `maa fight [stage]`: 运行战斗任务,`[stage]` 是关卡名称,例如 `1-7`;留空选择上次或者当前关卡; - `maa copilot `: 运行自动战斗任务,其中 `` 是作业的 URI, 其可以是 `maa://1234` 或者本地文件路径 `./1234.json`; -- `maa roguelike [theme]`: 运行 roguelike 模式的战斗任务,`[theme]` 是 roguelike 模式的主题,可选值为 `phantom`, `mizuki` 以及 `sami`,如果留空,那么你将需要在 `maa` 询问后手动输入主题, +- `maa roguelike [theme]`: 运行 roguelike 模式的战斗任务,`[theme]` 是 roguelike 模式的主题,可选值为 `Phantom`, `Mizuki` 以及 `Sami`; #### 自定义任务 @@ -326,8 +326,15 @@ type = "Fight" [[tasks.variants]] condition = { type = "DateTime", start = "2023-08-01T16:00:00", end = "2023-08-21T03:59:59" } [tasks.variants.params.stage] -alternatives = ["SL-6", "SL-7", "SL-8"] # 可选的关卡,必须提供至少一个可选值 +# 可选的关卡,必须提供至少一个可选值 +# 可选值可以是一个值,也可以是同时包含值和描述的一个表 +alternatives = [ + "SL-7", # 将被显示为 "1. SL-7" + { value = "SL-8", desc = "轻锰矿" } # 将被显示为 "2. SL-8 (轻锰矿)" +] +default_index = 1 # 默认值的索引,从 1 开始,如果没有设置,输入空值将会重新提示输入 description = "a stage to fight in summer event" # 描述,可选 +allow_custom = true # 是否允许输入自定义的值,默认为 false,如果允许,那么非整数的值将会被视为自定义的值 # 无需任何输入 [[tasks.variants]] @@ -339,11 +346,18 @@ params = { stage = "CE-6" } [tasks.variants.params.stage] default = "1-7" # 默认的关卡,可选(如果没有默认值,输入空值将会重新提示输入) description = "a stage to fight" # 描述,可选 +[tasks.variants.params.medicine] +# 依赖的参数,键为参数名,值为依赖的参数的预期值 +# 当设置时,只有所有的依赖参数都满足预期值时,这个参数才会被要求输入 +deps = { stage = "1-7" } +default = 1000 +description = "medicine to use" ``` -对于 `Input` 类型,当运行任务时,你将会被提示输入一个值。如果你输入了一个空值,那么默认值将会被使用。 -对于 `Select` 类型,当运行任务时,你将会被提示选择一个值 (通过输入可选值的序号)。 -注意,当你的输入不是可选值时,你将会被提示重新输入。 +对于 `Input` 类型,当运行任务时,你将会被提示输入一个值。如果你输入了一个空值,如果有默认值,那么默认值将会被使用,否则你将会被提示重新输入。 +对于 `Select` 类型,当运行任务时,你将会被提示输入一个的索引或者自定义的值(如果允许)。如果你输入了一个空值,如果有默认值,那么默认值将会被使用,否则你将会被提示重新输入。 + +`--batch` 选项可以用于在运行任务时跳过所有的输入,这将会使用默认值;如果有任何输入没有默认值,那么将会导致错误。 ### MaaCore 相关配置 diff --git a/maa-cli/config_examples/tasks/daily.json b/maa-cli/config_examples/tasks/daily.json index 1e1d07ed..831050cc 100644 --- a/maa-cli/config_examples/tasks/daily.json +++ b/maa-cli/config_examples/tasks/daily.json @@ -4,7 +4,13 @@ { "type": "StartUp", "params": { - "client_type": "Official", + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type", + "deps": { + "start_game_enabled": true + } + }, "start_game_enabled": { "default": true, "description": "start the game" @@ -51,7 +57,9 @@ "params": { "stage": { "alternatives": ["SL-6", "SL-7", "SL-8"], - "description": "a stage to fight in summer event" + "default_index": 2, + "description": "a stage to fight in summer event", + "allow_custom": true } } } diff --git a/maa-cli/config_examples/tasks/daily.toml b/maa-cli/config_examples/tasks/daily.toml index 7f5ca1b8..9e2abab5 100644 --- a/maa-cli/config_examples/tasks/daily.toml +++ b/maa-cli/config_examples/tasks/daily.toml @@ -5,8 +5,11 @@ type = "StartUp" [tasks.params] -client_type = "Official" start_game_enabled = { default = true, description = "start the game" } +[tasks.params.client_type] +alternatives = ["Official", "YoStarEN", "YoStarJP"] +description = "a client type" +deps = { start_game_enabled = true } [[tasks]] name = "Fight Daily" @@ -34,7 +37,9 @@ params = { stage = "CE-6" } condition = { type = "DateTime", start = "2023-08-01T16:00:00", end = "2023-08-21T03:59:59" } [tasks.variants.params.stage] alternatives = ["SL-6", "SL-7", "SL-8"] +default_index = 2 description = "a stage to fight in summer event" +allow_custom = true # Mall after 16:00 [[tasks]] diff --git a/maa-cli/config_examples/tasks/daily.yml b/maa-cli/config_examples/tasks/daily.yml index 93b634f8..c8d61d4a 100644 --- a/maa-cli/config_examples/tasks/daily.yml +++ b/maa-cli/config_examples/tasks/daily.yml @@ -1,10 +1,14 @@ tasks: - type: StartUp params: - client_type: Official start_game_enabled: default: true description: start the game + client_type: + alternatives: [Official, YoStarEN, YoStarJP] + description: a client type + deps: + start_game_enabled: true - type: Fight name: Fight Daily strategy: merge @@ -31,7 +35,9 @@ tasks: params: stage: alternatives: [SL-6, SL-7, SL-8] + default_index: 2 description: a stage to fight in summer event + allow_custom: true - type: Mall params: shopping: true diff --git a/maa-cli/schemas/task.schema.json b/maa-cli/schemas/task.schema.json index b6781f83..1050f342 100644 --- a/maa-cli/schemas/task.schema.json +++ b/maa-cli/schemas/task.schema.json @@ -203,8 +203,7 @@ { "$ref": "#/definitions/maaBool" }, { "$ref": "#/definitions/maaNumber" }, { "$ref": "#/definitions/maaString" }, - { "$ref": "#/definitions/maaObject" }, - { "type": "null" } + { "$ref": "#/definitions/maaObject" } ] }, "maaObject": { @@ -227,8 +226,10 @@ "type": "object", "properties": { "default": { "type": "boolean" }, + "deps": { "type": "object" }, "description": { "type": "string" } - } + }, + "additionalProperties": false } ] }, @@ -239,8 +240,10 @@ "type": "object", "properties": { "default": { "type": "number" }, + "deps": { "type": "object" }, "description": { "type": "string" } - } + }, + "additionalProperties": false }, { "type": "object", @@ -255,14 +258,19 @@ "required": ["value"], "properties": { "value": { "type": "number" }, - "display": { "type": "string" } - } + "desc": { "type": "string" } + }, + "additionalProperties": false } ] } }, + "deps": { "type": "object" }, + "default_index": { "type": "number" }, + "allow_custom": { "type": "boolean" }, "description": { "type": "string" } - } + }, + "additionalProperties": false } ] }, @@ -273,8 +281,10 @@ "type": "object", "properties": { "default": { "type": "string" }, + "deps": { "type": "object" }, "description": { "type": "string" } - } + }, + "additionalProperties": false }, { "type": "object", @@ -290,14 +300,19 @@ "required": ["value"], "properties": { "value": { "type": "string" }, - "display": { "type": "string" } - } + "desc": { "type": "string" } + }, + "additionalProperties": false } ] } }, + "deps": { "type": "object" }, + "default_index": { "type": "number" }, + "allow_custom": { "type": "boolean" }, "description": { "type": "string" } - } + }, + "additionalProperties": false } ] }, diff --git a/maa-cli/src/config/task/mod.rs b/maa-cli/src/config/task/mod.rs index 207e697c..375f4659 100644 --- a/maa-cli/src/config/task/mod.rs +++ b/maa-cli/src/config/task/mod.rs @@ -1,6 +1,3 @@ -pub mod value; -pub use value::MAAValue; - pub mod task_type; use task_type::{MAATask, TaskOrUnknown}; @@ -10,7 +7,7 @@ pub use client_type::ClientType; mod condition; use condition::Condition; -use crate::{dirs, object}; +use crate::{dirs, object, value::MAAValue}; use std::path::PathBuf; @@ -185,16 +182,16 @@ impl TaskConfig { let mut tasks: Vec = Vec::new(); + use TaskOrUnknown::Task; for task in self.tasks.iter() { if task.is_active() { let task_type = task.task_type(); - let mut params = task.params(); - params.init()?; + let mut params = task.params().init()?; match task_type { - TaskOrUnknown::MAATask(MAATask::StartUp) => { - let start_game = params.get_or("enable", true)? - && params.get_or("start_game_enabled", false)?; + Task(MAATask::StartUp) => { + let start_game = params.get_or("enable", true) + && params.get_or("start_game_enabled", false); match (start_game, startup) { (true, None) => { @@ -210,7 +207,11 @@ impl TaskConfig { match (params.get("client_type"), client_type) { // If client_type in task is set, set client type in config automatically (Some(t), None) => { - client_type = Some(t.as_string()?.parse()?); + client_type = Some( + t.as_str() + .context("client_type must be a string")? + .parse()?, + ); } // If client type in config is set, set client_type in task automatically (None, Some(t)) => { @@ -221,8 +222,8 @@ impl TaskConfig { prepend_startup = false; } - TaskOrUnknown::MAATask(MAATask::CloseDown) => { - match (params.get_or("enable", true)?, closedown) { + Task(MAATask::CloseDown) => { + match (params.get_or("enable", true), closedown) { // If closedown task is enabled, enable closedown automatically (true, None) => { closedown = Some(true); @@ -242,7 +243,8 @@ impl TaskConfig { // it will be treated as a relative path to the config directory // and will be converted to an absolute path. if let Some(v) = params.get("filename") { - let file = PathBuf::from(v.as_string()?); + let file: PathBuf = + v.as_str().context("filename must be a string")?.into(); let sub_dir = task_type.as_ref().to_lowercase(); if let Some(path) = dirs::abs_config(file, Some(sub_dir)) { params.insert("filename", path.to_str().context("Invilid UTF-8")?) @@ -302,7 +304,11 @@ pub struct InitializedTask { } impl InitializedTask { - fn new(name: Option, task_type: impl Into, params: MAAValue) -> Self { + pub fn new( + name: Option, + task_type: impl Into, + params: MAAValue, + ) -> Self { Self { name, task_type: task_type.into(), @@ -310,7 +316,7 @@ impl InitializedTask { } } - fn new_noname(task_type: impl Into, params: MAAValue) -> Self { + pub fn new_noname(task_type: impl Into, params: MAAValue) -> Self { Self::new(None, task_type.into(), params) } @@ -335,6 +341,12 @@ mod tests { use task_type::MAATask; + impl TaskConfig { + pub fn tasks(&self) -> &[Task] { + &self.tasks + } + } + mod task { use super::*; @@ -381,7 +393,7 @@ mod tests { fn get_type() { assert_eq!( Task::new_with_default(MAATask::StartUp, object!()).task_type(), - &MAATask::StartUp.into() + &MAATask::StartUp, ); } @@ -492,16 +504,10 @@ mod tests { mod task_config { use super::*; - impl TaskConfig { - pub fn tasks(&self) -> &[Task] { - &self.tasks - } - } - mod serde { use super::*; - use value::input::{BoolInput, Input, Select}; + use crate::value::userinput::{BoolInput, Input, SelectD}; use chrono::{NaiveDateTime, NaiveTime, TimeZone, Weekday}; @@ -520,12 +526,28 @@ mod tests { } fn example_task_config() -> TaskConfig { + use crate::value::Map; + use ClientType::*; + use MAAValue::OptionalInput; + let mut task_list = TaskConfig::new(); task_list.push(Task::new_with_default( MAATask::StartUp, object!( - "client_type" => "Official", + "client_type" => OptionalInput { + deps: Map::from([("start_game_enabled".to_string(), true.into())]), + input: SelectD::::new( + vec![ + Official, + YoStarEN, + YoStarJP, + ], + None, + Some("a client type"), + false + ).unwrap().into(), + }, "start_game_enabled" => BoolInput::new( Some(true), Some("start the game"), @@ -534,50 +556,52 @@ mod tests { )); task_list.push(Task::new( - Some("Fight Daily".to_string()), - MAATask::Fight, - object!(), - Strategy::Merge, - vec![ - TaskVariant { - condition: Condition::Weekday { - weekdays: vec![Weekday::Sun], + Some("Fight Daily".to_string()), + MAATask::Fight, + object!(), + Strategy::Merge, + vec![ + TaskVariant { + condition: Condition::Weekday { + weekdays: vec![Weekday::Sun], + }, + params: object!("expiring_medicine" => 5), }, - params: object!("expiring_medicine" => 5), - }, - TaskVariant { - condition: Condition::Always, - params: object!( - "stage" => Input { - default: Some("1-7".to_string()), - description: Some("a stage to fight".to_string()) - } - ), - }, - TaskVariant { - condition: Condition::Weekday { - weekdays: vec![Weekday::Tue, Weekday::Thu, Weekday::Sat], + TaskVariant { + condition: Condition::Always, + params: object!( + "stage" => Input::new( + Some("1-7".to_string()), + Some("a stage to fight"), + ), + ), }, - 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)), + TaskVariant { + condition: Condition::Weekday { + weekdays: vec![Weekday::Tue, Weekday::Thu, Weekday::Sat], + }, + params: object!("stage" => "CE-6"), }, - params: object!( - "stage" => Select { - alternatives: vec![ - "SL-6".to_string(), - "SL-7".to_string(), - "SL-8".to_string(), - ], - description: Some("a stage to fight in summer event".to_string()), - } - ), - }, - ], - )); + 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)), + }, + 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, @@ -781,7 +805,7 @@ mod tests { object!("stage" => "1-7"), ); assert_eq!(task.name(), Some("Fight Daily")); - assert_eq!(task.task_type(), &MAATask::Fight.into()); + assert_eq!(task.task_type(), &MAATask::Fight); assert_eq!(task.params(), &object!("stage" => "1-7")); } } diff --git a/maa-cli/src/config/task/task_type.rs b/maa-cli/src/config/task/task_type.rs index ad5d3b82..ab8f0a0a 100644 --- a/maa-cli/src/config/task/task_type.rs +++ b/maa-cli/src/config/task/task_type.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum MAATask { StartUp, CloseDown, @@ -19,48 +19,50 @@ pub enum MAATask { SingleStep, VideoRecognition, } +use MAATask::*; impl MAATask { fn to_str(self) -> &'static str { match self { - MAATask::StartUp => "StartUp", - MAATask::CloseDown => "CloseDown", - MAATask::Fight => "Fight", - MAATask::Recruit => "Recruit", - MAATask::Infrast => "Infrast", - MAATask::Mall => "Mall", - MAATask::Award => "Award", - MAATask::Roguelike => "Roguelike", - MAATask::Copilot => "Copilot", - MAATask::SSSCopilot => "SSSCopilot", - MAATask::Depot => "Depot", - MAATask::OperBox => "OperBox", - MAATask::ReclamationAlgorithm => "ReclamationAlgorithm", - MAATask::Custom => "Custom", - MAATask::SingleStep => "SingleStep", - MAATask::VideoRecognition => "VideoRecognition", + StartUp => "StartUp", + CloseDown => "CloseDown", + Fight => "Fight", + Recruit => "Recruit", + Infrast => "Infrast", + Mall => "Mall", + Award => "Award", + Roguelike => "Roguelike", + Copilot => "Copilot", + SSSCopilot => "SSSCopilot", + Depot => "Depot", + OperBox => "OperBox", + ReclamationAlgorithm => "ReclamationAlgorithm", + Custom => "Custom", + SingleStep => "SingleStep", + VideoRecognition => "VideoRecognition", } } } -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] pub enum TaskOrUnknown { - MAATask(MAATask), + Task(MAATask), Unknown(String), } +use TaskOrUnknown::*; impl From for TaskOrUnknown { fn from(task: MAATask) -> Self { - TaskOrUnknown::MAATask(task) + Task(task) } } impl AsRef for TaskOrUnknown { fn as_ref(&self) -> &str { match self { - TaskOrUnknown::MAATask(task) => task.to_str(), - TaskOrUnknown::Unknown(s) => s.as_str(), + Task(task) => task.to_str(), + Unknown(s) => s.as_str(), } } } @@ -83,26 +85,35 @@ mod tests { use serde_test::{assert_de_tokens, Token}; + impl PartialEq for TaskOrUnknown { + fn eq(&self, other: &MAATask) -> bool { + match self { + Task(task) => task == other, + Unknown(_) => false, + } + } + } + #[test] fn deserialize() { let types: [TaskOrUnknown; 17] = [ - MAATask::StartUp.into(), - MAATask::CloseDown.into(), - MAATask::Fight.into(), - MAATask::Recruit.into(), - MAATask::Infrast.into(), - MAATask::Mall.into(), - MAATask::Award.into(), - MAATask::Roguelike.into(), - MAATask::Copilot.into(), - MAATask::SSSCopilot.into(), - MAATask::Depot.into(), - MAATask::OperBox.into(), - MAATask::ReclamationAlgorithm.into(), - MAATask::Custom.into(), - MAATask::SingleStep.into(), - MAATask::VideoRecognition.into(), - TaskOrUnknown::Unknown("Other".to_string()), + StartUp.into(), + CloseDown.into(), + Fight.into(), + Recruit.into(), + Infrast.into(), + Mall.into(), + Award.into(), + Roguelike.into(), + Copilot.into(), + SSSCopilot.into(), + Depot.into(), + OperBox.into(), + ReclamationAlgorithm.into(), + Custom.into(), + SingleStep.into(), + VideoRecognition.into(), + Unknown("Other".to_string()), ]; assert_de_tokens( @@ -133,27 +144,27 @@ mod tests { #[test] fn to_str() { - assert_eq!(MAATask::StartUp.to_str(), "StartUp"); - assert_eq!(MAATask::CloseDown.to_str(), "CloseDown"); - assert_eq!(MAATask::Fight.to_str(), "Fight"); - assert_eq!(MAATask::Recruit.to_str(), "Recruit"); - assert_eq!(MAATask::Infrast.to_str(), "Infrast"); - assert_eq!(MAATask::Mall.to_str(), "Mall"); - assert_eq!(MAATask::Award.to_str(), "Award"); - assert_eq!(MAATask::Roguelike.to_str(), "Roguelike"); - assert_eq!(MAATask::Copilot.to_str(), "Copilot"); - assert_eq!(MAATask::SSSCopilot.to_str(), "SSSCopilot"); - assert_eq!(MAATask::Depot.to_str(), "Depot"); - assert_eq!(MAATask::OperBox.to_str(), "OperBox"); + assert_eq!(StartUp.to_str(), "StartUp"); + assert_eq!(CloseDown.to_str(), "CloseDown"); + assert_eq!(Fight.to_str(), "Fight"); + assert_eq!(Recruit.to_str(), "Recruit"); + assert_eq!(Infrast.to_str(), "Infrast"); + assert_eq!(Mall.to_str(), "Mall"); + assert_eq!(Award.to_str(), "Award"); + assert_eq!(Roguelike.to_str(), "Roguelike"); + assert_eq!(Copilot.to_str(), "Copilot"); + assert_eq!(SSSCopilot.to_str(), "SSSCopilot"); + assert_eq!(Depot.to_str(), "Depot"); + assert_eq!(OperBox.to_str(), "OperBox"); assert_eq!( MAATask::ReclamationAlgorithm.to_str(), "ReclamationAlgorithm", ); - assert_eq!(MAATask::Custom.to_str(), "Custom"); - assert_eq!(MAATask::SingleStep.to_str(), "SingleStep"); - assert_eq!(MAATask::VideoRecognition.to_str(), "VideoRecognition"); - assert_eq!(TaskOrUnknown::MAATask(MAATask::StartUp).as_ref(), "StartUp"); - assert_eq!(TaskOrUnknown::Unknown("Other".into()).as_ref(), "Other"); + assert_eq!(Custom.to_str(), "Custom"); + assert_eq!(SingleStep.to_str(), "SingleStep"); + assert_eq!(VideoRecognition.to_str(), "VideoRecognition"); + assert_eq!(Task(StartUp).as_ref(), "StartUp"); + assert_eq!(Unknown("Other".into()).as_ref(), "Other"); } #[test] @@ -162,14 +173,12 @@ mod tests { use std::ffi::CString; assert_eq!( - TaskOrUnknown::MAATask(MAATask::StartUp) - .to_cstring() - .unwrap(), + Task(StartUp).to_cstring().unwrap(), CString::new("StartUp").unwrap(), ); assert_eq!( - TaskOrUnknown::Unknown("Other".into()).to_cstring().unwrap(), + Unknown("Other".into()).to_cstring().unwrap(), CString::new("Other").unwrap(), ); } diff --git a/maa-cli/src/config/task/value/input.rs b/maa-cli/src/config/task/value/input.rs deleted file mode 100644 index d26e9be8..00000000 --- a/maa-cli/src/config/task/value/input.rs +++ /dev/null @@ -1,653 +0,0 @@ -use std::{ - fmt::Display, - io::{BufRead, Write}, - str::FromStr, -}; - -use serde::{Deserialize, Serialize}; - -// Use batch mode in tests by default to avoid blocking tests. -// This variable can also be change at runtime by cli argument -static mut BATCH_MODE: bool = cfg!(test); - -pub unsafe fn enable_batch_mode() { - BATCH_MODE = true; -} - -/// A struct that represents a user input that queries the user for boolean input. -#[cfg_attr(test, derive(PartialEq))] -#[derive(Deserialize, Debug, Clone)] -#[serde(deny_unknown_fields)] -pub struct BoolInput { - /// Default value for this parameter. - pub default: Option, - /// Description of this parameter - pub description: Option, -} - -impl Serialize for BoolInput { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result { - self.get() - .map_err(serde::ser::Error::custom)? - .serialize(serializer) - } -} - -impl BoolInput { - pub fn new(default: Option, description: Option) -> Self - where - S: Into, - { - Self { - default, - description: description.map(|s| s.into()), - } - } - - pub fn get(&self) -> Result { - if unsafe { BATCH_MODE } { - // In batch mode, we use default value and do not ask user for input. - self.default.ok_or(Error::DefaultNotSet) - } else { - let writer = std::io::stdout(); - let reader = std::io::stdin().lock(); - self.prompt(&writer)?; - self.ask(writer, reader) - } - } - - pub fn prompt(&self, mut writer: impl Write) -> Result<()> { - write!(writer, "Whether to")?; - if let Some(description) = &self.description { - write!(writer, " {}", description)?; - } else { - write!(writer, " do something")?; - } - if let Some(default) = &self.default { - if *default { - write!(writer, " [Y/n]: ")?; - } else { - write!(writer, " [y/N]: ")?; - } - } else { - write!(writer, " [y/n]: ")?; - } - writer.flush()?; - Ok(()) - } - - /// Ask user to input a value for this parameter. - pub fn ask(&self, mut writer: impl Write, mut reader: impl BufRead) -> Result { - let mut input = String::new(); - loop { - reader.read_line(&mut input)?; - let trimmed = input.trim(); - if trimmed.is_empty() { - if let Some(default) = self.default { - break Ok(default); - } else { - write!(writer, "Default value not set, please input y/n: ")?; - writer.flush()?; - } - } else { - match trimmed { - "y" | "Y" | "yes" | "Yes" | "YES" => break Ok(true), - "n" | "N" | "no" | "No" | "NO" => break Ok(false), - _ => { - write!(writer, "Invalid input, please input y/n: ")?; - writer.flush()?; - } - }; - } - input.clear(); - } - } -} - -#[cfg_attr(test, derive(PartialEq))] -#[derive(Deserialize, Debug, Clone)] -#[serde(untagged)] -pub enum UserInput { - Input(Input), - Select(Select), -} - -impl UserInput { - pub fn get(&self) -> Result { - if unsafe { BATCH_MODE } { - match self { - Self::Input(i) => i.get_default(), - Self::Select(s) => s.get_first(), - } - } else { - let writer = std::io::stdout(); - let reader = std::io::stdin().lock(); - match self { - Self::Input(i) => { - i.prompt(&writer)?; - i.ask(&writer, reader) - } - Self::Select(s) => { - s.prompt(&writer)?; - s.ask(writer, reader) - } - } - } - } -} - -impl Serialize for UserInput -where - F: Serialize + FromStr + Clone + Display, -{ - fn serialize( - &self, - serializer: S, - ) -> std::result::Result { - self.get() - .map_err(serde::ser::Error::custom)? - .serialize(serializer) - } -} - -#[cfg_attr(test, derive(PartialEq))] -#[derive(Deserialize, Debug, Clone)] -#[serde(deny_unknown_fields)] -pub struct Input { - /// Default value for this parameter. - pub default: Option, - /// Description of this parameter - pub description: Option, -} - -impl Input { - pub fn new(default: Option, description: Option) -> Self - where - I: Into, - S: Into, - { - Self { - default: default.map(|i| i.into()), - description: description.map(|s| s.into()), - } - } - - pub fn get_default(&self) -> Result { - self.default.clone().ok_or(Error::DefaultNotSet) - } - - pub fn prompt(&self, mut writer: impl Write) -> Result<()> { - write!(writer, "Please input")?; - if let Some(description) = &self.description { - write!(writer, " {}", description)?; - } else { - write!(writer, " a {}", std::any::type_name::())?; - } - if let Some(default) = &self.default { - write!(writer, " [default: {}]", default)?; - } - write!(writer, ": ")?; - writer.flush()?; - Ok(()) - } - - /// Ask user to input a value for this parameter. - pub fn ask(&self, mut writer: impl Write, mut reader: impl BufRead) -> Result { - let mut input = String::new(); - loop { - reader.read_line(&mut input)?; - let trimmed = input.trim(); - if trimmed.is_empty() { - if let Some(default) = &self.default { - break Ok(default.clone()); - } else { - write!(writer, "Default value not set, please input a value: ")?; - writer.flush()?; - } - } else { - match trimmed.parse() { - Ok(value) => break Ok(value), - Err(_) => { - write!(writer, "Invalid input, please try again: ")?; - writer.flush()?; - } - }; - } - input.clear(); - } - } -} - -impl From> for UserInput { - fn from(input: Input) -> Self { - UserInput::Input(input) - } -} - -#[cfg_attr(test, derive(PartialEq))] -#[derive(Deserialize, Debug, Clone)] -#[serde(deny_unknown_fields)] -pub struct Select { - /// Alternatives for this parameter - pub alternatives: Vec, - /// Description of this parameter - pub description: Option, -} - -impl Select { - pub fn new(alternatives: Vec, description: Option) -> Self - where - I: Into, - S: Into, - { - Self { - alternatives: alternatives.into_iter().map(|i| i.into()).collect(), - description: description.map(|s| s.into()), - } - } - - pub fn get_first(&self) -> Result { - self.alternatives - .get(0) - .cloned() - .ok_or(Error::DefaultNotSet) - } - - pub fn prompt(&self, mut writer: impl Write) -> Result<()> { - for (i, alternative) in self.alternatives.iter().enumerate() { - writeln!(writer, "{}. {}", i + 1, alternative)?; - } - write!(writer, "Please select")?; - if let Some(description) = &self.description { - write!(writer, " {}", description)?; - } else { - write!(writer, " a {}", std::any::type_name::())?; - } - write!(writer, ": ")?; - writer.flush()?; - Ok(()) - } - - pub fn ask(&self, mut writer: impl Write, mut reader: impl BufRead) -> Result { - let mut input = String::new(); - loop { - reader.read_line(&mut input)?; - let trimmed = input.trim(); - if trimmed.is_empty() { - write!(writer, "Please select one of the alternatives: ")?; - writer.flush()?; - } else { - match trimmed.parse::() { - Ok(value) => { - if value > 0 && value <= self.alternatives.len() { - break Ok(self.alternatives[value - 1].clone()); - } else { - write!( - writer, - "Index out of range, must be between 1 and {}: ", - self.alternatives.len() - )?; - writer.flush()?; - } - } - Err(_) => { - write!(writer, "Invalid input, please try again: ")?; - writer.flush()?; - } - }; - } - input.clear(); - } - } -} - -impl From> for UserInput { - fn from(select: Select) -> Self { - UserInput::Select(select) - } -} - -#[derive(Debug)] -pub enum Error { - DefaultNotSet, - Io(std::io::Error), -} - -impl From for Error { - fn from(e: std::io::Error) -> Self { - Self::Io(e) - } -} - -impl Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self { - Self::DefaultNotSet => write!(f, "Failed to get default value"), - Self::Io(e) => write!(f, "IO error: {}", e), - } - } -} - -impl std::error::Error for Error {} - -pub type Result = std::result::Result; - -#[cfg(test)] -mod tests { - use super::*; - - mod serde { - use super::*; - - use serde_test::{assert_de_tokens, assert_ser_tokens, Token}; - - #[test] - fn bool_input() { - let value: Vec = vec![ - BoolInput::new(Some(true), Some("do something")), - BoolInput::new::<&str>(Some(false), None), - BoolInput::new(None, Some("do something")), - BoolInput::new::<&str>(None, None), - ]; - - assert_de_tokens( - &value, - &[ - Token::Seq { len: Some(4) }, - Token::Map { len: Some(2) }, - Token::Str("default"), - Token::Some, - Token::Bool(true), - Token::Str("description"), - Token::Some, - Token::Str("do something"), - Token::MapEnd, - Token::Map { len: Some(1) }, - Token::Str("default"), - Token::Some, - Token::Bool(false), - Token::MapEnd, - Token::Map { len: Some(1) }, - Token::Str("description"), - Token::Some, - Token::Str("do something"), - Token::MapEnd, - Token::Map { len: Some(0) }, - Token::MapEnd, - Token::SeqEnd, - ], - ); - - assert_ser_tokens( - &value[..2], - &[ - Token::Seq { len: Some(2) }, - Token::Bool(true), - Token::Bool(false), - Token::SeqEnd, - ], - ); - } - - #[test] - fn input() { - let value: Vec> = vec![ - UserInput::Input(Input::new(Some(1), Some("a number"))), - UserInput::Input(Input::new::(Some(2), None)), - UserInput::Input(Input::new::(None, Some("a number"))), - UserInput::Input(Input::new::(None, None)), - ]; - - assert_de_tokens( - &value, - &[ - Token::Seq { len: Some(4) }, - Token::Map { len: Some(2) }, - Token::Str("default"), - Token::I64(1), - Token::Str("description"), - Token::Str("a number"), - Token::MapEnd, - Token::Map { len: Some(1) }, - Token::Str("default"), - Token::I64(2), - Token::MapEnd, - Token::Map { len: Some(1) }, - Token::Str("description"), - Token::Str("a number"), - Token::MapEnd, - Token::Map { len: Some(0) }, - Token::MapEnd, - Token::SeqEnd, - ], - ); - - assert_ser_tokens( - &value[..2], - &[ - Token::Seq { len: Some(2) }, - Token::I64(1), - Token::I64(2), - Token::SeqEnd, - ], - ); - } - - #[test] - fn select() { - let value: Vec> = vec![ - UserInput::Select(Select::new(vec![1, 2], Some("a number"))), - UserInput::Select(Select::new::(vec![3], None)), - ]; - - assert_de_tokens( - &value, - &[ - Token::Seq { len: Some(2) }, - Token::Map { len: Some(2) }, - Token::Str("alternatives"), - Token::Seq { len: Some(2) }, - Token::I64(1), - Token::I64(2), - Token::SeqEnd, - Token::Str("description"), - Token::Str("a number"), - Token::MapEnd, - Token::Map { len: Some(1) }, - Token::Str("alternatives"), - Token::Seq { len: Some(1) }, - Token::I64(3), - Token::SeqEnd, - Token::MapEnd, - Token::SeqEnd, - ], - ); - - assert_ser_tokens( - &value, - &[ - Token::Seq { len: Some(2) }, - Token::I64(1), - Token::I64(3), - Token::SeqEnd, - ], - ); - } - } - - mod get { - use super::*; - - use crate::assert_matches; - - const INPUT_YES: &[u8] = b"y\n"; - const INPUT_NO: &[u8] = b"n\n"; - const INPUT_ONE: &[u8] = b"1\n"; - const INPUT_TWO: &[u8] = b"2\n"; - const INPUT_THREE: &[u8] = b"3\n"; - const INPUT_EMPTY: &[u8] = b"\n"; - const INPUT_INVALID: &[u8] = b"invalid\n"; - - macro_rules! combine_input { - ($input1:ident, $input2:ident) => { - &[$input1, $input2].concat()[..] - }; - } - - macro_rules! assert_output_then_clear { - ($output:ident, $expected:expr) => { - assert_eq!(&$output, $expected); - $output.clear(); - }; - ($output:ident) => { - assert_eq!(&$output, b""); - }; - } - - #[test] - fn bool_input() { - let mut output = b"".to_vec(); - let input_empty_then_yes = combine_input!(INPUT_EMPTY, INPUT_YES); - let input_invalid = combine_input!(INPUT_INVALID, INPUT_YES); - - let value: BoolInput = BoolInput::new(Some(true), Some("fight")); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Whether to fight [Y/n]: "); - assert!(value.ask(&mut output, INPUT_YES).unwrap()); - assert!(!value.ask(&mut output, INPUT_NO).unwrap()); - assert!(value.ask(&mut output, INPUT_EMPTY).unwrap()); - assert!(value.ask(&mut output, input_invalid).unwrap()); - assert_output_then_clear!(output, b"Invalid input, please input y/n: "); - assert!(value.get().unwrap()); - - let value: BoolInput = BoolInput::new(Some(false), Some("fight")); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Whether to fight [y/N]: "); - assert!(value.ask(&mut output, INPUT_YES).unwrap()); - assert!(!value.ask(&mut output, INPUT_NO).unwrap()); - assert!(!value.ask(&mut output, INPUT_EMPTY).unwrap()); - assert!(value.ask(&mut output, input_invalid).unwrap()); - assert_output_then_clear!(output, b"Invalid input, please input y/n: "); - assert!(!value.get().unwrap()); - - let value: BoolInput = BoolInput::new(None, Some("fight")); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Whether to fight [y/n]: "); - assert!(value.ask(&mut output, INPUT_YES).unwrap()); - assert!(!value.ask(&mut output, INPUT_NO).unwrap()); - assert!(value.ask(&mut output, input_empty_then_yes).unwrap()); - assert_output_then_clear!(output, b"Default value not set, please input y/n: "); - assert!(value.ask(&mut output, input_invalid).unwrap()); - assert_output_then_clear!(output, b"Invalid input, please input y/n: "); - assert_matches!(value.get(), Err(Error::DefaultNotSet)); - - let value: BoolInput = BoolInput::new::<&str>(None, None); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Whether to do something [y/n]: "); - assert!(value.ask(&mut output, INPUT_YES).unwrap()); - assert!(!value.ask(&mut output, INPUT_NO).unwrap()); - assert!(value.ask(&mut output, input_empty_then_yes).unwrap()); - assert_output_then_clear!(output, b"Default value not set, please input y/n: "); - assert!(value.ask(&mut output, input_invalid).unwrap()); - assert_output_then_clear!(output, b"Invalid input, please input y/n: "); - assert!(matches!(value.get(), Err(Error::DefaultNotSet))); - - // test other valid inputs - let value: BoolInput = BoolInput::new::<&str>(None, None); - assert!(value.ask(&mut output, &b"y\n"[..]).unwrap()); - assert!(value.ask(&mut output, &b"Y\n"[..]).unwrap()); - assert!(value.ask(&mut output, &b"yes\n"[..]).unwrap()); - assert!(value.ask(&mut output, &b"Yes\n"[..]).unwrap()); - assert!(value.ask(&mut output, &b"YES\n"[..]).unwrap()); - assert!(!value.ask(&mut output, &b"n\n"[..]).unwrap()); - assert!(!value.ask(&mut output, &b"N\n"[..]).unwrap()); - assert!(!value.ask(&mut output, &b"no\n"[..]).unwrap()); - assert!(!value.ask(&mut output, &b"No\n"[..]).unwrap()); - assert!(!value.ask(&mut output, &b"NO\n"[..]).unwrap()); - } - - #[test] - fn input() { - let mut output = b"".to_vec(); - let input_empty_then_one = combine_input!(INPUT_EMPTY, INPUT_ONE); - let input_invalid_then_one = combine_input!(INPUT_INVALID, INPUT_ONE); - - let value: Input = Input::new(Some(1), Some("a number")); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Please input a number [default: 1]: "); - assert_matches!(value.ask(&mut output, INPUT_ONE), Ok(1)); - assert_matches!(value.ask(&mut output, INPUT_TWO), Ok(2)); - assert_matches!(value.ask(&mut output, INPUT_EMPTY), Ok(1)); - assert_matches!(value.ask(&mut output, input_invalid_then_one), Ok(1)); - assert_output_then_clear!(output, b"Invalid input, please try again: "); - assert_matches!(value.get_default(), Ok(1)); - - let value: Input = Input::new::(Some(1), None); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Please input a i64 [default: 1]: "); - assert_matches!(value.ask(&mut output, INPUT_ONE), Ok(1)); - assert_matches!(value.ask(&mut output, INPUT_TWO), Ok(2)); - assert_matches!(value.ask(&mut output, INPUT_EMPTY), Ok(1)); - assert_matches!(value.ask(&mut output, input_invalid_then_one), Ok(1)); - assert_output_then_clear!(output, b"Invalid input, please try again: "); - assert_matches!(value.get_default(), Ok(1)); - - let value: Input = Input::new::(None, Some("a number")); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Please input a number: "); - assert_matches!(value.ask(&mut output, INPUT_ONE), Ok(1)); - assert_matches!(value.ask(&mut output, INPUT_TWO), Ok(2)); - assert_matches!(value.ask(&mut output, input_empty_then_one), Ok(1)); - assert_output_then_clear!(output, b"Default value not set, please input a value: "); - assert_matches!(value.get_default(), Err(Error::DefaultNotSet)); - - let value: Input = Input::new::(None, None); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"Please input a i64: "); - assert_matches!(value.ask(&mut output, INPUT_ONE), Ok(1)); - assert_matches!(value.ask(&mut output, INPUT_TWO), Ok(2)); - assert_matches!(value.ask(&mut output, input_empty_then_one), Ok(1)); - assert_output_then_clear!(output, b"Default value not set, please input a value: "); - assert_matches!(value.get_default(), Err(Error::DefaultNotSet)); - } - - #[test] - fn select() { - let mut output = b"".to_vec(); - let input_empty_then_one = combine_input!(INPUT_EMPTY, INPUT_ONE); - let input_invalid_then_one = combine_input!(INPUT_INVALID, INPUT_ONE); - let input_out_of_range_then_one = combine_input!(INPUT_THREE, INPUT_ONE); - - let value: Select = Select::new(vec!['A', 'B'], Some("an option")); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"1. A\n2. B\nPlease select an option: "); - assert_matches!(value.ask(&mut output, INPUT_ONE), Ok('A')); - assert_matches!(value.ask(&mut output, INPUT_TWO), Ok('B')); - assert_matches!(value.ask(&mut output, input_empty_then_one), Ok('A')); - assert_output_then_clear!(output, b"Please select one of the alternatives: "); - assert_matches!(value.ask(&mut output, input_invalid_then_one), Ok('A')); - assert_output_then_clear!(output, b"Invalid input, please try again: "); - assert_matches!(value.ask(&mut output, input_out_of_range_then_one), Ok('A')); - assert_output_then_clear!(output, b"Index out of range, must be between 1 and 2: "); - assert_matches!(value.get_first(), Ok('A')); - - let value: Select = Select::new::(vec!['A', 'B'], None); - value.prompt(&mut output).unwrap(); - assert_output_then_clear!(output, b"1. A\n2. B\nPlease select a char: "); - assert_matches!(value.ask(&mut output, INPUT_ONE), Ok('A')); - assert_matches!(value.ask(&mut output, INPUT_TWO), Ok('B')); - assert_matches!(value.ask(&mut output, input_empty_then_one), Ok('A')); - assert_output_then_clear!(output, b"Please select one of the alternatives: "); - assert_matches!(value.ask(&mut output, input_invalid_then_one), Ok('A')); - assert_output_then_clear!(output, b"Invalid input, please try again: "); - assert_matches!(value.ask(&mut output, input_out_of_range_then_one), Ok('A')); - assert_output_then_clear!(output, b"Index out of range, must be between 1 and 2: "); - - let value: Select = Select::new::(vec![], Some("a char")); - assert_matches!(value.get_first(), Err(Error::DefaultNotSet)); - } - } -} diff --git a/maa-cli/src/config/task/value/mod.rs b/maa-cli/src/config/task/value/mod.rs deleted file mode 100644 index 696e7403..00000000 --- a/maa-cli/src/config/task/value/mod.rs +++ /dev/null @@ -1,794 +0,0 @@ -pub mod input; - -use std::fmt::Display; - -use input::{BoolInput, Input, Select, UserInput}; - -use serde::{Deserialize, Serialize}; - -#[cfg_attr(test, derive(PartialEq))] -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(untagged)] -pub enum MAAValue { - Array(Vec), - InputString(UserInput), - InputBool(BoolInput), - InputInt(UserInput), - InputFloat(UserInput), - Object(Map), - String(String), - Bool(bool), - Int(i64), - Float(f64), - Null, -} - -impl Default for MAAValue { - fn default() -> Self { - Self::new() - } -} - -impl From for MAAValue { - fn from(value: bool) -> Self { - Self::Bool(value) - } -} - -impl From for MAAValue { - fn from(value: BoolInput) -> Self { - Self::InputBool(value) - } -} - -impl From for MAAValue { - fn from(value: i64) -> Self { - Self::Int(value) - } -} - -impl From> for MAAValue { - fn from(value: UserInput) -> Self { - Self::InputInt(value) - } -} - -impl From> for MAAValue { - fn from(value: Input) -> Self { - Self::InputInt(UserInput::Input(value)) - } -} - -impl From> for MAAValue { - fn from(value: Select) -> Self { - Self::InputInt(UserInput::Select(value)) - } -} - -impl From for MAAValue { - fn from(value: f64) -> Self { - Self::Float(value) - } -} - -impl From> for MAAValue { - fn from(value: UserInput) -> Self { - Self::InputFloat(value) - } -} - -impl From> for MAAValue { - fn from(value: Input) -> Self { - Self::InputFloat(UserInput::Input(value)) - } -} - -impl From> for MAAValue { - fn from(value: Select) -> Self { - Self::InputFloat(UserInput::Select(value)) - } -} - -impl From for MAAValue { - fn from(value: String) -> Self { - Self::String(value) - } -} - -impl From> for MAAValue { - fn from(value: UserInput) -> Self { - Self::InputString(value) - } -} - -impl From> for MAAValue { - fn from(value: Input) -> Self { - Self::InputString(UserInput::Input(value)) - } -} - -impl From> for MAAValue { - fn from(value: Select) -> Self { - Self::InputString(UserInput::Select(value)) - } -} - -impl From<&str> for MAAValue { - fn from(value: &str) -> Self { - Self::String(value.into()) - } -} - -impl, V: Into> From<[(S, V); N]> for MAAValue { - fn from(value: [(S, V); N]) -> Self { - Self::Object(Map::from(value.map(|(k, v)| (k.into(), v.into())))) - } -} - -impl> From<[T; N]> for MAAValue { - fn from(value: [T; N]) -> Self { - Self::Array(Vec::from(value.map(|v| v.into()))) - } -} - -impl MAAValue { - /// Create a new empty object - pub fn new() -> Self { - Self::Object(Map::new()) - } - - /// Initialize the value - /// - /// If the value is an input value, try to get the value from user input and set it to the value. - /// If the value is an array or an object, initialize all the values in it recursively. - pub fn init(&mut self) -> Result<(), TryFromError> { - match self { - Self::InputString(v) => *self = Self::String(v.get()?), - Self::InputBool(v) => *self = Self::Bool(v.get()?), - Self::InputInt(v) => *self = Self::Int(v.get()?), - Self::InputFloat(v) => *self = Self::Float(v.get()?), - Self::Object(map) => { - for value in map.values_mut() { - value.init()?; - } - } - Self::Array(array) => { - for value in array { - value.init()?; - } - } - _ => {} - } - - Ok(()) - } - - /// Get value of given key - /// - /// If the value is an object and the key exists, the value will be returned. - /// If the key is not exist, `None` will be returned. - /// Otherwise, the panic will be raised. - pub fn get(&self, key: &str) -> Option<&Self> { - if let Self::Object(map) = self { - map.get(key) - } else { - panic!("value is not an object"); - } - } - - /// Get value of given key or return default value - /// - /// Get value of key by calling `get`. If the key is not exist, the default value will be returned. - /// Otherwise the value will be converted to the type of the default value. - /// - /// # Errors - /// - /// If the value is not convertible to the type of the default value, the error will be returned. - pub fn get_or<'a, T>(&'a self, key: &str, default: T) -> Result - where - T: TryFrom<&'a Self>, - { - match self.get(key) { - Some(value) => value.try_into(), - None => Ok(default), - } - } - - /// Insert a key-value pair into the object - /// - /// If the value is an object, the key-value pair will be inserted into the object. - /// Otherwise, the panic will be raised. - /// If the key is already exist, the value will be replaced, - /// otherwise the key-value pair will be inserted. - pub fn insert(&mut self, key: impl Into, value: impl Into) { - if let Self::Object(map) = self { - map.insert(key.into(), value.into()); - } else { - panic!("value is not an object"); - } - } - - pub fn is_null(&self) -> bool { - matches!(self, Self::Null) - } - - pub fn is_bool(&self) -> bool { - matches!(self, Self::Bool(_) | Self::InputBool(_)) - } - - pub fn as_bool(&self) -> TryFromResult { - match self { - Self::InputBool(v) => Ok(v.get()?), - Self::Bool(v) => Ok(*v), - _ => Err(TryFromError::TypeMismatch), - } - } - - pub fn is_int(&self) -> bool { - matches!(self, Self::Int(_) | Self::InputInt(_)) - } - - pub fn as_int(&self) -> TryFromResult { - match self { - Self::InputInt(v) => Ok(v.get()?), - Self::Int(v) => Ok(*v), - _ => Err(TryFromError::TypeMismatch), - } - } - - pub fn is_float(&self) -> bool { - matches!(self, Self::Float(_) | Self::InputFloat(_)) - } - - pub fn as_float(&self) -> TryFromResult { - match self { - Self::InputFloat(v) => Ok(v.get()?), - Self::Float(v) => Ok(*v), - _ => Err(TryFromError::TypeMismatch), - } - } - - pub fn is_string(&self) -> bool { - matches!(self, Self::String(_) | Self::InputString(_)) - } - - pub fn as_string(&self) -> TryFromResult { - match self { - Self::InputString(v) => Ok(v.get()?), - Self::String(v) => Ok(v.clone()), - _ => Err(TryFromError::TypeMismatch), - } - } - - pub fn is_array(&self) -> bool { - matches!(self, Self::Array(_)) - } - - pub fn is_object(&self) -> bool { - matches!(self, Self::Object(_)) - } - - pub fn merge(&self, other: &Self) -> Self { - let mut ret = self.clone(); - ret.merge_mut(other); - ret - } - - pub fn merge_mut(&mut self, other: &Self) { - match (self, other) { - (Self::Object(self_map), Self::Object(other_map)) => { - for (key, value) in other_map { - if let Some(self_value) = self_map.get_mut(key) { - self_value.merge_mut(value); - } else { - self_map.insert(key.clone(), value.clone()); - } - } - } - (s, o) => *s = o.clone(), - } - } -} - -#[macro_export] -macro_rules! object { - () => { - MAAValue::new() - }; - ($($key:expr => $value:expr),* $(,)?) => {{ - let mut value = MAAValue::new(); - $(value.insert($key, $value);)* - value - }}; -} - -impl TryFrom<&MAAValue> for bool { - type Error = TryFromError; - - fn try_from(value: &MAAValue) -> Result { - value.as_bool() - } -} - -impl TryFrom<&MAAValue> for i64 { - type Error = TryFromError; - - fn try_from(value: &MAAValue) -> Result { - value.as_int() - } -} - -impl TryFrom<&MAAValue> for f64 { - type Error = TryFromError; - - fn try_from(value: &MAAValue) -> Result { - value.as_float() - } -} - -impl TryFrom<&MAAValue> for String { - type Error = TryFromError; - - fn try_from(value: &MAAValue) -> Result { - value.as_string() - } -} - -type TryFromResult = Result; - -#[derive(Debug)] -pub enum TryFromError { - TypeMismatch, - InputError(input::Error), -} - -impl From for TryFromError { - fn from(error: input::Error) -> Self { - Self::InputError(error) - } -} - -impl Display for TryFromError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TryFromError::TypeMismatch => write!(f, "type mismatch"), - TryFromError::InputError(error) => write!(f, "{}", error), - } - } -} - -impl std::error::Error for TryFromError {} - -pub type Map = std::collections::BTreeMap; - -#[cfg(test)] -mod tests { - use super::*; - - use super::input::Input; - - mod serde { - use super::*; - - use serde_test::{assert_de_tokens, assert_tokens, Token}; - - #[test] - fn input() { - let value = object!( - "input_bool" => BoolInput { - default: Some(true), - description: None, - }, - "input_int" => Input { - default: Some(1), - description: None, - }, - "input_float" => Input { - default: Some(1.0), - description: None, - }, - "input_string" => Input { - default: Some("string".to_string()), - description: None, - }, - "input_no_default" => Input:: { - default: None, - description: None, - }, - "select_int" => Select { - alternatives: vec![1, 2], - description: None, - }, - "select_float" => Select { - alternatives: vec![1.0, 2.0], - description: None, - }, - "select_string" => Select { - alternatives: vec!["string1".to_string(), "string2".to_string()], - description: None, - }, - ); - - assert_de_tokens( - &value, - &[ - Token::Map { len: Some(8) }, - Token::Str("input_bool"), - Token::Map { len: Some(1) }, - Token::Str("default"), - Token::Bool(true), - Token::MapEnd, - Token::Str("input_int"), - Token::Map { len: Some(1) }, - Token::Str("default"), - Token::I64(1), - Token::MapEnd, - Token::Str("input_float"), - Token::Map { len: Some(1) }, - Token::Str("default"), - Token::F64(1.0), - Token::MapEnd, - Token::Str("input_string"), - Token::Map { len: Some(1) }, - Token::Str("default"), - Token::Str("string"), - Token::MapEnd, - Token::Str("input_no_default"), - Token::Map { len: Some(0) }, - Token::MapEnd, - Token::Str("select_int"), - Token::Map { len: Some(1) }, - Token::Str("alternatives"), - Token::Seq { len: Some(2) }, - Token::I64(1), - Token::I64(2), - Token::SeqEnd, - Token::MapEnd, - Token::Str("select_float"), - Token::Map { len: Some(1) }, - Token::Str("alternatives"), - Token::Seq { len: Some(2) }, - Token::F64(1.0), - Token::F64(2.0), - Token::SeqEnd, - Token::MapEnd, - Token::Str("select_string"), - Token::Map { len: Some(1) }, - Token::Str("alternatives"), - Token::Seq { len: Some(2) }, - Token::Str("string1"), - Token::Str("string2"), - Token::SeqEnd, - Token::MapEnd, - Token::MapEnd, - ], - ) - } - - #[test] - fn value() { - let value = object!( - "array" => [1, 2], - "bool" => true, - "float" => 1.0, - "int" => 1, - "null" => MAAValue::Null, - "object" => [("key", "value")], - "string" => "string", - ); - - assert_tokens( - &value, - &[ - Token::Map { len: Some(7) }, - Token::Str("array"), - Token::Seq { len: Some(2) }, - Token::I64(1), - Token::I64(2), - Token::SeqEnd, - Token::Str("bool"), - Token::Bool(true), - Token::Str("float"), - Token::F64(1.0), - Token::Str("int"), - Token::I64(1), - Token::Str("null"), - Token::Unit, - Token::Str("object"), - Token::Map { len: Some(1) }, - Token::Str("key"), - Token::Str("value"), - Token::MapEnd, - Token::Str("string"), - Token::Str("string"), - Token::MapEnd, - ], - ) - } - - #[test] - fn array() { - let value = MAAValue::Array(vec![ - MAAValue::Bool(true), - MAAValue::Int(1_i64), - MAAValue::Float(1.0), - MAAValue::String("string".to_string()), - MAAValue::from([1, 2]), - MAAValue::from([("key", "value")]), - ]); - - assert_tokens( - &value, - &[ - Token::Seq { len: Some(6) }, - Token::Bool(true), - Token::I64(1), - Token::F64(1.0), - Token::Str("string"), - Token::Seq { len: Some(2) }, - Token::I64(1), - Token::I64(2), - Token::SeqEnd, - Token::Map { len: Some(1) }, - Token::Str("key"), - Token::Str("value"), - Token::MapEnd, - Token::SeqEnd, - ], - ) - } - - #[test] - fn bool() { - let boolean = MAAValue::Bool(true); - assert_tokens(&boolean, &[Token::Bool(true)]); - } - - #[test] - fn int() { - let integer = MAAValue::Int(1); - assert_tokens(&integer, &[Token::I64(1)]); - } - - #[test] - fn float() { - let float = MAAValue::Float(1.0); - assert_tokens(&float, &[Token::F64(1.0)]); - } - - #[test] - fn string() { - let string = MAAValue::String("string".to_string()); - assert_tokens(&string, &[Token::Str("string")]); - } - - #[test] - fn null() { - let null = MAAValue::Null; - assert_tokens(&null, &[Token::Unit]); - } - } - - #[test] - fn init() { - let input_bool = BoolInput { - default: Some(true), - description: None, - }; - let input_int = UserInput::Input(Input { - default: Some(1), - description: None, - }); - let input_float = UserInput::Input(Input { - default: Some(1.0), - description: None, - }); - let input_string = UserInput::Input(Input { - default: Some("string".to_string()), - description: None, - }); - - let mut value = object!( - "null" => MAAValue::Null, - "bool" => input_bool.clone(), - "int" => input_int.clone(), - "float" => input_float.clone(), - "string" => input_string.clone(), - "array" => [input_int.clone()], - "object" => [("int", input_int.clone())], - ); - - assert_eq!(value.get("null").unwrap(), &MAAValue::Null); - assert_eq!(value.get("bool").unwrap(), &MAAValue::InputBool(input_bool)); - assert_eq!( - value.get("int").unwrap(), - &MAAValue::InputInt(input_int.clone()) - ); - assert_eq!( - value.get("float").unwrap(), - &MAAValue::InputFloat(input_float) - ); - assert_eq!( - value.get("string").unwrap(), - &MAAValue::InputString(input_string) - ); - assert_eq!( - value.get("array").unwrap(), - &MAAValue::Array(vec![MAAValue::InputInt(input_int.clone())]) - ); - assert_eq!( - value.get("object").unwrap(), - &MAAValue::Object(Map::from([( - "int".to_string(), - MAAValue::InputInt(input_int.clone()) - )])) - ); - - value.init().unwrap(); - - assert_eq!(value.get("null").unwrap(), &MAAValue::Null); - assert_eq!(value.get("bool").unwrap(), &MAAValue::Bool(true)); - assert_eq!(value.get("int").unwrap(), &MAAValue::Int(1)); - assert_eq!(value.get("float").unwrap(), &MAAValue::Float(1.0)); - assert_eq!( - value.get("string").unwrap(), - &MAAValue::String("string".to_string()) - ); - assert_eq!( - value.get("array").unwrap(), - &MAAValue::Array(vec![MAAValue::Int(1)]) - ); - assert_eq!( - value.get("object").unwrap(), - &MAAValue::Object(Map::from([("int".to_string(), MAAValue::Int(1))])) - ); - } - - #[test] - fn get() { - let value = MAAValue::from([("int", 1)]); - - assert_eq!(value.get("int").unwrap(), &MAAValue::Int(1.into())); - assert!(value.get("float").is_none()); - - assert_eq!(value.get_or("int", 2).unwrap(), 1); - assert_eq!(value.get_or("float", 2.0).unwrap(), 2.0); - } - - #[test] - #[should_panic] - fn get_panic() { - let value = MAAValue::Null; - value.get("int"); - } - - #[test] - fn insert() { - let mut value = MAAValue::Object(Map::new()); - assert_eq!(value.get_or("int", 2).unwrap(), 2); - value.insert("int", 1); - assert_eq!(value.get_or("int", 2).unwrap(), 1); - } - - #[test] - #[should_panic] - fn insert_panic() { - let mut value = MAAValue::Null; - value.insert("int", 1); - } - - #[test] - fn is_sth() { - assert!(MAAValue::Null.is_null()); - assert!(MAAValue::from(true).is_bool()); - assert!(MAAValue::from(1).is_int()); - assert!(MAAValue::from(1.0).is_float()); - assert!(MAAValue::from(String::from("string")).is_string()); - assert!(MAAValue::from("string").is_string()); - assert!(MAAValue::from([1, 2]).is_array()); - assert!(MAAValue::from([("key", "value")]).is_object()); - } - - #[test] - #[allow(clippy::bool_assert_comparison)] - fn try_from() { - // Bool - let bool_value = MAAValue::from(true); - assert_eq!(bool::try_from(&bool_value).unwrap(), true); - assert!(matches!( - i64::try_from(&bool_value), - Err(TryFromError::TypeMismatch) - )); - let bool_input_value = MAAValue::InputBool(BoolInput { - default: Some(true), - description: None, - }); - assert_eq!(bool::try_from(&bool_input_value).unwrap(), true); - assert!(matches!( - i64::try_from(&bool_input_value), - Err(TryFromError::TypeMismatch) - )); - - // Int - let int_value = MAAValue::from(1); - assert_eq!(i64::try_from(&int_value).unwrap(), 1); - assert!(matches!( - f64::try_from(&int_value), - Err(TryFromError::TypeMismatch) - )); - let int_input_value = MAAValue::InputInt(UserInput::Input(Input { - default: Some(1), - description: None, - })); - assert_eq!(i64::try_from(&int_input_value).unwrap(), 1); - assert!(matches!( - f64::try_from(&int_input_value), - Err(TryFromError::TypeMismatch) - )); - - // Float - let float_value = MAAValue::from(1.0); - assert_eq!(f64::try_from(&float_value).unwrap(), 1.0); - assert!(matches!( - String::try_from(&float_value), - Err(TryFromError::TypeMismatch) - )); - let float_input_value = MAAValue::InputFloat(UserInput::Input(Input { - default: Some(1.0), - description: None, - })); - assert_eq!(f64::try_from(&float_input_value).unwrap(), 1.0); - assert!(matches!( - String::try_from(&float_input_value), - Err(TryFromError::TypeMismatch) - )); - - // String - let string_value = MAAValue::from("string"); - assert_eq!(String::try_from(&string_value).unwrap(), "string"); - assert!(matches!( - bool::try_from(&string_value), - Err(TryFromError::TypeMismatch) - )); - let string_input_value = MAAValue::InputString(UserInput::Input(Input { - default: Some("string".to_string()), - description: None, - })); - assert_eq!(String::try_from(&string_input_value).unwrap(), "string"); - assert!(matches!( - bool::try_from(&string_input_value), - Err(TryFromError::TypeMismatch) - )); - } - - #[test] - fn merge() { - let value = object!( - "bool" => true, - "int" => 1, - "float" => 1.0, - "string" => "string", - "array" => [1, 2], - "object" => [("key1", "value1"), ("key2", "value2")], - ); - - let value2 = object!( - "bool" => false, - "int" => 2, - "array" => [3, 4], - "object" => [("key2", "value2_2"), ("key3", "value3")], - ); - - assert_eq!( - value.merge(&value2), - object!( - "bool" => false, - "int" => 2, - "float" => 1.0, - "string" => "string", - "array" => [3, 4], // array will be replaced instead of merged - "object" => [("key1", "value1"), ("key2", "value2_2"), ("key3", "value3")], - ), - ); - } -} diff --git a/maa-cli/src/main.rs b/maa-cli/src/main.rs index 2c42c238..8c8cbff6 100644 --- a/maa-cli/src/main.rs +++ b/maa-cli/src/main.rs @@ -4,12 +4,9 @@ mod consts; mod dirs; mod installer; mod run; +mod value; -use crate::{ - config::{cli, task::value::input::enable_batch_mode}, - dirs::Ensure, - installer::resource, -}; +use crate::{config::cli, dirs::Ensure, installer::resource, value::userinput::enable_batch_mode}; #[cfg(feature = "cli_installer")] use crate::installer::maa_cli; @@ -146,7 +143,8 @@ enum SubCommand { /// Run fight task Fight { /// Stage to fight - stage: Option, + #[arg(default_value = "")] + stage: String, /// Run startup task before the fight #[arg(long)] startup: bool, @@ -169,8 +167,7 @@ enum SubCommand { /// Theme of the game /// /// The theme of the game, can be one of "Phantom", "Mizuki" and "Sami". - /// If not specified, it will be asked in the game. - theme: Option, + args: run::RoguelikeTheme, #[command(flatten)] common: run::CommonArgs, }, @@ -297,7 +294,7 @@ fn main() -> Result<()> { builder.init(); if cli.batch { - unsafe { enable_batch_mode() }; + enable_batch_mode() } let subcommand = cli.command; @@ -362,7 +359,7 @@ fn main() -> Result<()> { |config| run::copilot(uri, config.resource.base_dirs()), common, )?, - SubCommand::Roguelike { theme, common } => run::run(|_| run::roguelike(theme), common)?, + SubCommand::Roguelike { args, common } => run::run(|_| run::roguelike(args), common)?, SubCommand::Convert { input, output, @@ -708,30 +705,20 @@ mod test { #[test] fn fight() { - assert_matches!( - CLI::parse_from(["maa", "fight"]).command, - SubCommand::Fight { - stage: None, - startup: false, - closedown: false, - .. - } - ); - assert_matches!( CLI::parse_from(["maa", "fight", "1-7"]).command, SubCommand::Fight { - stage: Some(stage), + stage, .. } if stage == "1-7" ); assert_matches!( - CLI::parse_from(["maa", "fight", "--startup"]).command, + CLI::parse_from(["maa", "fight", "1-7", "--startup"]).command, SubCommand::Fight { startup: true, .. } ); assert_matches!( - CLI::parse_from(["maa", "fight", "--closedown"]).command, + CLI::parse_from(["maa", "fight", "1-7", "--closedown"]).command, SubCommand::Fight { closedown: true, .. @@ -758,21 +745,19 @@ mod test { ); } - #[test] - fn rougelike() { - assert_matches!( - CLI::parse_from(["maa", "roguelike"]).command, - SubCommand::Roguelike { theme: None, .. } - ); - - assert_matches!( - CLI::parse_from(["maa", "roguelike", "phantom"]).command, - SubCommand::Roguelike { - theme: Some(theme), - .. - } if matches!(theme, run::RoguelikeTheme::Phantom) - ); - } + // #[test] + // fn rougelike() { + // assert_matches!( + // CLI::parse_from(["maa", "roguelike", "phantom"]).command, + // SubCommand::Roguelike { + // args: run::RoguelikeArgs { + // theme: theme, + // .. + // }, + // .. + // } if matches!(theme, run::RoguelikeTheme::Phantom) + // ); + // } #[test] fn convert() { diff --git a/maa-cli/src/run/callback/summary.rs b/maa-cli/src/run/callback/summary.rs index ad2135c8..90d99068 100644 --- a/maa-cli/src/run/callback/summary.rs +++ b/maa-cli/src/run/callback/summary.rs @@ -115,12 +115,13 @@ pub struct TaskSummary { impl TaskSummary { pub fn new(name: Option, task: TaskOrUnknown) -> Self { use MAATask::*; + use TaskOrUnknown::Task; let detail = match task { - TaskOrUnknown::MAATask(Fight) => Detail::Fight(FightDetail::new()), - TaskOrUnknown::MAATask(Infrast) => Detail::Infrast(InfrastDetail::new()), - TaskOrUnknown::MAATask(Recruit) => Detail::Recruit(RecruitDetail::new()), - TaskOrUnknown::MAATask(Roguelike) => Detail::Roguelike(RoguelikeDetail::new()), + Task(Fight) => Detail::Fight(FightDetail::new()), + Task(Infrast) => Detail::Infrast(InfrastDetail::new()), + Task(Recruit) => Detail::Recruit(RecruitDetail::new()), + Task(Roguelike) => Detail::Roguelike(RoguelikeDetail::new()), _ => Detail::None, }; diff --git a/maa-cli/src/run/copilot.rs b/maa-cli/src/run/copilot.rs index 4a284aae..ea679ba1 100644 --- a/maa-cli/src/run/copilot.rs +++ b/maa-cli/src/run/copilot.rs @@ -1,11 +1,11 @@ use crate::{ - config::task::{ - task_type::MAATask, - value::input::{BoolInput, Input}, - MAAValue, Task, TaskConfig, - }, + config::task::{task_type::MAATask, Task, TaskConfig}, dirs::{self, Ensure}, object, + value::{ + userinput::{BoolInput, Input}, + MAAValue, + }, }; use std::{ @@ -15,7 +15,7 @@ use std::{ }; use anyhow::{bail, Context, Result}; -use log::{debug, info, trace, warn}; +use log::{debug, trace, warn}; use prettytable::{format, row, Table}; use serde_json::Value as JsonValue; @@ -37,10 +37,10 @@ pub fn copilot(uri: impl AsRef, resource_dirs: &Vec) -> Result filename.as_ref(), - "loop_times" => Input::::new(Some(1), Some("loop times")) + "loop_times" => Input::new(Some(1), Some("loop times")) ), ), } diff --git a/maa-cli/src/run/fight.rs b/maa-cli/src/run/fight.rs index 4cd98ee8..358eb464 100644 --- a/maa-cli/src/run/fight.rs +++ b/maa-cli/src/run/fight.rs @@ -1,45 +1,48 @@ use crate::{ - config::task::{ - task_type::MAATask, - value::input::{BoolInput, Input, Select}, - MAAValue, Task, TaskConfig, - }, + config::task::{task_type::MAATask, ClientType, Task, TaskConfig}, object, + value::{ + userinput::{BoolInput, Input, SelectD, ValueWithDesc}, + MAAValue, Map, + }, }; use anyhow::Result; -pub fn fight(stage: Option, startup: bool, closedown: bool) -> Result -where - S: Into, -{ +impl From for ValueWithDesc { + fn from(client: ClientType) -> Self { + Self::Value(client.to_string()) + } +} + +pub fn fight(stage: String, startup: bool, closedown: bool) -> Result { let mut task_config = TaskConfig::new(); + use MAAValue::OptionalInput; if startup { + use ClientType::*; task_config.push(Task::new_with_default( MAATask::StartUp, object!( "start_game_enabled" => BoolInput::new(Some(true), Some("start game")), - "client_type" => Select::::new( - // TODO: a select type that accepts a enum (maybe a trait) - vec!["Official", "Bilibili", "Txwy", "YoStarEN", "YoStarJP", "YoStarKR"], - Some("client type"), - ), + "client_type" => OptionalInput { + deps: Map::from([("start_game_enabled".to_string(), true.into())]), + input: SelectD::::new( + vec![Official, Bilibili, Txwy, YoStarEN, YoStarJP, YoStarKR], + Some(1), + Some("a client type"), + true, + ).unwrap().into(), + } ), )); } - let stage = if let Some(stage) = stage { - MAAValue::String(stage.into()) - } else { - Input::::new(Some("1-7"), Some("a stage to fight")).into() - }; - task_config.push(Task::new_with_default( MAATask::Fight, object!( "stage" => stage, - "medicine" => Input::::new(Some(0), Some("medicine to use")), + "medicine" => Input::new(Some(0), Some("the number of medicine to use")), ), )); @@ -57,40 +60,57 @@ where mod tests { use super::*; - use crate::{ - assert_matches, - config::task::{task_type::TaskOrUnknown, ClientType, InitializedTaskConfig}, - }; + use crate::config::task::{InitializedTask, InitializedTaskConfig}; #[test] fn test_fight() { - assert_matches!( - fight::<&str>(None, true, true).unwrap().init().unwrap(), + use ClientType::*; + + assert_eq!( + fight("1-7".to_string(), true, true) + .unwrap() + .init() + .unwrap(), InitializedTaskConfig { client_type: Some(ClientType::Official), start_app: true, close_app: true, - tasks - } if tasks.len() == 3 && { - let fight = &tasks[1]; - fight.task_type() == &TaskOrUnknown::MAATask(MAATask::Fight) - && fight.params().get("stage").unwrap().as_string().unwrap() == "1-7" - && fight.params().get("medicine").unwrap().as_int().unwrap() == 0 + tasks: vec![ + InitializedTask::new_noname( + MAATask::StartUp, + object!( + "start_game_enabled" => true, + "client_type" => Official.as_ref(), + ), + ), + InitializedTask::new_noname( + MAATask::Fight, + object!( + "stage" => "1-7", + "medicine" => 0, + ), + ), + InitializedTask::new_noname(MAATask::CloseDown, object!()), + ], } ); - assert_matches!( - fight(Some("CE-6"), false, false).unwrap().init().unwrap(), + assert_eq!( + fight("CE-6".to_string(), false, false) + .unwrap() + .init() + .unwrap(), InitializedTaskConfig { client_type: None, start_app: false, close_app: false, - tasks - } if tasks.len() == 1 && { - let fight = &tasks[0]; - fight.task_type() == &TaskOrUnknown::MAATask(MAATask::Fight) - && fight.params().get("stage").unwrap().as_string().unwrap() == "CE-6" - && fight.params().get("medicine").unwrap().as_int().unwrap() == 0 + tasks: vec![InitializedTask::new_noname( + MAATask::Fight, + object!( + "stage" => "CE-6", + "medicine" => 0, + ), + )], } ) } diff --git a/maa-cli/src/run/roguelike.rs b/maa-cli/src/run/roguelike.rs index ea80eef1..37f84bcb 100644 --- a/maa-cli/src/run/roguelike.rs +++ b/maa-cli/src/run/roguelike.rs @@ -1,16 +1,17 @@ use crate::{ - config::task::{ - task_type::MAATask, - value::input::{BoolInput, Input}, - MAAValue, Task, TaskConfig, - }, + config::task::{task_type::MAATask, Task, TaskConfig}, object, + value::{ + userinput::{BoolInput, Input, SelectD, ValueWithDesc}, + MAAValue, Map, + }, }; use anyhow::Result; use clap::ValueEnum; -#[derive(ValueEnum, Clone, Copy)] +#[cfg_attr(test, derive(PartialEq, Debug))] +#[derive(Clone, Copy)] pub enum Theme { Phantom, Mizuki, @@ -18,35 +19,67 @@ pub enum Theme { } impl Theme { - pub fn to_str(self) -> &'static str { + fn to_str(self) -> &'static str { match self { - Theme::Phantom => "Phantom", - Theme::Mizuki => "Mizuki", - Theme::Sami => "Sami", + Self::Phantom => "Phantom", + Self::Mizuki => "Mizuki", + Self::Sami => "Sami", } } } -pub fn roguelike(theme: Option) -> Result { +impl ValueEnum for Theme { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Phantom, Self::Mizuki, Self::Sami] + } + + fn to_possible_value(&self) -> Option { + Some(clap::builder::PossibleValue::new(self.to_str())) + } +} + +pub fn roguelike(theme: Theme) -> Result { let mut task_config = TaskConfig::new(); - let theme = if let Some(theme) = theme { - MAAValue::String(theme.to_str().into()) - } else { - MAAValue::InputString(Input::::new(Some("Sami"), Some("theme")).into()) - }; + use MAAValue::OptionalInput; + 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" => OptionalInput { + deps: Map::from([("investment_disabled".to_string(), false.into())]), + input: Input::::new(Some(999), Some("number of times to invest")).into(), + }, + "stop_when_investment_full" => OptionalInput { + deps: Map::from([("investment_disabled".to_string(), false.into())]), + input: BoolInput::new(Some(false), Some("stop when investment is full")).into(), + }, + "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" => OptionalInput { + deps: Map::from([("use_support".to_string(), true.into())]), + input: BoolInput::new(Some(false), Some("use non-friend support operator")).into(), + }, + "refresh_trader_with_dice" => OptionalInput { + deps: Map::from([("theme".to_string(), "Mizuki".into())]), + input: BoolInput::new(Some(false), Some("refresh trader with dice")).into(), + }, + ); - // TODO: better prompt and options - task_config.push(Task::new_with_default( - MAATask::Roguelike, - object!( - "theme" => theme, - "mode" => Input::::new(Some(0), Some("mode")), - "squad" => Input::::new::(None, Some("a squad name")), - "core_char" => Input::::new::(None, Some("a operator name")), - "use_support" => BoolInput::new(Some(true), Some("use support")), - ), - )); + task_config.push(Task::new_with_default(MAATask::Roguelike, params)); Ok(task_config) } @@ -55,8 +88,6 @@ pub fn roguelike(theme: Option) -> Result { mod tests { use super::*; - use crate::config::task::task_type::TaskOrUnknown; - #[test] fn theme_to_str() { assert_eq!(Theme::Phantom.to_str(), "Phantom"); @@ -65,33 +96,38 @@ mod tests { } #[test] - fn test_roguelike() { + fn theme_value_variants() { assert_eq!( - roguelike(None).unwrap().tasks()[0], - Task::new_with_default( - TaskOrUnknown::MAATask(MAATask::Roguelike), - object!( - "theme" => MAAValue::InputString(Input::::new(Some("Sami"), Some("theme")).into()), - "mode" => Input::::new(Some(0), Some("mode")), - "squad" => Input::::new::(None, Some("a squad name")), - "core_char" => Input::::new::(None, Some("a operator name")), - "use_support" => BoolInput::new(Some(true), Some("use support")), - ), - ) + Theme::value_variants(), + &[Theme::Phantom, Theme::Mizuki, Theme::Sami] ); + } + #[test] + fn theme_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")) + ); + } + + #[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(), &MAATask::Roguelike); assert_eq!( - roguelike(Some(Theme::Phantom)).unwrap().tasks()[0], - Task::new_with_default( - TaskOrUnknown::MAATask(MAATask::Roguelike), - object!( - "theme" => MAAValue::String("Phantom".into()), - "mode" => Input::::new(Some(0), Some("mode")), - "squad" => Input::::new::(None, Some("a squad name")), - "core_char" => Input::::new::(None, Some("a operator name")), - "use_support" => BoolInput::new(Some(true), Some("use support")), - ), - ) + tasks[0].params().get("theme").unwrap(), + &MAAValue::from("Phantom") ); } } diff --git a/maa-cli/src/value/input.rs b/maa-cli/src/value/input.rs new file mode 100644 index 00000000..a4ae5bd6 --- /dev/null +++ b/maa-cli/src/value/input.rs @@ -0,0 +1,229 @@ +use super::{ + primate::MAAPrimate, + userinput::{BoolInput, Input, SelectD, UserInput}, + MAAValue, +}; + +use std::io; + +use serde::Deserialize; + +#[cfg_attr(test, derive(PartialEq, Debug))] +#[derive(Deserialize, Clone)] +#[serde(untagged)] +pub enum MAAInput { + InputString(Input), + InputBool(BoolInput), + InputInt(Input), + InputFloat(Input), + SelectInt(SelectD), + SelectFloat(SelectD), + SelectString(SelectD), +} + +impl MAAInput { + pub(super) fn into_primate(self) -> io::Result { + use MAAInput::*; + use MAAPrimate::*; + match self { + InputBool(v) => Ok(Bool(v.value()?)), + InputInt(v) => Ok(Int(v.value()?)), + InputFloat(v) => Ok(Float(v.value()?)), + InputString(v) => Ok(String(v.value()?)), + SelectInt(v) => Ok(Int(v.value()?)), + SelectFloat(v) => Ok(Float(v.value()?)), + SelectString(v) => Ok(String(v.value()?)), + } + } +} + +impl From for MAAInput { + fn from(v: BoolInput) -> Self { + Self::InputBool(v) + } +} + +impl From> for MAAInput { + fn from(v: Input) -> Self { + Self::InputInt(v) + } +} + +impl From> for MAAInput { + fn from(v: Input) -> Self { + Self::InputFloat(v) + } +} + +impl From> for MAAInput { + fn from(v: Input) -> Self { + Self::InputString(v) + } +} + +impl From> for MAAInput { + fn from(v: SelectD) -> Self { + Self::SelectInt(v) + } +} + +impl From> for MAAInput { + fn from(v: SelectD) -> Self { + Self::SelectFloat(v) + } +} + +impl From> for MAAInput { + fn from(v: SelectD) -> Self { + Self::SelectString(v) + } +} + +macro_rules! impl_into_maa_value { + ($($t:ty),* $(,)?) => { + $( + impl From<$t> for MAAValue { + fn from(v: $t) -> Self { + Self::Input(v.into()) + } + } + )* + }; +} + +impl_into_maa_value!( + BoolInput, + Input, + Input, + Input, + SelectD, + SelectD, + SelectD, + // MAAInput, +); + +#[cfg(test)] +mod tests { + use super::*; + + fn sstr(s: &str) -> Option { + Some(s.to_string()) + } + + #[test] + fn deserialize() { + use serde_test::{assert_de_tokens, Token}; + + let values: Vec = vec![ + BoolInput::new(Some(true), None).into(), + Input::new(Some(1), None).into(), + Input::new(Some(1.0), None).into(), + Input::new(sstr("1"), None).into(), + SelectD::new([1, 2], Some(2), None, false).unwrap().into(), + SelectD::new([1.0, 2.0], Some(2), None, false) + .unwrap() + .into(), + SelectD::::new(["1", "2"], Some(2), None, false) + .unwrap() + .into(), + ]; + + assert_de_tokens( + &values, + &[ + Token::Seq { len: Some(7) }, + Token::Map { len: Some(1) }, + Token::String("default"), + Token::Bool(true), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::String("default"), + Token::I64(1), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::String("default"), + Token::F64(1.0), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::String("default"), + Token::String("1"), + Token::MapEnd, + Token::Map { len: Some(2) }, + Token::String("default_index"), + Token::U64(2), + Token::String("alternatives"), + Token::Seq { len: Some(2) }, + Token::I64(1), + Token::I64(2), + Token::SeqEnd, + Token::MapEnd, + Token::Map { len: Some(2) }, + Token::String("default_index"), + Token::U64(2), + Token::String("alternatives"), + Token::Seq { len: Some(2) }, + Token::F64(1.0), + Token::F64(2.0), + Token::SeqEnd, + Token::MapEnd, + Token::Map { len: Some(2) }, + Token::String("default_index"), + Token::U64(2), + Token::String("alternatives"), + Token::Seq { len: Some(2) }, + Token::String("1"), + Token::String("2"), + Token::SeqEnd, + Token::MapEnd, + Token::SeqEnd, + ], + ); + } + + #[test] + fn to_primate() { + assert_eq!( + MAAInput::from(BoolInput::new(Some(true), None)) + .into_primate() + .unwrap(), + true.into() + ); + assert_eq!( + MAAInput::InputInt(Input::new(Some(1), None)) + .into_primate() + .unwrap(), + 1.into() + ); + assert_eq!( + MAAInput::InputFloat(Input::new(Some(1.0), None)) + .into_primate() + .unwrap(), + 1.0.into() + ); + assert_eq!( + MAAInput::InputString(Input::new(sstr("1"), None)) + .into_primate() + .unwrap(), + "1".into() + ); + assert_eq!( + MAAInput::SelectInt(SelectD::new([1, 2], Some(2), None, false).unwrap()) + .into_primate() + .unwrap(), + 2.into() + ); + assert_eq!( + MAAInput::SelectFloat(SelectD::new([1.0, 2.0], Some(2), None, false).unwrap()) + .into_primate() + .unwrap(), + 2.0.into() + ); + + assert_eq!( + MAAInput::from(SelectD::::new(["1", "2"], Some(2), None, false).unwrap()) + .into_primate() + .unwrap(), + "2".into() + ); + } +} diff --git a/maa-cli/src/value/mod.rs b/maa-cli/src/value/mod.rs new file mode 100644 index 00000000..250047e4 --- /dev/null +++ b/maa-cli/src/value/mod.rs @@ -0,0 +1,671 @@ +pub mod userinput; + +mod primate; +pub use primate::MAAPrimate; + +mod input; +pub use input::MAAInput; + +use std::io; + +use serde::{Deserialize, Serialize}; + +#[cfg_attr(test, derive(PartialEq, Debug))] +#[derive(Deserialize, Clone)] +#[serde(untagged)] +pub enum MAAValue { + /// An array of values + Array(Vec), + /// A value that should be queried from user input + Input(MAAInput), + /// A optional value only if all the dependencies are satisfied. Must in an object. + /// + /// If keys in dependencies are not exist or the values are not equal to the expected values, + /// the value will be dropped during initialization. + OptionalInput { + /// A map of dependencies + /// + /// Keys are the keys of the dependencies in the sam object and values are the expected + deps: Map, + /// Input value query from user when all the dependencies are satisfied + #[serde(flatten)] + input: MAAInput, + }, + /// Object is a map of key-value pair + Object(Map), + /// Primate json types: bool, int, float, string + Primate(MAAPrimate), +} + +impl Serialize for MAAValue { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + use MAAValue::*; + + // shortcut for return custom serde serialization error + macro_rules! serr { + ($msg:expr) => { + Err(serde::ser::Error::custom($msg)) + }; + } + + match self { + // Serialize the value directly + Primate(v) => v.serialize(serializer), + // Serialize as a sequence of values and filter out all the missing values + Array(v) => v.serialize(serializer), + // Serialize as a map of key-value pairs and filter all the missing values + Object(v) => v.serialize(serializer), + // Input value should be initialized before serializing + _ => serr!("cannot serialize input value, you shoule initialize it first"), + } + } +} + +impl MAAValue { + /// Create a new empty object + pub fn new() -> Self { + Self::Object(Map::new()) + } + + /// Initialize the value + /// + /// If the value is an primate value, do nothing. + /// If the value is an input value, try to get the value from user input and set it to the value. + /// If the value is an array or an object, initialize all the values in it recursively. + /// + /// For optional input value, initialize it only if all the dependencies are satisfied. + /// + /// Note: circular dependencies will be set to missing. + pub fn init(self) -> io::Result { + use MAAValue::*; + match self { + Input(v) => Ok(v.into_primate()?.into()), + Array(array) => { + let mut ret = Vec::with_capacity(array.len()); + for value in array { + ret.push(value.init()?); + } + Ok(Array(ret)) + } + Object(mut map) => { + let mut sorted_map = map.iter().collect::>(); + sorted_map.sort_by(|(k1, v1), (k2, v2)| match (v1, v2) { + (OptionalInput { deps: deps1, .. }, OptionalInput { deps: deps2, .. }) => { + match (deps1.contains_key(*k2), deps2.contains_key(*k1)) { + (false, false) => k1.cmp(k2), + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (true, true) => panic!("circular dependencies"), + } + } + (OptionalInput { .. }, _) => std::cmp::Ordering::Greater, + (_, OptionalInput { .. }) => std::cmp::Ordering::Less, + _ => k1.cmp(k2), + }); + + // Clone sorted keys to release the borrow of map + let sorted_keys = sorted_map + .iter() + .map(|(k, _)| (*k).clone()) + .collect::>(); + + // Initialize all the values with given order and put them into a new map + let mut initailized: Map = Map::new(); + for key in sorted_keys { + let value = map.remove(&key).unwrap(); + if let OptionalInput { deps, input } = value { + let mut satisfied = true; + // Check if all the dependencies are satisfied + for (dep_key, expected) in deps { + // If the dependency is not exist or the value is not equal to the expected values + // break the loop and mark status as unsatisfied + if !initailized.get(&dep_key).is_some_and(|v| v == &expected) { + satisfied = false; + break; + } + } + // if all the dependencies are satisfied, initialize the value + if satisfied { + initailized.insert(key, input.into_primate()?.into()); + } + } else { + initailized.insert(key, value.init()?); + } + } + + Ok(Object(initailized)) + } + OptionalInput { .. } => Err(io::Error::new( + io::ErrorKind::InvalidData, + "optional input must be in an object", + )), + _ => Ok(self), + } + } + + /// Get inner value if the value is an object + pub fn as_object(&self) -> Option<&Map> { + match self { + Self::Object(v) => Some(v), + _ => None, + } + } + + /// Get value of given key + /// + /// If the value is an object and the key exists, the value will be returned. + /// Otherwise, return `None`. + pub fn get(&self, key: &str) -> Option<&Self> { + self.as_object().and_then(|map| map.get(key)) + } + + /// Get value of given key or return default value + /// + /// If the value is an object and the key exists, get the value and try to convert it to type of + /// default value. Otherwise, the default value will be returned. + pub fn get_or<'a, T>(&'a self, key: &str, default: T) -> T + where + T: TryFromMAAValue<'a, Value = T>, + { + self.get(key).and_then(T::try_from_value).unwrap_or(default) + } + + /// Insert a key-value pair into the object + /// + /// If the value is an object, the key-value pair will be inserted into the object. + /// If the key is already exist, the value will be replaced, + /// otherwise the key-value pair will be inserted. + /// + /// # Panics + /// + /// If the value is not an object, the panic will be raised. + pub fn insert(&mut self, key: impl Into, value: impl Into) { + if let Self::Object(map) = self { + map.insert(key.into(), value.into()); + } else { + panic!("value is not an object"); + } + } + + /// Get the value if the value is primate + fn as_primate(&self) -> Option<&MAAPrimate> { + match self { + Self::Primate(v) => Some(v), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + self.as_primate().and_then(MAAPrimate::as_bool) + } + + pub fn as_int(&self) -> Option { + self.as_primate().and_then(MAAPrimate::as_int) + } + + pub fn as_float(&self) -> Option { + self.as_primate().and_then(MAAPrimate::as_float) + } + + pub fn as_str(&self) -> Option<&str> { + self.as_primate().and_then(MAAPrimate::as_string) + } + + pub fn merge_mut(&mut self, other: &Self) { + match (self, other) { + (Self::Object(self_map), Self::Object(other_map)) => { + for (key, value) in other_map { + if let Some(self_value) = self_map.get_mut(key) { + self_value.merge_mut(value); + } else { + self_map.insert(key.clone(), value.clone()); + } + } + } + (s, o) => *s = o.clone(), + } + } +} + +// TODO: shortcur for OptionalInput +#[macro_export] +macro_rules! object { + () => { + MAAValue::new() + }; + ($($key:expr => $value:expr),* $(,)?) => {{ + let mut value = MAAValue::new(); + $(value.insert($key, $value);)* + value + }}; +} + +impl Default for MAAValue { + fn default() -> Self { + Self::new() + } +} + +impl, V: Into> From<[(S, V); N]> for MAAValue { + fn from(value: [(S, V); N]) -> Self { + Self::Object(Map::from(value.map(|(k, v)| (k.into(), v.into())))) + } +} + +impl> From<[T; N]> for MAAValue { + fn from(value: [T; N]) -> Self { + Self::Array(Vec::from(value.map(|v| v.into()))) + } +} + +/// Try to convert the value to given type +/// +/// If the value is not convertible to the type, None will be returned. +pub trait TryFromMAAValue<'a>: Sized { + type Value; + + fn try_from_value(value: &'a MAAValue) -> Option; +} + +impl<'a> TryFromMAAValue<'a> for bool { + type Value = bool; + + fn try_from_value(value: &MAAValue) -> Option { + value.as_bool() + } +} + +impl<'a> TryFromMAAValue<'a> for i64 { + type Value = i64; + + fn try_from_value(value: &MAAValue) -> Option { + value.as_int() + } +} + +impl<'a> TryFromMAAValue<'a> for f64 { + type Value = f64; + + fn try_from_value(value: &MAAValue) -> Option { + value.as_float() + } +} + +impl<'a> TryFromMAAValue<'a> for &str { + type Value = &'a str; + + fn try_from_value(value: &'a MAAValue) -> Option { + value.as_str() + } +} + +pub type Map = std::collections::BTreeMap; + +#[cfg(test)] +mod tests { + use super::*; + + use userinput::{BoolInput, Input, SelectD}; + + impl MAAValue { + pub fn merge(&self, other: &Self) -> Self { + let mut ret = self.clone(); + ret.merge_mut(other); + ret + } + } + + fn sstr(s: &str) -> Option { + Some(s.to_string()) + } + + #[test] + fn serde() { + use serde_test::Token; + + let obj = object!( + "array" => [1, 2], + "bool" => true, + "float" => 1.0, + "int" => 1, + "object" => [("key", "value")], + "string" => "string", + "input_bool" => BoolInput::new(Some(true), None), + "input_float" => Input::new(Some(1.0), None), + "input_int" => Input::new(Some(1), None), + "input_string" => Input::new(sstr("string"), None), + "select_int" => SelectD::new([1, 2], Some(2), None, false).unwrap(), + "select_float" => SelectD::new([1.0, 2.0], Some(2), None, false).unwrap(), + "select_string" => SelectD::::new(["string1", "string2"], Some(2), None, false).unwrap(), + "optional" => MAAValue::OptionalInput { + deps: Map::from([("input_bool".to_string(), true.into())]), + input: Input::new(Some(1), None).into(), + }, + "optional_no_satisfied" => MAAValue::OptionalInput { + deps: Map::from([("input_bool".to_string(), false.into())]), + input: Input::new(Some(1), None).into(), + }, + ); + + serde_test::assert_de_tokens( + &obj, + &[ + Token::Map { len: Some(15) }, + Token::Str("array"), + Token::Seq { len: Some(2) }, + Token::I64(1), + Token::I64(2), + Token::SeqEnd, + Token::Str("bool"), + Token::Bool(true), + Token::Str("float"), + Token::F64(1.0), + Token::Str("int"), + Token::I64(1), + Token::Str("object"), + Token::Map { len: Some(1) }, + Token::Str("key"), + Token::Str("value"), + Token::MapEnd, + Token::Str("string"), + Token::Str("string"), + Token::Str("input_bool"), + Token::Map { len: Some(1) }, + Token::Str("default"), + Token::Bool(true), + Token::MapEnd, + Token::Str("input_int"), + Token::Map { len: Some(1) }, + Token::Str("default"), + Token::I64(1), + Token::MapEnd, + Token::Str("input_float"), + Token::Map { len: Some(1) }, + Token::Str("default"), + Token::F64(1.0), + Token::MapEnd, + Token::Str("input_string"), + Token::Map { len: Some(1) }, + Token::Str("default"), + Token::Str("string"), + Token::MapEnd, + Token::Str("select_int"), + Token::Map { len: Some(2) }, + Token::Str("alternatives"), + Token::Seq { len: Some(2) }, + Token::I64(1), + Token::I64(2), + Token::SeqEnd, + Token::Str("default_index"), + Token::U64(2), + Token::MapEnd, + Token::Str("select_float"), + Token::Map { len: Some(2) }, + Token::Str("alternatives"), + Token::Seq { len: Some(2) }, + Token::F64(1.0), + Token::F64(2.0), + Token::SeqEnd, + Token::Str("default_index"), + Token::U64(2), + Token::MapEnd, + Token::Str("select_string"), + Token::Map { len: Some(2) }, + Token::Str("alternatives"), + Token::Seq { len: Some(2) }, + Token::Str("string1"), + Token::Str("string2"), + Token::SeqEnd, + Token::Str("default_index"), + Token::U64(2), + Token::MapEnd, + Token::Str("optional"), + Token::Map { len: Some(2) }, + Token::Str("deps"), + Token::Map { len: Some(1) }, + Token::Str("input_bool"), + Token::Bool(true), + Token::MapEnd, + Token::Str("default"), + Token::I64(1), + Token::MapEnd, + Token::Str("optional_no_satisfied"), + Token::Map { len: Some(2) }, + Token::Str("deps"), + Token::Map { len: Some(1) }, + Token::Str("input_bool"), + Token::Bool(false), + Token::MapEnd, + Token::Str("default"), + Token::I64(1), + Token::MapEnd, + Token::MapEnd, + ], + ); + + let obj = obj.init().unwrap(); + + serde_test::assert_ser_tokens( + &obj, + &[ + Token::Map { len: Some(14) }, + Token::Str("array"), + Token::Seq { len: Some(2) }, + Token::I64(1), + Token::I64(2), + Token::SeqEnd, + Token::Str("bool"), + Token::Bool(true), + Token::Str("float"), + Token::F64(1.0), + Token::Str("input_bool"), + Token::Bool(true), + Token::Str("input_float"), + Token::F64(1.0), + Token::Str("input_int"), + Token::I64(1), + Token::Str("input_string"), + Token::Str("string"), + Token::Str("int"), + Token::I64(1), + Token::Str("object"), + Token::Map { len: Some(1) }, + Token::Str("key"), + Token::Str("value"), + Token::MapEnd, + Token::Str("optional"), + Token::I64(1), + Token::Str("select_float"), + Token::F64(2.0), + Token::Str("select_int"), + Token::I64(2), + Token::Str("select_string"), + Token::Str("string2"), + Token::Str("string"), + Token::Str("string"), + Token::MapEnd, + ], + ); + + serde_test::assert_ser_tokens_error( + &object!( + "input_bool" => BoolInput::new(None, None), + ), + &[Token::Map { len: Some(1) }, Token::Str("input_bool")], + "cannot serialize input value, you shoule initialize it first", + ); + } + + #[test] + fn init() { + use MAAValue::OptionalInput; + + let input = BoolInput::new(Some(true), None); + let optional = OptionalInput { + deps: Map::from([("input".to_string(), true.into())]), + input: input.clone().into(), + }; + let optional_no_satisfied = OptionalInput { + deps: Map::from([("input".to_string(), false.into())]), + input: input.clone().into(), + }; + let optional_no_exist = OptionalInput { + deps: Map::from([("no_exist".to_string(), true.into())]), + input: input.clone().into(), + }; + let optional_chianed = OptionalInput { + deps: Map::from([("optional".to_string(), true.into())]), + input: input.clone().into(), + }; + + let value = object!( + "input" => input.clone(), + "array" => [1], + "primate" => 1, + "optional" => optional.clone(), + "optional_no_satisfied" => optional_no_satisfied.clone(), + "optional_no_exist" => optional_no_exist.clone(), + "optinal_chian" => optional_chianed.clone(), + ); + + assert_eq!(value.get("input").unwrap(), &MAAValue::from(input.clone())); + assert_eq!( + value.get("array").unwrap(), + &MAAValue::Array(vec![1.into()]) + ); + assert_eq!(value.get("primate").unwrap(), &MAAValue::from(1)); + assert_eq!(value.get("optional").unwrap(), &optional); + assert_eq!( + value.get("optional_no_satisfied").unwrap(), + &optional_no_satisfied + ); + assert_eq!(value.get("optional_no_exist").unwrap(), &optional_no_exist); + assert_eq!(value.get("optinal_chian").unwrap(), &optional_chianed); + + let value = value.init().unwrap(); + + assert_eq!(value.get("input").unwrap(), &MAAValue::from(true)); + assert_eq!( + value.get("array").unwrap(), + &MAAValue::Array(vec![1.into()]) + ); + assert_eq!(value.get("primate").unwrap(), &MAAValue::from(1)); + assert_eq!(value.get("optional").unwrap(), &MAAValue::from(true)); + assert_eq!(value.get("optional_no_satisfied"), None); + assert_eq!(value.get("optional_no_exist"), None); + assert_eq!(value.get("optinal_chian").unwrap(), &MAAValue::from(true)); + + assert_eq!( + optional.init().unwrap_err().kind(), + io::ErrorKind::InvalidData + ) + } + + #[test] + #[should_panic(expected = "circular dependencies")] + fn init_circular_dependencies() { + let input1 = BoolInput::new(Some(true), None); + let value = object!( + "optional1" => MAAValue::OptionalInput { + deps: Map::from([("optional2".to_string(), true.into())]), + input: input1.clone().into(), + }, + "optional2" => MAAValue::OptionalInput { + deps: Map::from([("optional1".to_string(), true.into())]), + input: input1.clone().into(), + }, + ); + + value.init().unwrap(); + } + + #[test] + fn get() { + let value = MAAValue::from([("int", 1)]); + + assert_eq!(value.get("int").unwrap().as_int().unwrap(), 1); + assert_eq!(value.get("float"), None); + assert_eq!(MAAValue::from(1).get("int"), None); + + assert_eq!(value.get_or("int", 2), 1); + assert_eq!(value.get_or("int", 2.0), 2.0); + assert_eq!(value.get_or("float", 2.0), 2.0); + } + + #[test] + fn insert() { + let mut value = MAAValue::new(); + assert_eq!(value.get("int"), None); + value.insert("int", 1); + assert_eq!(value.get("int").unwrap().as_int().unwrap(), 1); + } + + #[test] + #[should_panic(expected = "value is not an object")] + fn insert_panics() { + let mut value = MAAValue::from(1); + value.insert("int", 1); + } + + #[test] + fn try_from() { + // Bool + assert_eq!(bool::try_from_value(&true.into()), Some(true)); + assert_eq!(i64::try_from_value(&true.into()), None); + assert_eq!( + bool::try_from_value(&BoolInput::new(Some(true), None).into()), + None + ); + + // Int + assert_eq!(i64::try_from_value(&1.into()), Some(1)); + assert_eq!(f64::try_from_value(&1.into()), None); + assert_eq!(i64::try_from_value(&Input::new(Some(1), None).into()), None); + + // Float + assert_eq!(f64::try_from_value(&1.0.into()), Some(1.0)); + assert_eq!(i64::try_from_value(&1.0.into()), None); + assert_eq!( + f64::try_from_value(&Input::new(Some(1.0), None).into()), + None + ); + + // String + assert_eq!(<&str>::try_from_value(&"string".into()), Some("string")); + assert_eq!(bool::try_from_value(&"string".into()), None); + } + + #[test] + fn merge() { + let value = object!( + "bool" => true, + "int" => 1, + "float" => 1.0, + "string" => "string", + "array" => [1, 2], + "object" => [("key1", "value1"), ("key2", "value2")], + ); + + let value2 = object!( + "bool" => false, + "int" => 2, + "array" => [3, 4], + "object" => [("key2", "value2_2"), ("key3", "value3")], + ); + + assert_eq!( + value.merge(&value2), + object!( + "bool" => false, + "int" => 2, + "float" => 1.0, + "string" => "string", + "array" => [3, 4], // array will be replaced instead of merged + "object" => [("key1", "value1"), ("key2", "value2_2"), ("key3", "value3")], + ), + ); + } +} diff --git a/maa-cli/src/value/primate.rs b/maa-cli/src/value/primate.rs new file mode 100644 index 00000000..deb5ae92 --- /dev/null +++ b/maa-cli/src/value/primate.rs @@ -0,0 +1,164 @@ +use super::MAAValue; + +use serde::{Deserialize, Serialize}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum MAAPrimate { + Bool(bool), + Int(i64), + Float(f64), + String(String), +} + +impl MAAPrimate { + pub(super) fn as_bool(&self) -> Option { + match self { + Self::Bool(v) => Some(*v), + _ => None, + } + } + pub(super) fn as_int(&self) -> Option { + match self { + Self::Int(v) => Some(*v), + _ => None, + } + } + + pub(super) fn as_float(&self) -> Option { + match self { + Self::Float(v) => Some(*v), + _ => None, + } + } + + pub(super) fn as_string(&self) -> Option<&str> { + match self { + Self::String(v) => Some(v), + _ => None, + } + } +} + +impl Serialize for MAAPrimate { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Bool(v) => serializer.serialize_bool(*v), + Self::Int(v) => serializer.serialize_i64(*v), + Self::Float(v) => serializer.serialize_f64(*v), + Self::String(v) => serializer.serialize_str(v), + } + } +} + +impl PartialEq for MAAValue { + fn eq(&self, other: &MAAPrimate) -> bool { + match self { + Self::Primate(v) => v == other, + _ => false, + } + } +} + +impl From for MAAPrimate { + fn from(v: bool) -> Self { + Self::Bool(v) + } +} + +impl From for MAAPrimate { + fn from(v: i64) -> Self { + Self::Int(v) + } +} + +impl From for MAAPrimate { + fn from(v: f64) -> Self { + Self::Float(v) + } +} + +impl From for MAAPrimate { + fn from(v: String) -> Self { + Self::String(v) + } +} + +impl From<&str> for MAAPrimate { + fn from(v: &str) -> Self { + Self::String(v.to_string()) + } +} + +impl From for MAAValue { + fn from(v: MAAPrimate) -> Self { + Self::Primate(v) + } +} + +macro_rules! impl_from { + ($($t:ty),*) => { + $( + impl From<$t> for MAAValue { + fn from(v: $t) -> Self { + Self::Primate(v.into()) + } + } + )* + }; +} + +impl_from!(bool, i64, f64, String, &str); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + use serde_test::{assert_de_tokens, Token}; + + let values = vec![ + MAAPrimate::Bool(true), + MAAPrimate::Int(1), + MAAPrimate::Float(1.0), + MAAPrimate::String("".to_string()), + ]; + + assert_de_tokens( + &values, + &[ + Token::Seq { len: Some(4) }, + Token::Bool(true), + Token::I64(1), + Token::F64(1.0), + Token::Str(""), + Token::SeqEnd, + ], + ); + } + + #[test] + fn as_type() { + assert_eq!(MAAPrimate::Bool(true).as_bool(), Some(true)); + assert_eq!(MAAPrimate::Bool(true).as_int(), None); + assert_eq!(MAAPrimate::Bool(true).as_float(), None); + assert_eq!(MAAPrimate::Bool(true).as_string(), None); + + assert_eq!(MAAPrimate::Int(1).as_bool(), None); + assert_eq!(MAAPrimate::Int(1).as_int(), Some(1)); + assert_eq!(MAAPrimate::Int(1).as_float(), None); + assert_eq!(MAAPrimate::Int(1).as_string(), None); + + assert_eq!(MAAPrimate::Float(1.0).as_bool(), None); + assert_eq!(MAAPrimate::Float(1.0).as_int(), None); + assert_eq!(MAAPrimate::Float(1.0).as_float(), Some(1.0)); + assert_eq!(MAAPrimate::Float(1.0).as_string(), None); + + assert_eq!(MAAPrimate::String("".to_string()).as_bool(), None); + assert_eq!(MAAPrimate::String("".to_string()).as_int(), None); + assert_eq!(MAAPrimate::String("".to_string()).as_float(), None); + assert_eq!(MAAPrimate::String("".to_string()).as_string(), Some("")); + } +} diff --git a/maa-cli/src/value/userinput/bool_input.rs b/maa-cli/src/value/userinput/bool_input.rs new file mode 100644 index 00000000..11685e8f --- /dev/null +++ b/maa-cli/src/value/userinput/bool_input.rs @@ -0,0 +1,226 @@ +use super::UserInput; + +use std::io::{self, Write}; + +use serde::Deserialize; + +/// A struct that represents a user input that queries the user for boolean input. +#[cfg_attr(test, derive(PartialEq))] +#[derive(Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct BoolInput { + /// Default value for this parameter. + default: Option, + /// Description of this parameter + description: Option, +} + +impl BoolInput { + pub fn new(default: Option, description: Option<&str>) -> Self { + Self { + default, + description: description.map(|s| s.to_string()), + } + } +} + +impl UserInput for BoolInput { + type Value = bool; + + fn default(self) -> Result { + match self.default { + Some(v) => Ok(v), + None => Err(self), + } + } + + fn prompt(&self, writer: &mut impl Write) -> Result<(), io::Error> { + write!(writer, "Whether to")?; + if let Some(description) = &self.description { + write!(writer, " {}", description)?; + } else { + write!(writer, " do something")?; + } + if let Some(default) = &self.default { + if *default { + write!(writer, " [Y/n]")?; + } else { + write!(writer, " [y/N]")?; + } + } else { + write!(writer, " [y/n]")?; + } + Ok(()) + } + + fn prompt_no_default(&self, writer: &mut impl Write) -> Result<(), io::Error> { + write!(writer, "Default value not set, please input y/n") + } + + fn parse( + self, + trimmed: &str, + writer: &mut impl Write, + ) -> Result> { + match trimmed { + "y" | "Y" | "yes" | "Yes" | "YES" => Ok(true), + "n" | "N" | "no" | "No" | "NO" => Ok(false), + _ => { + err_err!(writer.write_all(b"Invalid input, please input y/n")); + Err(Ok(self)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::assert_matches; + + use serde_test::{assert_de_tokens, Token}; + + #[test] + fn serde() { + let values = vec![ + BoolInput::new(Some(true), Some("do something")), + BoolInput::new(Some(false), None), + BoolInput::new(None, Some("do something")), + BoolInput::new(None, None), + ]; + + assert_de_tokens( + &values, + &[ + Token::Seq { len: Some(4) }, + Token::Map { len: Some(2) }, + Token::Str("default"), + Token::Some, + Token::Bool(true), + Token::Str("description"), + Token::Some, + Token::Str("do something"), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::Str("default"), + Token::Some, + Token::Bool(false), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::Str("description"), + Token::Some, + Token::Str("do something"), + Token::MapEnd, + Token::Map { len: Some(0) }, + Token::MapEnd, + Token::SeqEnd, + ], + ); + } + + #[test] + fn construct() { + assert_matches!( + BoolInput::new(Some(true), Some("do something")), + BoolInput { + default: Some(true), + description: Some(description), + } if description == "do something" + ); + + assert_matches!( + BoolInput::new(Some(true), None), + BoolInput { + default: Some(true), + description: None, + } + ); + + assert_matches!( + BoolInput::new(None, Some("do something")), + BoolInput { + default: None, + description: Some(description), + } if description == "do something" + ); + + assert_matches!( + BoolInput::new(None, None), + BoolInput { + default: None, + description: None, + } + ); + } + + #[test] + fn default() { + assert_eq!(BoolInput::new(Some(true), None).default(), Ok(true)); + + assert_eq!( + BoolInput::new(None, None).default(), + Err(BoolInput::new(None, None)) + ); + } + + #[test] + fn prompt() { + let mut buffer = Vec::new(); + + BoolInput::new(Some(true), None) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Whether to do something [Y/n]"); + buffer.clear(); + + BoolInput::new(Some(true), Some("do other thing")) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Whether to do other thing [Y/n]"); + buffer.clear(); + + BoolInput::new(None, Some("do other thing")) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Whether to do other thing [y/n]"); + buffer.clear(); + } + + #[test] + fn prompt_no_default() { + let mut buffer = Vec::new(); + + BoolInput::new(None, None) + .prompt_no_default(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Default value not set, please input y/n"); + } + + #[test] + fn parse() { + let bool_input = BoolInput::new(None, None); + let mut output = Vec::new(); + for input in &["y", "Y", "yes", "Yes", "YES"] { + assert!(bool_input.clone().parse(input, &mut output).unwrap()) + } + + for input in &["n", "N", "no", "No", "NO"] { + assert!(!bool_input.clone().parse(input, &mut output).unwrap()) + } + + assert_eq!( + bool_input + .clone() + .parse("invalid", &mut output) + .unwrap_err() + .unwrap(), + bool_input.clone() + ); + + assert_eq!( + String::from_utf8(output).unwrap(), + "Invalid input, please input y/n", + ); + } +} diff --git a/maa-cli/src/value/userinput/input.rs b/maa-cli/src/value/userinput/input.rs new file mode 100644 index 00000000..bcccfe9d --- /dev/null +++ b/maa-cli/src/value/userinput/input.rs @@ -0,0 +1,230 @@ +use super::UserInput; + +use std::{ + fmt::Display, + io::{self, Write}, + str::FromStr, +}; + +use serde::Deserialize; + +#[cfg_attr(test, derive(PartialEq))] +#[derive(Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +/// A generic struct that represents a user input that queries the user for input. +/// +/// For example, `Input::::new(Some(0), Some("medicine to use"))` represents a user input +/// that queries the user for an integer input, with default value 0 and description "medicine to +/// use". +/// +/// If you want to query a boolean input, use [`BoolInput`]. +pub struct Input { + /// Default value for this parameter. + default: Option, + /// Description of this parameter + description: Option, +} + +impl Input { + pub fn new(default: Option, description: Option<&str>) -> Self { + Self { + default, + description: description.map(|s| s.to_string()), + } + } +} + +impl UserInput for Input { + type Value = F; + + fn default(self) -> Result { + match self.default { + Some(v) => Ok(v), + None => Err(self), + } + } + + fn prompt(&self, writer: &mut impl Write) -> io::Result<()> { + write!(writer, "Please input")?; + if let Some(description) = self.description.as_deref() { + write!(writer, " {}", description)?; + } else { + write!(writer, " a {}", std::any::type_name::())?; + } + if let Some(default) = &self.default { + write!(writer, " [default: {}]", default)?; + } + Ok(()) + } + + fn prompt_no_default(&self, writer: &mut impl Write) -> io::Result<()> { + write!(writer, "Default value not set, please input")?; + if let Some(description) = self.description.as_deref() { + write!(writer, " {}", description)?; + } else { + write!(writer, " a {}", std::any::type_name::())?; + } + Ok(()) + } + + fn parse(self, input: &str, writer: &mut impl Write) -> Result> { + if let Ok(value) = input.parse() { + Ok(value) + } else { + err_err!(write!( + writer, + "Invalid input \"{}\", please try again", + input + )); + Err(Ok(self)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::assert_matches; + + use serde_test::{assert_de_tokens, Token}; + + #[test] + fn serde() { + let values: Vec> = vec![ + Input::new(Some(0), Some("how many medicine to use")), + Input::new(Some(0), None), + Input::new(None, Some("how many medicine to use")), + Input::new(None, None), + ]; + + assert_de_tokens( + &values, + &[ + Token::Seq { len: Some(4) }, + Token::Map { len: Some(2) }, + Token::Str("default"), + Token::Some, + Token::I64(0), + Token::Str("description"), + Token::Some, + Token::Str("how many medicine to use"), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::Str("default"), + Token::Some, + Token::I64(0), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::Str("description"), + Token::Some, + Token::Str("how many medicine to use"), + Token::MapEnd, + Token::Map { len: Some(0) }, + Token::MapEnd, + Token::SeqEnd, + ], + ); + } + + #[test] + fn construct() { + assert_matches!( + Input::new(Some(0), Some("medicine to use")), + Input:: { + default: Some(0), + description: Some(s) + } if s == "medicine to use", + ); + assert_matches!( + Input::::new(None::, Some("medicine to use")), + Input:: { + default: None, + description: Some(s) + } if s == "medicine to use", + ); + assert_matches!( + Input::::new(Some(0), None::<&str>), + Input:: { + default: Some(0), + description: None, + }, + ); + assert_matches!( + Input::::new(None::, None::<&str>), + Input:: { + default: None, + description: None, + }, + ); + } + + #[test] + fn default() { + assert_eq!(Input::new(Some(0), None).default(), Ok(0)); + assert_eq!( + Input::new(None::, None).default(), + Err(Input::new(None, None)) + ); + } + + #[test] + fn prompt() { + let mut buffer = Vec::new(); + + Input::::new(Some(0), Some("medicine to use")) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Please input medicine to use [default: 0]"); + buffer.clear(); + + Input::::new(None::, Some("medicine to use")) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Please input medicine to use"); + buffer.clear(); + + Input::::new(Some(0), None::<&str>) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Please input a i64 [default: 0]"); + buffer.clear(); + + Input::::new(None::, None::<&str>) + .prompt(&mut buffer) + .unwrap(); + assert_eq!(buffer, b"Please input a i64"); + buffer.clear(); + } + + #[test] + fn prompt_no_default() { + let mut buffer = Vec::new(); + + Input::::new(Some(0), Some("medicine to use")) + .prompt_no_default(&mut buffer) + .unwrap(); + assert_eq!( + buffer, + b"Default value not set, please input medicine to use" + ); + buffer.clear(); + } + + #[test] + fn parse() { + let input = Input::new(Some(0), None); + + let mut output = Vec::new(); + + assert_eq!(input.clone().parse("1", &mut output).unwrap(), 1); + assert_eq!( + input.clone().parse("a", &mut output).unwrap_err().unwrap(), + input.clone() + ); + assert_eq!( + String::from_utf8(output).unwrap(), + "Invalid input \"a\", please try again", + ); + } +} diff --git a/maa-cli/src/value/userinput/mod.rs b/maa-cli/src/value/userinput/mod.rs new file mode 100644 index 00000000..6b8dd2dd --- /dev/null +++ b/maa-cli/src/value/userinput/mod.rs @@ -0,0 +1,216 @@ +use std::{ + io::{self, BufRead, Write}, + sync::atomic::{AtomicBool, Ordering}, +}; + +// Use batch mode in tests by default to avoid blocking tests. +// This variable can also be change at runtime by cli argument +static BATCH_MODE: AtomicBool = AtomicBool::new(cfg!(test)); + +pub fn enable_batch_mode() { + BATCH_MODE.store(true, Ordering::Relaxed); +} + +fn is_batch_mode() -> bool { + BATCH_MODE.load(Ordering::Relaxed) +} + +pub trait UserInput: Sized { + type Value: Sized; + + /// Get the value of this parameter from user input. + /// + /// If in batch mode, try to get the default value by calling `batch_default`. + /// If not in batch mode, prompt user to input a value by calling `ask`, + /// and return the value returned by `ask`. + /// + /// Errors: + /// + /// - If in batch mode and `batch_default` returns `None`, return an io::Error with kind other. + /// - If not in batch mode and `ask` returns an io::Error, return the error. + fn value(self) -> io::Result { + if is_batch_mode() { + self.batch_default().map_err(|_| { + io::Error::new( + io::ErrorKind::Other, + "can not get default value in batch mode", + ) + }) + } else { + self.ask(&mut std::io::stdout(), &mut std::io::stdin().lock()) + } + } + + /// Get the default value when user input is empty. + /// + /// If there is a default value, return it. + /// If there is no default value, give back the ownership of self. + fn default(self) -> Result; + + /// The default value used in batch mode + /// + /// Fall back to `default` if not implemented. + fn batch_default(self) -> Result { + self.default() + } + + /// Prompt user to input a value for this parameter and return the value when success. + fn ask(self, writer: &mut impl Write, reader: &mut impl BufRead) -> io::Result { + self.prompt(writer)?; + writer.write_all(b": ")?; + writer.flush()?; + let mut input = String::new(); + let mut self_mut = self; + loop { + reader.read_line(&mut input)?; + let trimmed = input.trim(); + if trimmed.is_empty() { + match self_mut.default() { + Ok(value) => break Ok(value), + Err(self_) => { + self_mut = self_; + self_mut.prompt_no_default(writer)?; + writer.write_all(b": ")?; + writer.flush()?; + } + }; + } else { + match self_mut.parse(trimmed, writer) { + Ok(value) => break Ok(value), + Err(err) => match err { + Err(err) => break Err(err), + Ok(self_) => { + self_mut = self_; + writer.write_all(b": ")?; + writer.flush()?; + } + }, + }; + } + input.clear(); + } + } + + /// Prompt user to input a value for this parameter. + /// + /// Don't flush the writer after writing to it. + /// The caller will flush it when necessary. + fn prompt(&self, writer: &mut impl Write) -> io::Result<()>; + + /// Prompt user to re-input a value for this parameter when the input is empty and default value is not set. + /// + /// Don't flush the writer after writing to it. + /// The caller will flush it when necessary. + fn prompt_no_default(&self, writer: &mut impl Write) -> io::Result<()>; + + /// Function to parse a string to the value of this parameter. + /// If the input is invalid, give back the ownership and write an error message to the writer. + /// self should be returned in `Err(Ok(self))`, + /// while if write failed, the error should be returned in `Err(Error(err))`. + fn parse(self, input: &str, writer: &mut impl Write) -> Result>; +} + +macro_rules! err_err { + ($err:expr) => { + if let Err(err) = $err { + return Err(Err(err.into())); + } + }; +} + +mod bool_input; +pub use bool_input::BoolInput; + +mod input; +pub use input::Input; + +mod select; +pub use select::{Select, SelectD, Selectable, ValueWithDesc}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get() { + assert!(BoolInput::new(Some(true), Some("")).value().unwrap()); + assert_eq!( + BoolInput::new(None, Some("")).value().unwrap_err().kind(), + io::ErrorKind::Other + ); + + assert_eq!(Input::::new(Some(1), Some("")).value().unwrap(), 1); + assert_eq!( + Input::::new(None::, Some("")) + .value() + .unwrap_err() + .kind(), + io::ErrorKind::Other + ); + + assert_eq!( + SelectD::::new([1, 2], Some(2), Some(""), true) + .unwrap() + .value() + .unwrap(), + 2 + ); + assert_eq!( + SelectD::::new([1, 2], None, Some(""), true) + .unwrap() + .value() + .unwrap(), + 1 + ); + } + + #[test] + fn ask() { + macro_rules! input { + ($str:expr) => { + &mut io::BufReader::new($str.as_bytes()) + }; + } + + let mut output = Vec::new(); + + // Test good input + let bool_input = BoolInput::new(Some(true), Some("hello")); + assert!(bool_input.clone().ask(&mut output, input!("\n")).unwrap()); + assert!(bool_input.clone().ask(&mut output, input!(" \n")).unwrap()); + assert!(bool_input.clone().ask(&mut output, input!("y\n")).unwrap()); + assert!(bool_input.clone().ask(&mut output, input!("y \n")).unwrap()); + assert!(!bool_input.clone().ask(&mut output, input!("n\n")).unwrap()); + assert!(!bool_input.clone().ask(&mut output, input!("n \n")).unwrap()); + + assert_eq!( + String::from_utf8(output).unwrap(), + "Whether to hello [Y/n]: Whether to hello [Y/n]: Whether to hello [Y/n]: \ + Whether to hello [Y/n]: Whether to hello [Y/n]: Whether to hello [Y/n]: " + ); + + // Test empty input when default is not set + let mut output = Vec::new(); + let bool_input = BoolInput::new(None, Some("hello")); + assert!(bool_input + .clone() + .ask(&mut output, input!("\ny\n")) + .unwrap()); + assert_eq!( + String::from_utf8(output).unwrap(), + "Whether to hello [y/n]: Default value not set, please input y/n: " + ); + + // Test invalid input + let mut output = Vec::new(); + assert!(bool_input + .clone() + .ask(&mut output, input!("invalid\ny\n")) + .unwrap()); + + assert_eq!( + String::from_utf8(output).unwrap(), + "Whether to hello [y/n]: Invalid input, please input y/n: " + ); + } +} diff --git a/maa-cli/src/value/userinput/select.rs b/maa-cli/src/value/userinput/select.rs new file mode 100644 index 00000000..fb2c1a2c --- /dev/null +++ b/maa-cli/src/value/userinput/select.rs @@ -0,0 +1,572 @@ +use super::UserInput; + +use std::{ + convert::Infallible, + fmt::Display, + io::{self, Write}, + str::FromStr, +}; + +use anyhow::bail; +use serde::Deserialize; + +#[cfg_attr(test, derive(PartialEq))] +#[derive(Debug, Clone)] +pub struct Select { + /// Alternatives for this parameter + alternatives: Vec, + /// The index of the default value + default_index: Option, + /// Description of this parameter + description: Option, + /// Allow custom input + allow_custom: bool, +} + +impl<'de, S: Deserialize<'de>> Deserialize<'de> for Select { + fn deserialize(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct SelectHelper { + #[serde(default = "Vec::new")] + alternatives: Vec, + #[serde(default)] + default_index: Option, + #[serde(default)] + description: Option, + #[serde(default)] + allow_custom: bool, + } + + let helper = SelectHelper::::deserialize(deserializer)?; + + Select::raw_new( + helper.alternatives, + helper.default_index, + helper.description, + helper.allow_custom, + ) + .map_err(serde::de::Error::custom) + } +} + +impl Select { + /// Create a new Select + /// + /// # Arguments + /// + /// * `alternatives` - A list of alternatives for this parameter; + /// * `default_index` - The 1-based index of the default value; + /// * `description` - Description of this parameter, default to "one of the alternatives"; + /// * `allow_custom` - Allow custom input, if set to true and input is not a number, try to parse it; + /// + /// # Examples + /// + /// ``` + /// use crate::config::task::value::input::Select; + /// + /// let select = Select::::new( + /// vec!["CE-5", "CE-6"], + /// Some(2), + /// Some("a stage to fight"), + /// true, + /// ); + /// ``` + /// + /// User will be prompt with: + /// + /// ```text + /// 1. CE-5 + /// 2. CE-6 (default) + /// Please select a stage to fight or input a custom one: + /// ``` + /// + /// If user input an empty string, it will be return the default value `CE-6`. + /// If user input a number in range like `1`, it will be return the first alternative `CE-5`. + /// If user input a custom value like `CE-4`, it will be return the custom value `CE-4`. + /// + /// # Errors + /// + /// - `alternatives` is empty; + /// - `default_index` is out of range; + pub fn new( + alternatives: Iter, + default_index: Option, + description: Option<&str>, + allow_custom: bool, + ) -> anyhow::Result + where + Item: Into, + Iter: IntoIterator, + { + Self::raw_new( + alternatives.into_iter().map(Into::into).collect(), + default_index, + description.map(|s| s.into()), + allow_custom, + ) + } + + fn raw_new( + alternatives: Vec, + mut default_index: Option, + description: Option, + allow_custom: bool, + ) -> anyhow::Result { + if alternatives.is_empty() { + bail!("alternatives is empty"); + } + + if let Some(ref mut default_index) = default_index { + if *default_index > alternatives.len() || *default_index < 1 { + bail!("default_index out of range (1 - {})", alternatives.len()); + } + *default_index -= 1; + } + + Ok(Self { + alternatives, + default_index, + description, + allow_custom, + }) + } +} + +impl UserInput for Select +where + S: Selectable + Display, +{ + type Value = S::Value; + + fn default(mut self) -> Result { + self.default_index + .map(|i| self.alternatives.swap_remove(i).value()) + .ok_or(self) + } + + /// Get the first alternative as default value if default_index is not set. + fn batch_default(mut self) -> Result { + Ok(self + .alternatives + .swap_remove(self.default_index.unwrap_or(0)) + .value()) + } + + fn prompt(&self, writer: &mut impl Write) -> io::Result<()> { + for (i, alternative) in self.alternatives.iter().enumerate() { + write!(writer, "{}. {}", i + 1, alternative)?; + if self.default_index.is_some_and(|d| d == i) { + writeln!(writer, " [default]")?; + } else { + writeln!(writer)?; + } + } + write!(writer, "Please select")?; + if let Some(description) = &self.description { + write!(writer, " {}", description)?; + } else { + write!(writer, " one of the alternatives")?; + } + if self.allow_custom { + write!(writer, " or input a custom value")?; + } + if self.default_index.is_some() { + write!(writer, " (empty for default)")?; + } + + Ok(()) + } + + fn prompt_no_default(&self, writer: &mut impl Write) -> io::Result<()> { + write!(writer, "Default not set, please select")?; + if let Some(description) = &self.description { + write!(writer, " {}", description)?; + } else { + write!(writer, " one of the alternatives")?; + } + if self.allow_custom { + write!(writer, " or input a custom value")?; + } + + Ok(()) + } + + fn parse( + mut self, + input: &str, + writer: &mut impl Write, + ) -> Result> { + let len = self.alternatives.len(); + match input.parse::() { + Ok(index) => { + if index > len || index < 1 { + err_err!(write!( + writer, + "Index {} out of range, please try again (1 - {})", + index, len + )); + + Err(Ok(self)) + } else { + Ok(self + .alternatives + .swap_remove(index.saturating_sub(1)) + .value()) + } + } + Err(_) if self.allow_custom => match S::parse(input) { + Ok(value) => Ok(value), + Err(_) => { + err_err!(write!( + writer, + "Invalid input \"{}\", please input an index number (1 - {}) or a custom value", + input, len + )); + + Err(Ok(self)) + } + }, + Err(_) => { + err_err!(write!( + writer, + "Invalid index \"{}\", please input an index number (1 - {})", + input, len + )); + + Err(Ok(self)) + } + } + } +} + +pub trait Selectable { + type Value; + type Error; + + /// Get the value of this element, consum self. + fn value(self) -> Self::Value; + + /// Parse a string to value of this element. + /// + /// This function parse a string to value of this element + /// instead of the element itself to allow custom input. + fn parse(input: &str) -> Result; +} + +#[cfg_attr(test, derive(PartialEq, Debug))] +#[derive(Deserialize, Clone)] +#[serde(untagged, deny_unknown_fields)] +pub enum ValueWithDesc { + Value(T), + WithDesc { value: T, desc: String }, +} + +impl ValueWithDesc { + pub fn new(value: impl Into, desc: Option<&str>) -> Self { + match desc { + Some(desc) => Self::WithDesc { + value: value.into(), + desc: desc.to_string(), + }, + None => Self::Value(value.into()), + } + } + + fn value(self) -> T { + use ValueWithDesc::*; + match self { + Value(value) => value, + WithDesc { value, .. } => value, + } + } +} + +impl From for ValueWithDesc { + fn from(value: T) -> Self { + ValueWithDesc::Value(value) + } +} + +impl From<&str> for ValueWithDesc { + fn from(value: &str) -> Self { + Self::from(String::from(value)) + } +} + +impl Selectable for ValueWithDesc { + type Value = i64; + type Error = ::Err; + + fn value(self) -> i64 { + self.value() + } + + fn parse(input: &str) -> Result { + input.parse() + } +} + +impl Display for ValueWithDesc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValueWithDesc::Value(value) => write!(f, "{value}"), + ValueWithDesc::WithDesc { value, desc } => write!(f, "{value} ({desc})"), + } + } +} + +impl Selectable for ValueWithDesc { + type Value = f64; + type Error = ::Err; + + fn value(self) -> f64 { + self.value() + } + + fn parse(input: &str) -> Result { + input.parse() + } +} + +impl Selectable for ValueWithDesc { + type Value = String; + type Error = Infallible; + + fn value(self) -> String { + self.value() + } + + fn parse(input: &str) -> Result { + Ok(input.to_owned()) + } +} + +/// A type alias for `Select>`. +/// +/// The `SelectD` type is a `Select` with optional description for each alternative. +/// Value of `SelectD` is the same as `Select`. +pub type SelectD = Select>; + +#[cfg(test)] +mod tests { + use super::*; + + use crate::assert_matches; + + use serde_test::{assert_de_tokens, Token}; + + // Use this function to get a Select with most fields set to Some. + fn test_full() -> SelectD { + SelectD::::new( + vec![ + ValueWithDesc::new("CE-5", Some("LMB stage 5")), + ValueWithDesc::new("CE-6", Some("LMB stage 6")), + ], + Some(2), + Some("a stage to fight"), + true, + ) + .unwrap() + } + + // Use this function to get a Select with most fields set to None. + fn test_none() -> SelectD { + SelectD::::new(vec!["CE-5", "CE-6"], None, None, false).unwrap() + } + + #[test] + fn serde() { + let values = [test_full(), test_none()]; + + assert_de_tokens( + &values, + &[ + Token::Seq { len: Some(2) }, + Token::Map { len: Some(4) }, + Token::Str("alternatives"), + Token::Seq { len: Some(2) }, + Token::Map { len: Some(2) }, + Token::Str("value"), + Token::Str("CE-5"), + Token::Str("desc"), + Token::Str("LMB stage 5"), + Token::MapEnd, + Token::Map { len: Some(2) }, + Token::Str("value"), + Token::Str("CE-6"), + Token::Str("desc"), + Token::Str("LMB stage 6"), + Token::MapEnd, + Token::SeqEnd, + Token::Str("default_index"), + Token::Some, + Token::U64(2), + Token::Str("description"), + Token::Some, + Token::Str("a stage to fight"), + Token::Str("allow_custom"), + Token::Bool(true), + Token::MapEnd, + Token::Map { len: Some(1) }, + Token::Str("alternatives"), + Token::Seq { len: Some(2) }, + Token::Str("CE-5"), + Token::Str("CE-6"), + Token::SeqEnd, + Token::MapEnd, + Token::SeqEnd, + ], + ); + } + + #[test] + fn construct() { + assert_matches!( + test_full(), + SelectD { + alternatives, + default_index: Some(1), + description: Some(description), + allow_custom: true, + } if alternatives == [ + ValueWithDesc::new("CE-5", Some("LMB stage 5")), + ValueWithDesc::new("CE-6", Some("LMB stage 6")), + ] && description == "a stage to fight" + ); + + assert_matches!( + test_none(), + SelectD { + alternatives, + default_index: None, + description: None, + allow_custom: false, + } if alternatives == vec!["CE-5", "CE-6"].into_iter().map(|s| s.into()).collect::>() + ); + + assert_eq!( + SelectD::::new::<&str, [_; 0]>([], None, None, false) + .unwrap_err() + .to_string(), + "alternatives is empty" + ); + + assert_eq!( + SelectD::::new(["CE-5", "CE-6"], Some(3), None, false) + .unwrap_err() + .to_string(), + "default_index out of range (1 - 2)" + ) + } + + #[test] + fn default() { + assert_eq!(test_full().default().unwrap(), "CE-6"); + assert_eq!(test_none().default().unwrap_err(), test_none()); + } + + #[test] + fn batch_default() { + assert_eq!(test_full().batch_default().unwrap(), "CE-6"); + assert_eq!(test_none().batch_default().unwrap(), "CE-5"); + } + + #[test] + fn prompt() { + let mut buffer = Vec::new(); + test_full().prompt(&mut buffer).unwrap(); + assert_eq!( + String::from_utf8(buffer).unwrap(), + "1. CE-5 (LMB stage 5)\n\ + 2. CE-6 (LMB stage 6) [default]\n\ + Please select a stage to fight or input a custom value (empty for default)" + ); + + let mut buffer = Vec::new(); + test_none().prompt(&mut buffer).unwrap(); + assert_eq!( + String::from_utf8(buffer).unwrap(), + "1. CE-5\n\ + 2. CE-6\n\ + Please select one of the alternatives" + ); + } + + #[test] + fn prompt_no_default() { + let mut buffer = Vec::new(); + test_full().prompt_no_default(&mut buffer).unwrap(); + assert_eq!( + String::from_utf8(buffer).unwrap(), + "Default not set, please select a stage to fight or input a custom value" + ); + + let mut buffer = Vec::new(); + test_none().prompt_no_default(&mut buffer).unwrap(); + assert_eq!( + String::from_utf8(buffer).unwrap(), + "Default not set, please select one of the alternatives" + ); + } + + #[test] + fn parse() { + let select = SelectD::new([1.0, 3.0], Some(2), None, true).unwrap(); + + let mut output = Vec::new(); + assert_eq!(select.clone().parse("1", &mut output).unwrap(), 1.0); + assert_eq!(select.clone().parse("2.0", &mut output).unwrap(), 2.0); + assert_eq!( + select.clone().parse("3", &mut output).unwrap_err().unwrap(), + select + ); + assert_eq!( + select.clone().parse("x", &mut output).unwrap_err().unwrap(), + select + ); + + let select = SelectD::new([1.0, 3.0], Some(2), None, false).unwrap(); + assert_eq!( + select.clone().parse("x", &mut output).unwrap_err().unwrap(), + select.clone() + ); + + assert_eq!( + String::from_utf8(output).unwrap(), + "Index 3 out of range, please try again (1 - 2)\ + Invalid input \"x\", please input an index number (1 - 2) or a custom value\ + Invalid index \"x\", please input an index number (1 - 2)" + ); + } + + mod selectable { + use super::*; + + #[test] + fn int() { + let value = ValueWithDesc::::new(1, None); + assert_eq!(value.value(), 1); + assert_eq!(ValueWithDesc::::parse("1").unwrap(), 1); + assert!(ValueWithDesc::::parse("a").is_err()) + } + + #[test] + fn float() { + let value = ValueWithDesc::::new(1.0, None); + assert_eq!(value.value(), 1.0); + assert_eq!(ValueWithDesc::::parse("1.0").unwrap(), 1.0); + assert!(ValueWithDesc::::parse("a").is_err()) + } + + #[test] + fn string() { + let value = ValueWithDesc::::new("a", None); + assert_eq!(value.value(), "a"); + assert_eq!(ValueWithDesc::::parse("a").unwrap(), "a"); + } + } +}