From 8bdfaa9a32b0fc5dad29252c4dc6ba37ebeebc0a Mon Sep 17 00:00:00 2001 From: nan-mu Date: Thu, 9 Jul 2026 02:53:14 +0800 Subject: [PATCH 01/20] feat: add GUI profile to maa-cli task config conversion Introduce gui::convert for TaskQueue profiles with StartUp and Fight mappings, plus a default GUI profile fixture for regression tests. Co-authored-by: Cursor --- .../maa-cli/fixtures/gui/default_profile.json | 261 ++++++++ crates/maa-cli/src/config/gui.rs | 592 ++++++++++++++++++ crates/maa-cli/src/config/mod.rs | 5 + 3 files changed, 858 insertions(+) create mode 100644 crates/maa-cli/fixtures/gui/default_profile.json create mode 100644 crates/maa-cli/src/config/gui.rs diff --git a/crates/maa-cli/fixtures/gui/default_profile.json b/crates/maa-cli/fixtures/gui/default_profile.json new file mode 100644 index 00000000..8799308d --- /dev/null +++ b/crates/maa-cli/fixtures/gui/default_profile.json @@ -0,0 +1,261 @@ +{ + "InfrastOrder": {}, + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "LS-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" + }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, + { + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/07/05 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": false, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" + }, + { + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" + }, + { + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" + } + ], + "TaskSelectedIndex": -1, + "DragItemIsChecked": {} +} diff --git a/crates/maa-cli/src/config/gui.rs b/crates/maa-cli/src/config/gui.rs new file mode 100644 index 00000000..e208bc4a --- /dev/null +++ b/crates/maa-cli/src/config/gui.rs @@ -0,0 +1,592 @@ +use anyhow::{Context, Result, bail}; +use maa_value::prelude::*; + +/// Convert a GUI profile `MAAValue` into maa-cli task config shape. +pub(crate) fn convert(input: MAAValue) -> Result { + // GUI profile task list + let queue = input + .get("TaskQueue") + .context("GUI profile missing TaskQueue")?; + let MAAValue::Array(queue) = queue else { + bail!("GUI profile TaskQueue must be an array"); + }; + + let mut tasks = Vec::new(); + for task in queue { + if let Some(item) = convert_task(task)? { + tasks.push(item); + } + } + + Ok(object!("tasks" => tasks??)) +} + +fn convert_task(task: &MAAValue) -> Result> { + // GUI task discriminator, e.g. "FightTask" + let type_tag = task + .get("$type") + .and_then(|v| v.as_str()) + .context("GUI task missing $type")?; + match type_tag { + "StartUpTask" => StartUp::convert_start_up_task(task), + "FightTask" => Fight::convert_fight_task(task), + _ => Ok(None), + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::{fs::File, path::Path}; + + use super::*; + + const DEFAULT_PROFILE: &str = include_str!("../../fixtures/gui/default_profile.json"); + + fn write_json(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + fn read_json(path: &Path) -> serde_json::Value { + serde_json::from_reader(File::open(path).unwrap()).unwrap() + } + + #[test] + fn missing_task_queue_is_error() { + let input = object!("a" => 1); + let err = convert(input).unwrap_err(); + assert!(err.to_string().contains("TaskQueue")); + } + + #[test] + fn task_queue_not_array_is_error() { + let input = object!("TaskQueue" => "not-an-array"); + let err = convert(input).unwrap_err(); + assert!(err.to_string().contains("TaskQueue must be an array")); + } + + #[test] + fn json_to_json_default_profile() { + let dir = tempfile::tempdir().unwrap(); + let input = write_json(dir.path(), "profile.json", DEFAULT_PROFILE); + let output = dir.path().join("tasks.json"); + + let input: MAAValue = serde_json::from_reader(File::open(&input).unwrap()).unwrap(); + let value = convert(input).unwrap(); + let file = File::create(&output).unwrap(); + serde_json::to_writer_pretty(file, &value).unwrap(); + + assert_eq!( + read_json(&output), + serde_json::json!({ + "tasks": [ + { + "type": "StartUp", + "name": "", + "params": { + "start_game_enabled": true, + "account_name": "" + } + }, + { + "type": "Fight", + "name": "日常经验本", + "strategy": "merge", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Mon", "Wed", "Fri"] + }, + "params": { + "medicine_expire_days": 2, + "stage": "LS-6" + } + } + ] + }, + { + "type": "Fight", + "name": "日常龙门币", + "strategy": "merge", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat"] + }, + "params": { + "medicine_expire_days": 2, + "stage": "CE-6" + } + } + ] + } + ] + }) + ); + } +} + +mod StartUp { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_start_up_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI StartUpTask is disabled but will still be converted"); + } + + let mut params = object!("start_game_enabled" => true); + // -> account_name + if let Some(MAAValue::Primitive(MAAPrimitive::String(account))) = + task.get("AccountName") + { + insert!(params, "account_name" => account.as_str()); + } + + let mut item = object!( + "type" => "StartUp", + "params" => params + ); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + Ok(Some(item)) + } + + #[cfg(test)] + #[cfg_attr(coverage_nightly, coverage(off))] + mod tests { + use std::{fs::File, path::Path}; + + use super::*; + + const STARTUP_TASK: &str = r#"{ + "$type": "StartUpTask", + "AccountName": "", + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" +}"#; + + const STARTUP_TASK_WITH_ACCOUNT: &str = r#"{ + "$type": "StartUpTask", + "AccountName": "123****4567", + "Name": "启动游戏", + "IsEnable": true, + "TaskType": "StartUp" +}"#; + + fn write_task_to_file(dir: &Path, name: &str, task_json: &str) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, task_json).unwrap(); + path + } + + fn read_task_from_file(path: &Path) -> MAAValue { + serde_json::from_reader(File::open(path).unwrap()).unwrap() + } + + fn write_output_json(output: &Path, value: &MAAValue) { + let file = File::create(output).unwrap(); + serde_json::to_writer_pretty(file, value).unwrap(); + } + + fn read_json_file(path: &Path) -> serde_json::Value { + serde_json::from_reader(File::open(path).unwrap()).unwrap() + } + + #[test] + fn basic() { + let dir = tempfile::tempdir().unwrap(); + let input = write_task_to_file(dir.path(), "startup.json", STARTUP_TASK); + let output = dir.path().join("task.json"); + + let task = read_task_from_file(&input); + let value = super::convert_start_up_task(&task).unwrap().unwrap(); + write_output_json(&output, &value); + + assert_eq!( + read_json_file(&output), + serde_json::json!({ + "type": "StartUp", + "name": "", + "params": { + "start_game_enabled": true, + "account_name": "" + } + }) + ); + } + + #[test] + fn with_account_and_name() { + let dir = tempfile::tempdir().unwrap(); + let input = write_task_to_file(dir.path(), "startup.json", STARTUP_TASK_WITH_ACCOUNT); + let output = dir.path().join("task.json"); + + let task = read_task_from_file(&input); + let value = super::convert_start_up_task(&task).unwrap().unwrap(); + write_output_json(&output, &value); + + assert_eq!( + read_json_file(&output), + serde_json::json!({ + "type": "StartUp", + "name": "启动游戏", + "params": { + "start_game_enabled": true, + "account_name": "123****4567" + } + }) + ); + } + } +} + +mod Fight { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_fight_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI FightTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Fight"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut weekday_condition = None; + // UseWeeklySchedule + WeeklySchedule -> condition + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseWeeklySchedule") { + if let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") { + const DAYS: [(&str, &str); 7] = [ + ("Sunday", "Sun"), + ("Monday", "Mon"), + ("Tuesday", "Tue"), + ("Wednesday", "Wed"), + ("Thursday", "Thu"), + ("Friday", "Fri"), + ("Saturday", "Sat"), + ]; + + let mut weekdays = Vec::new(); + for (gui_day, cli_day) in DAYS { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) + { + weekdays.push(cli_day); + } + } + + weekday_condition = Some(object!( + "type" => "Weekday", + "weekdays" => weekdays?? + )); + } + } + + let mut params = MAAValue::default(); + // StagePlan -> stage + if let Some(MAAValue::Array(stages)) = task.get("StagePlan") { + let mut stage_list = Vec::new(); + for stage in stages { + if let MAAValue::Primitive(MAAPrimitive::String(stage)) = stage { + stage_list.push(stage.as_str()); + } + } + if stage_list.len() == 1 { + insert!(params, "stage" => stage_list[0]); + } else if !stage_list.is_empty() { + insert!(params, "stage" => stage_list??); + } + } + // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = + task.get("UseExpiringMedicine") + { + if let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = + task.get("MedicineExpireDays") + { + insert!(params, "medicine_expire_days" => *days); + } + } + + if let Some(condition) = weekday_condition { + insert!(item, "strategy" => "merge"); + insert!( + item, + "variants" => vec![object!( + "condition" => condition, + "params" => params + )]?? + ); + } else { + insert!(item, "params" => params); + } + + Ok(Some(item)) + } + + #[cfg(test)] + #[cfg_attr(coverage_nightly, coverage(off))] + mod tests { + use std::{fs::File, path::Path}; + + use super::*; + + const FIGHT_TASK: &str = r#"{ + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": ["LS-6"], + "IsDrGrandet": false, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" +}"#; + + const DISABLED_FIGHT_TASK: &str = r#"{ + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": ["LS-6"], + "IsDrGrandet": false, + "Name": "日常经验本", + "IsEnable": false, + "TaskType": "Fight" +}"#; + + const WEEKLY_LS6_TASK: &str = r#"{ + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": ["LS-6"], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" +}"#; + + const WEEKLY_CE6_TASK: &str = r#"{ + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": ["CE-6"], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" +}"#; + + fn write_task_to_file(dir: &Path, name: &str, task_json: &str) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, task_json).unwrap(); + path + } + + fn read_task_from_file(path: &Path) -> MAAValue { + serde_json::from_reader(File::open(path).unwrap()).unwrap() + } + + fn write_output_json(output: &Path, value: &MAAValue) { + let file = File::create(output).unwrap(); + serde_json::to_writer_pretty(file, value).unwrap(); + } + + fn read_json_file(path: &Path) -> serde_json::Value { + serde_json::from_reader(File::open(path).unwrap()).unwrap() + } + + #[test] + fn basic() { + let dir = tempfile::tempdir().unwrap(); + let input = write_task_to_file(dir.path(), "fight.json", FIGHT_TASK); + let output = dir.path().join("task.json"); + + let task = read_task_from_file(&input); + let value = super::convert_fight_task(&task).unwrap().unwrap(); + write_output_json(&output, &value); + + assert_eq!( + read_json_file(&output), + serde_json::json!({ + "type": "Fight", + "name": "日常经验本", + "params": { + "stage": "LS-6" + } + }) + ); + } + + #[test] + fn when_disabled() { + let dir = tempfile::tempdir().unwrap(); + let input = write_task_to_file(dir.path(), "fight.json", DISABLED_FIGHT_TASK); + let output = dir.path().join("task.json"); + + let task = read_task_from_file(&input); + let value = super::convert_fight_task(&task).unwrap().unwrap(); + write_output_json(&output, &value); + + assert_eq!( + read_json_file(&output), + serde_json::json!({ + "type": "Fight", + "name": "日常经验本", + "params": { + "stage": "LS-6" + } + }) + ); + } + + #[test] + fn weekly_schedule_ls6() { + let dir = tempfile::tempdir().unwrap(); + let input = write_task_to_file(dir.path(), "fight.json", WEEKLY_LS6_TASK); + let output = dir.path().join("task.json"); + + let task = read_task_from_file(&input); + let value = super::convert_fight_task(&task).unwrap().unwrap(); + write_output_json(&output, &value); + + assert_eq!( + read_json_file(&output), + serde_json::json!({ + "type": "Fight", + "name": "日常经验本", + "strategy": "merge", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Mon", "Wed", "Fri"] + }, + "params": { + "medicine_expire_days": 2, + "stage": "LS-6" + } + } + ] + }) + ); + } + + #[test] + fn weekly_schedule_ce6() { + let dir = tempfile::tempdir().unwrap(); + let input = write_task_to_file(dir.path(), "fight.json", WEEKLY_CE6_TASK); + let output = dir.path().join("task.json"); + + let task = read_task_from_file(&input); + let value = super::convert_fight_task(&task).unwrap().unwrap(); + write_output_json(&output, &value); + + assert_eq!( + read_json_file(&output), + serde_json::json!({ + "type": "Fight", + "name": "日常龙门币", + "strategy": "merge", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat"] + }, + "params": { + "medicine_expire_days": 2, + "stage": "CE-6" + } + } + ] + }) + ); + } + } +} diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index 29c89c83..27e77cf5 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -154,6 +154,9 @@ pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result< let value = MAAValue::from_file(file)?; + // TODO: 等flag写了套个if + let value = gui::convert(value)?; + let Some(format) = ft else { bail!("Format not given") }; @@ -173,6 +176,8 @@ pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result< pub mod import; +mod gui; + pub mod asst; pub mod cli; From df22364115e4287bd67182d620ae08520c0a47e1 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 10 Jul 2026 18:28:05 +0800 Subject: [PATCH 02/20] feat: add --gui flag and remaining GUI task conversions Wire gui::convert behind convert -g, map Infrast/Recruit/Mall/Award/ Roguelike/Reclamation from GUI TaskQueue, and align StartUp output with daily.json input templates. Co-authored-by: Cursor --- crates/maa-cli/src/command.rs | 14 + crates/maa-cli/src/config/gui.rs | 700 +++++++++++++++++++++++++++++-- crates/maa-cli/src/config/mod.rs | 24 +- crates/maa-cli/src/main.rs | 3 +- 4 files changed, 705 insertions(+), 36 deletions(-) diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 0ddbd80e..3d5c4ab6 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -188,6 +188,9 @@ pub(crate) enum Command { /// file. If output file is not specified, the output will be default to "json". #[arg(short, long)] format: Option, + /// Convert a GUI profile TaskQueue into maa-cli task config + #[arg(short = 'g', long)] + gui: bool, }, /// Show stage activity of given client Activity { @@ -552,6 +555,7 @@ mod test { input, output: None, format: None, + gui: false, } if input == Path::new("input.toml") ); @@ -579,6 +583,16 @@ mod test { .. } if output == Path::new("output.json") ); + + assert_matches!( + parse_from(["maa", "convert", "-g", "profile.json", "tasks.toml"]).command, + Command::Convert { + input, + output: Some(output), + gui: true, + .. + } if input == Path::new("profile.json") && output == Path::new("tasks.toml") + ); } #[test] diff --git a/crates/maa-cli/src/config/gui.rs b/crates/maa-cli/src/config/gui.rs index e208bc4a..330416aa 100644 --- a/crates/maa-cli/src/config/gui.rs +++ b/crates/maa-cli/src/config/gui.rs @@ -28,8 +28,14 @@ fn convert_task(task: &MAAValue) -> Result> { .and_then(|v| v.as_str()) .context("GUI task missing $type")?; match type_tag { - "StartUpTask" => StartUp::convert_start_up_task(task), - "FightTask" => Fight::convert_fight_task(task), + "StartUpTask" => start_up::convert_start_up_task(task), + "FightTask" => fight::convert_fight_task(task), + "InfrastTask" => infrast::convert_infrast_task(task), + "RecruitTask" => recruit::convert_recruit_task(task), + "MallTask" => mall::convert_mall_task(task), + "AwardTask" => award::convert_award_task(task), + "RoguelikeTask" => roguelike::convert_roguelike_task(task), + "ReclamationTask" => reclamation::convert_reclamation_task(task), _ => Ok(None), } } @@ -84,10 +90,18 @@ mod tests { "tasks": [ { "type": "StartUp", - "name": "", "params": { - "start_game_enabled": true, - "account_name": "" + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type", + "deps": { + "start_game_enabled": true + } + }, + "start_game_enabled": { + "default": true, + "description": "start the game" + } } }, { @@ -123,6 +137,108 @@ mod tests { } } ] + }, + { + "type": "Infrast", + "name": "", + "params": { + "mode": 0, + "facility": [ + "Mfg", + "Trade", + "Control", + "Power", + "Reception", + "Office", + "Dorm", + "Processing", + "Training" + ], + "drones": "Money", + "threshold": 0.3, + "replenish": true, + "dorm_notstationed_enabled": true, + "dorm_trust_enabled": true, + "reception_message_board": true, + "reception_clue_exchange": true, + "reception_send_clue": true + } + }, + { + "type": "Recruit", + "name": "", + "params": { + "times": 4, + "extra_tags_mode": 0, + "refresh": true, + "expedite": true, + "select": [5, 4, 3], + "confirm": [5, 4, 3], + "recruitment_time": { + "3": 540, + "4": 540 + } + } + }, + { + "type": "Mall", + "name": "", + "params": { + "shopping": true, + "credit_fight": false, + "formation_index": 0, + "visit_friends": true, + "buy_first": ["加急许可", "招聘许可"], + "blacklist": ["碳", "家具"], + "force_shopping_if_credit_full": true, + "only_buy_discount": false, + "reserve_max_credit": false + } + }, + { + "type": "Award", + "name": "", + "params": { + "award": true, + "mail": true, + "recruit": false, + "orundum": true, + "mining": true, + "specialaccess": true + } + }, + { + "type": "Roguelike", + "name": "", + "params": { + "theme": "JieGarden", + "mode": 1, + "squad": "指挥分队", + "roles": "稳扎稳打", + "core_char": "维什戴尔", + "starts_count": 999999, + "investment_enabled": true, + "investments_count": 999, + "investment_with_more_score": false, + "stop_when_investment_full": true, + "stop_at_final_boss": false, + "stop_at_max_level": false, + "use_support": false, + "use_nonfriend_support": false, + "refresh_trader_with_dice": false, + "start_with_elite_two": false, + "only_start_with_elite_two": false + } + }, + { + "type": "Reclamation", + "name": "", + "params": { + "theme": "Tales", + "mode": 1, + "increment_mode": 0, + "num_craft_batches": 16 + } } ] }) @@ -130,7 +246,7 @@ mod tests { } } -mod StartUp { +mod start_up { use anyhow::Result; use maa_value::prelude::*; @@ -139,22 +255,23 @@ mod StartUp { log::warn!("GUI StartUpTask is disabled but will still be converted"); } - let mut params = object!("start_game_enabled" => true); - // -> account_name - if let Some(MAAValue::Primitive(MAAPrimitive::String(account))) = - task.get("AccountName") - { - insert!(params, "account_name" => account.as_str()); - } - - let mut item = object!( + let mut params = MAAValue::default(); + // -> client_type + insert!(params, "client_type" => object!( + "alternatives" => vec!["Official", "YoStarEN", "YoStarJP"]??, + "description" => "a client type", + "deps" => object!("start_game_enabled" => true) + )); + // -> start_game_enabled + insert!(params, "start_game_enabled" => object!( + "default" => true, + "description" => "start the game" + )); + + let item = object!( "type" => "StartUp", "params" => params ); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } Ok(Some(item)) } @@ -215,10 +332,18 @@ mod StartUp { read_json_file(&output), serde_json::json!({ "type": "StartUp", - "name": "", "params": { - "start_game_enabled": true, - "account_name": "" + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type", + "deps": { + "start_game_enabled": true + } + }, + "start_game_enabled": { + "default": true, + "description": "start the game" + } } }) ); @@ -238,10 +363,18 @@ mod StartUp { read_json_file(&output), serde_json::json!({ "type": "StartUp", - "name": "启动游戏", "params": { - "start_game_enabled": true, - "account_name": "123****4567" + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type", + "deps": { + "start_game_enabled": true + } + }, + "start_game_enabled": { + "default": true, + "description": "start the game" + } } }) ); @@ -249,7 +382,7 @@ mod StartUp { } } -mod Fight { +mod fight { use anyhow::Result; use maa_value::prelude::*; @@ -590,3 +723,518 @@ mod Fight { } } } + +mod infrast { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_infrast_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI InfrastTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Infrast"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut params = MAAValue::default(); + // Mode -> mode + if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { + if let Some(mode) = match mode.as_str() { + "Normal" => Some(0), + "Custom" => Some(10000), + "Rotation" => Some(20000), + _ => None, + } { + insert!(params, "mode" => mode); + } + } + // RoomList -> facility + if let Some(MAAValue::Array(rooms)) = task.get("RoomList") { + let mut facility = Vec::new(); + for room in rooms { + if let MAAValue::Object(map) = room { + if let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") + { + facility.push(room.as_str()); + } + } + } + if !facility.is_empty() { + insert!(params, "facility" => facility??); + } + } + // UsesOfDrones -> drones + if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") + { + insert!(params, "drones" => drones.as_str()); + } + // DormThreshold -> threshold + if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = + task.get("DormThreshold") + { + insert!(params, "threshold" => *threshold as f32 / 100.0); + } + // OriginiumShardAutoReplenishment -> replenish + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(replenish))) = + task.get("OriginiumShardAutoReplenishment") + { + insert!(params, "replenish" => *replenish); + } + // DormFilterNotStationed -> dorm_notstationed_enabled + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = + task.get("DormFilterNotStationed") + { + insert!(params, "dorm_notstationed_enabled" => *enabled); + } + // DormTrustEnabled -> dorm_trust_enabled + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = + task.get("DormTrustEnabled") + { + insert!(params, "dorm_trust_enabled" => *enabled); + } + // ReceptionMessageBoard -> reception_message_board + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = + task.get("ReceptionMessageBoard") + { + insert!(params, "reception_message_board" => *enabled); + } + // ReceptionClueExchange -> reception_clue_exchange + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = + task.get("ReceptionClueExchange") + { + insert!(params, "reception_clue_exchange" => *enabled); + } + // SendClue -> reception_send_clue + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("SendClue") { + insert!(params, "reception_send_clue" => *enabled); + } + // Filename -> filename + if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") { + if !filename.is_empty() { + insert!(params, "filename" => filename.as_str()); + } + } + // PlanSelect -> plan_index + if let Some(MAAValue::Primitive(MAAPrimitive::Int(plan_index))) = task.get("PlanSelect") + { + if *plan_index >= 0 { + insert!(params, "plan_index" => *plan_index); + } + } + + insert!(item, "params" => params); + Ok(Some(item)) + } +} + +mod recruit { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_recruit_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI RecruitTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Recruit"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut params = MAAValue::default(); + // MaxTimes -> times + if let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("MaxTimes") { + insert!(params, "times" => *times); + } + // ExtraTagMode -> extra_tags_mode + if let Some(MAAValue::Primitive(MAAPrimitive::Int(mode))) = task.get("ExtraTagMode") { + insert!(params, "extra_tags_mode" => *mode); + } + // RefreshLevel3 -> refresh + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") + { + insert!(params, "refresh" => *refresh); + } + // ForceRefresh -> expedite + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") + { + insert!(params, "expedite" => *expedite); + } + // LevelXChoose -> select / confirm + let mut select = Vec::new(); + let mut confirm = Vec::new(); + let mut recruitment_time = MAAValue::default(); + const LEVELS: [(&str, &str, i32); 4] = [ + ("Level6Choose", "Level6Time", 6), + ("Level5Choose", "Level5Time", 5), + ("Level4Choose", "Level4Time", 4), + ("Level3Choose", "Level3Time", 3), + ]; + for (choose_key, time_key, level) in LEVELS { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get(choose_key) { + select.push(level); + confirm.push(level); + } + if let Some(MAAValue::Primitive(MAAPrimitive::Int(minutes))) = task.get(time_key) { + recruitment_time.insert(level.to_string(), (*minutes).into()); + } + } + if !select.is_empty() { + insert!(params, "select" => select??); + insert!(params, "confirm" => confirm??); + } + if recruitment_time.as_map().is_some_and(|map| !map.is_empty()) { + insert!(params, "recruitment_time" => recruitment_time); + } + // PreferTagEnabled + Level3PreferTags -> first_tags + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = + task.get("PreferTagEnabled") + { + if let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") { + let mut first_tags = Vec::new(); + for tag in tags { + if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { + first_tags.push(tag.as_str()); + } + } + if !first_tags.is_empty() { + insert!(params, "first_tags" => first_tags??); + } + } + } + // PreserveTagEnabled + PreserveTagList -> preserve_tags + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = + task.get("PreserveTagEnabled") + { + if let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") { + let mut preserve_tags = Vec::new(); + for tag in tags { + if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { + preserve_tags.push(tag.as_str()); + } + } + if !preserve_tags.is_empty() { + insert!(params, "preserve_tags" => preserve_tags??); + } + } + } + + insert!(item, "params" => params); + Ok(Some(item)) + } +} + +mod mall { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_mall_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI MallTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Mall"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut params = MAAValue::default(); + // Shopping -> shopping + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(shopping))) = task.get("Shopping") { + insert!(params, "shopping" => *shopping); + } + // CreditFight -> credit_fight + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = + task.get("CreditFight") + { + insert!(params, "credit_fight" => *credit_fight); + } + // CreditFightFormation -> formation_index + if let Some(MAAValue::Primitive(MAAPrimitive::Int(formation_index))) = + task.get("CreditFightFormation") + { + insert!(params, "formation_index" => *formation_index); + } + // VisitFriends -> visit_friends + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(visit_friends))) = + task.get("VisitFriends") + { + insert!(params, "visit_friends" => *visit_friends); + } + // FirstList -> buy_first + if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("FirstList") { + let buy_first: Vec<_> = list + .split(';') + .filter(|item| !item.is_empty()) + .collect(); + if !buy_first.is_empty() { + insert!(params, "buy_first" => buy_first??); + } + } + // BlackList -> blacklist + if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("BlackList") { + let blacklist: Vec<_> = list + .split(';') + .filter(|item| !item.is_empty()) + .collect(); + if !blacklist.is_empty() { + insert!(params, "blacklist" => blacklist??); + } + } + // ShoppingIgnoreBlackListWhenFull -> force_shopping_if_credit_full + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(force))) = + task.get("ShoppingIgnoreBlackListWhenFull") + { + insert!(params, "force_shopping_if_credit_full" => *force); + } + // OnlyBuyDiscount -> only_buy_discount + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_discount))) = + task.get("OnlyBuyDiscount") + { + insert!(params, "only_buy_discount" => *only_discount); + } + // ReserveMaxCredit -> reserve_max_credit + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = + task.get("ReserveMaxCredit") + { + insert!(params, "reserve_max_credit" => *reserve); + } + + insert!(item, "params" => params); + Ok(Some(item)) + } +} + +mod award { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_award_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI AwardTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Award"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut params = MAAValue::default(); + // Award -> award + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(award))) = task.get("Award") { + insert!(params, "award" => *award); + } + // Mail -> mail + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mail))) = task.get("Mail") { + insert!(params, "mail" => *mail); + } + // FreeGacha -> recruit + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(recruit))) = task.get("FreeGacha") { + insert!(params, "recruit" => *recruit); + } + // Orundum -> orundum + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(orundum))) = task.get("Orundum") { + insert!(params, "orundum" => *orundum); + } + // Mining -> mining + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mining))) = task.get("Mining") { + insert!(params, "mining" => *mining); + } + // SpecialAccess -> specialaccess + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(specialaccess))) = + task.get("SpecialAccess") + { + insert!(params, "specialaccess" => *specialaccess); + } + + insert!(item, "params" => params); + Ok(Some(item)) + } +} + +mod roguelike { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_roguelike_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI RoguelikeTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Roguelike"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut params = MAAValue::default(); + // Theme -> theme + if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { + insert!(params, "theme" => theme.as_str()); + } + // Mode -> mode + if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { + if let Some(mode) = match mode.as_str() { + "Exp" => Some(0), + "Investment" => Some(1), + "Collect" => Some(4), + "CollapsalParadigms" => Some(5), + "MonthlySquad" => Some(6), + "DeepExploration" => Some(7), + _ => None, + } { + insert!(params, "mode" => mode); + } + } + // Squad -> squad + if let Some(MAAValue::Primitive(MAAPrimitive::String(squad))) = task.get("Squad") { + insert!(params, "squad" => squad.as_str()); + } + // Roles -> roles + if let Some(MAAValue::Primitive(MAAPrimitive::String(roles))) = task.get("Roles") { + insert!(params, "roles" => roles.as_str()); + } + // CoreChar -> core_char + if let Some(MAAValue::Primitive(MAAPrimitive::String(core_char))) = task.get("CoreChar") { + insert!(params, "core_char" => core_char.as_str()); + } + // StartCount -> starts_count + if let Some(MAAValue::Primitive(MAAPrimitive::Int(starts_count))) = task.get("StartCount") { + insert!(params, "starts_count" => *starts_count); + } + // Difficulty -> difficulty + if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") { + if *difficulty != i32::MAX { + insert!(params, "difficulty" => *difficulty); + } + } + // Investment -> investment_enabled + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_enabled))) = + task.get("Investment") + { + insert!(params, "investment_enabled" => *investment_enabled); + } + // InvestCount -> investments_count + if let Some(MAAValue::Primitive(MAAPrimitive::Int(investments_count))) = + task.get("InvestCount") + { + insert!(params, "investments_count" => *investments_count); + } + // InvestWithMoreScore -> investment_with_more_score + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_with_more_score))) = + task.get("InvestWithMoreScore") + { + insert!(params, "investment_with_more_score" => *investment_with_more_score); + } + // StopWhenDepositFull -> stop_when_investment_full + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_when_investment_full))) = + task.get("StopWhenDepositFull") + { + insert!(params, "stop_when_investment_full" => *stop_when_investment_full); + } + // StopAtFinalBoss -> stop_at_final_boss + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_final_boss))) = + task.get("StopAtFinalBoss") + { + insert!(params, "stop_at_final_boss" => *stop_at_final_boss); + } + // StopWhenLevelMax -> stop_at_max_level + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_max_level))) = + task.get("StopWhenLevelMax") + { + insert!(params, "stop_at_max_level" => *stop_at_max_level); + } + // UseSupport -> use_support + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = task.get("UseSupport") + { + insert!(params, "use_support" => *use_support); + } + // UseSupportNonFriend -> use_nonfriend_support + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_nonfriend_support))) = + task.get("UseSupportNonFriend") + { + insert!(params, "use_nonfriend_support" => *use_nonfriend_support); + } + // RefreshTraderWithDice -> refresh_trader_with_dice + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh_trader_with_dice))) = + task.get("RefreshTraderWithDice") + { + insert!(params, "refresh_trader_with_dice" => *refresh_trader_with_dice); + } + // StartWithEliteTwo -> start_with_elite_two + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_with_elite_two))) = + task.get("StartWithEliteTwo") + { + insert!(params, "start_with_elite_two" => *start_with_elite_two); + } + // StartWithEliteTwoOnly -> only_start_with_elite_two + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_start_with_elite_two))) = + task.get("StartWithEliteTwoOnly") + { + insert!(params, "only_start_with_elite_two" => *only_start_with_elite_two); + } + + insert!(item, "params" => params); + Ok(Some(item)) + } +} + +mod reclamation { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_reclamation_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI ReclamationTask is disabled but will still be converted"); + } + + let mut item = object!("type" => "Reclamation"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } + + let mut params = MAAValue::default(); + // Theme -> theme + if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { + insert!(params, "theme" => theme.as_str()); + } + // Mode -> mode + if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { + if let Some(mode) = match mode.as_str() { + "ProsperityNoSave" => Some(0), + "ProsperityInSave" => Some(1), + _ => None, + } { + insert!(params, "mode" => mode); + } + } + // ToolToCraft -> tools_to_craft + if let Some(MAAValue::Primitive(MAAPrimitive::String(tool))) = task.get("ToolToCraft") { + if !tool.is_empty() { + insert!(params, "tools_to_craft" => vec![tool.as_str()]??); + } + } + // IncrementMode -> increment_mode + if let Some(MAAValue::Primitive(MAAPrimitive::Int(increment_mode))) = + task.get("IncrementMode") + { + insert!(params, "increment_mode" => *increment_mode); + } + // MaxCraftCountPerRound -> num_craft_batches + if let Some(MAAValue::Primitive(MAAPrimitive::Int(num_craft_batches))) = + task.get("MaxCraftCountPerRound") + { + insert!(params, "num_craft_batches" => *num_craft_batches); + } + + insert!(item, "params" => params); + Ok(Some(item)) + } +} diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index 27e77cf5..70cd303c 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -143,7 +143,12 @@ impl FindFile for T where T: FromFile {} impl FindFileOrDefault for T where T: FromFile + Default {} -pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result<()> { +pub fn convert( + file: &Path, + out: Option<&Path>, + ft: Option, + gui: bool, +) -> Result<()> { use maa_dirs::Ensure; let ft = ft.or_else(|| { @@ -152,10 +157,11 @@ pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result< .and_then(Filetype::parse_extension) }); - let value = MAAValue::from_file(file)?; + let mut value = MAAValue::from_file(file)?; - // TODO: 等flag写了套个if - let value = gui::convert(value)?; + if gui { + value = gui::convert(value)?; + } let Some(format) = ft else { bail!("Format not given") @@ -307,9 +313,9 @@ mod tests { let value = object!("z" => 1, "a" => "test", "m" => false); Json.write(&input, &value).unwrap(); - super::super::convert(&input, None, Some(Json)).unwrap(); - super::super::convert(&input, Some(&toml), None).unwrap(); - super::super::convert(&input, Some(&toml), Some(Yaml)).unwrap(); + super::super::convert(&input, None, Some(Json), false).unwrap(); + super::super::convert(&input, Some(&toml), None, false).unwrap(); + super::super::convert(&input, Some(&toml), Some(Yaml), false).unwrap(); assert_eq!(MAAValue::from_file(&toml).unwrap(), value); assert_eq!(MAAValue::from_file(&yaml).unwrap(), value); @@ -323,7 +329,7 @@ mod tests { let value = object!("z" => 1); Filetype::Json.write(&input, &value).unwrap(); - assert!(super::super::convert(&input, None, None).is_err()); + assert!(super::super::convert(&input, None, None, false).is_err()); } #[test] @@ -357,7 +363,7 @@ mod tests { .collect(); assert_eq!(inner_keys, ["k2", "k1"]); - super::super::convert(&input, Some(&yaml), None).unwrap(); + super::super::convert(&input, Some(&yaml), None, false).unwrap(); assert_eq!( std::fs::read_to_string(&yaml).unwrap(), diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 89becacf..581f06de 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -107,7 +107,8 @@ fn main() -> Result<()> { input, output, format, - } => config::convert(&input, output.as_deref(), format)?, + gui, + } => config::convert(&input, output.as_deref(), format, gui)?, Command::Activity { client } => activity::display_stage_activity(client)?, Command::Remainder { divisor, timezone } => { use crate::config::task::{TimeOffset, remainder_of_day_mod}; From fc2110b7ea123ea9daade2799ce79a5250a5603f Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 10 Jul 2026 19:06:05 +0800 Subject: [PATCH 03/20] feat: warn when --gui convert input is not JSON The --gui flag targets MAA GUI profile JSON files; warn users when the input uses another format. Co-authored-by: Cursor --- crates/maa-cli/src/config/mod.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index 70cd303c..cb627d7e 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -143,14 +143,17 @@ impl FindFile for T where T: FromFile {} impl FindFileOrDefault for T where T: FromFile + Default {} -pub fn convert( - file: &Path, - out: Option<&Path>, - ft: Option, - gui: bool, -) -> Result<()> { +pub fn convert(file: &Path, out: Option<&Path>, ft: Option, gui: bool) -> Result<()> { use maa_dirs::Ensure; + if gui && !matches!(Filetype::parse_filetype(file), Some(Filetype::Json)) { + log::warn!( + "The --gui flag is for converting MAA GUI profiles, which are typically JSON files; \ + input {} is not JSON", + file.display() + ); + } + let ft = ft.or_else(|| { out.and_then(|path| path.extension()) .and_then(|ext| ext.to_str()) From f6ab2d0d074ea1a6e5d773e9228a4308f22813f5 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 10 Jul 2026 19:27:51 +0800 Subject: [PATCH 04/20] feat: support multi-configuration GUI profile selection Prompt users to pick a configuration when converting GUI profiles with multiple entries; use Current as the default in batch mode. Co-authored-by: Cursor --- .../maa-cli/fixtures/gui/default_profile.json | 1055 +++++++++++++---- crates/maa-cli/src/config/gui.rs | 85 +- crates/maa-cli/src/config/mod.rs | 1 + 3 files changed, 903 insertions(+), 238 deletions(-) diff --git a/crates/maa-cli/fixtures/gui/default_profile.json b/crates/maa-cli/fixtures/gui/default_profile.json index 8799308d..248648b4 100644 --- a/crates/maa-cli/fixtures/gui/default_profile.json +++ b/crates/maa-cli/fixtures/gui/default_profile.json @@ -1,261 +1,844 @@ { - "InfrastOrder": {}, - "TaskQueue": [ - { - "$type": "StartUpTask", - "AccountName": "", - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "LS-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "CE-6" + "Configurations": { + "Default": { + "InfrastOrder": {}, + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "LS-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" + }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, + { + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/07/10 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": false, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" + }, + { + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" + }, + { + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" + } ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" + "TaskSelectedIndex": 3, + "DragItemIsChecked": {} }, - { - "$type": "InfrastTask", - "Mode": "Normal", - "UsesOfDrones": "Money", - "DormThreshold": 30, - "DormTrustEnabled": true, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - "ContinueTraining": true, - "Filename": "", - "PlanSelect": -1, - "RoomList": [ + "Dev": { + "InfrastOrder": {}, + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, { - "Room": "Mfg" + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "LS-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" }, { - "Room": "Trade" + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" }, { - "Room": "Control" + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" }, { - "Room": "Power" + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" }, { - "Room": "Reception" + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/07/05 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": true, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" }, { - "Room": "Office" + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" }, { - "Room": "Dorm" + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" }, { - "Room": "Processing" + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" }, { - "Room": "Training" + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" } ], - "Name": "", - "IsEnable": true, - "TaskType": "Infrast" + "TaskSelectedIndex": 1, + "DragItemIsChecked": {} }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" + "26/07/10 18:36:14": { + "InfrastOrder": {}, + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "LS-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" + }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, + { + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/07/10 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": false, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" + }, + { + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" + }, + { + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" + } ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, - { - "$type": "MallTask", - "Shopping": true, - "CreditFight": false, - "CreditFightFormation": 0, - "CreditFightLastTime": "2026/06/08 00:00:00", - "CreditFightOnceADay": true, - "VisitFriends": true, - "VisitFriendsOnceADay": true, - "VisitFriendsLastTime": "2026/07/05 00:00:00", - "FirstList": "加急许可;招聘许可", - "BlackList": "碳;家具;", - "ShoppingIgnoreBlackListWhenFull": true, - "OnlyBuyDiscount": false, - "ReserveMaxCredit": false, - "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": false, - "Name": "", - "IsEnable": true, - "TaskType": "Mall" - }, - { - "$type": "AwardTask", - "Award": true, - "Mail": true, - "FreeGacha": false, - "Orundum": true, - "Mining": true, - "SpecialAccess": true, - "Name": "", - "IsEnable": true, - "TaskType": "Award" - }, - { - "$type": "RoguelikeTask", - "Theme": "JieGarden", - "Difficulty": 2147483647, - "Mode": "Investment", - "Squad": "指挥分队", - "SquadCollectible": "指挥分队", - "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", - "Investment": true, - "InvestCount": 999, - "InvestWithMoreScore": false, - "CollectibleStartAwards": "HotWater", - "CollectibleShopping": false, - "StartWithEliteTwo": false, - "StartWithEliteTwoOnly": false, - "ExpectedCollapsalParadigms": "", - "MonthlySquadAutoIterate": true, - "MonthlySquadCheckComms": true, - "DeepExplorationAutoIterate": true, - "FindPlaytimeTarget": "Ling", - "UseSupport": false, - "UseSupportNonFriend": false, - "RefreshTraderWithDice": false, - "SquadIsFoldartal": false, - "SamiFirstFloorFoldartal": false, - "SamiFirstFloorFoldartals": "", - "SamiNewSquad2StartingFoldartal": false, - "SamiNewSquad2StartingFoldartals": "", - "StopWhenDepositFull": true, - "StopAtFinalBoss": false, - "StopWhenLevelMax": false, - "StartWithSeed": false, - "Seed": "", - "Name": "", - "IsEnable": false, - "TaskType": "Roguelike" - }, - { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" - }, - { - "$type": "UserDataUpdateTask", - "UpdateOperBox": true, - "UpdateDepot": true, - "TriggerInterval": "EveryTime", - "IsTriggered": true, - "Name": "", - "IsEnable": true, - "TaskType": "UserDataUpdate" + "TaskSelectedIndex": 3, + "DragItemIsChecked": {} } - ], - "TaskSelectedIndex": -1, - "DragItemIsChecked": {} -} + }, + "Timers": {}, + "ConfigVersion": 1, + "Current": "Default", + "VersionUpdate": { + "Name": "", + "Body": "", + "IsFirstBoot": false, + "Package": "", + "Proxy": "", + "VersionType": "Stable", + "UpdateCheck": true, + "UpdateAutoCheck": false, + "AutoDownloadUpdatePackage": true, + "AutoInstallUpdatePackage": false, + "ShowUpdaterConsole": false, + "ShowUpdaterProgress": true + }, + "AnnouncementInfo": { + "DoNotShowAgain": true, + "DoNotShow": false + }, + "GUI": { + "DarkMode": "SyncWithOs", + "UseNotify": true, + "MainTasksInvertNullFunction": false, + "Localization": "zh-cn", + "MinimizeToTray": false, + "HideCloseButton": false, + "LogItemDateFormat": "HH:mm:ss", + "LoadWindowPlacement": true, + "SaveWindowPlacement": true, + "InverseClearMode": "Clear", + "WindowTitlePrefix": "", + "IgnoreBadModulesAndUseSoftwareRendering": false, + "ExpanderSwitchConfiguration": true, + "ExpanderScheduleSettings": true, + "ExpanderPerformanceSettings": true, + "ExpanderGameSettings": true, + "ExpanderConnectionSettings": true, + "ExpanderStartupSettings": true, + "ExpanderRemoteControlSettings": true, + "ExpanderUiSettings": true, + "ExpanderBackgroundSettings": true, + "ExpanderExternalNotificationSettings": true, + "ExpanderHotKeySettings": true, + "ExpanderAchievementSettings": true, + "ExpanderUpdateSettings": true, + "ExpanderIssueReport": true, + "ExpanderAboutUs": true, + "BackgroundMonetEnabled": false, + "BackgroundMonetMode": "Auto", + "BackgroundMonetCustomColor": "#326CF3", + "BackgroundMonetCachedColor": "" + } +} \ No newline at end of file diff --git a/crates/maa-cli/src/config/gui.rs b/crates/maa-cli/src/config/gui.rs index 330416aa..5a53e00b 100644 --- a/crates/maa-cli/src/config/gui.rs +++ b/crates/maa-cli/src/config/gui.rs @@ -1,5 +1,52 @@ +use std::num::NonZero; + use anyhow::{Context, Result, bail}; -use maa_value::prelude::*; +use maa_value::{ + map::StringMap, + prelude::*, + userinput::{SelectD, UserInput}, +}; + +/// Pick one configuration from a multi-profile GUI export. +/// +/// Legacy single-config profiles are returned unchanged. +pub(crate) fn select_configuration(input: MAAValue) -> Result { + let Some(configurations_value) = input.get("Configurations") else { + return Ok(input); + }; + let Some(configurations) = configurations_value.as_map() else { + bail!("GUI profile Configurations must be an object"); + }; + + match configurations.len() { + 0 => bail!("GUI profile has no configuration"), + 1 => Ok(configurations.values().next().unwrap().clone()), + _ => pick_configuration(&input, configurations), + } +} + +fn pick_configuration( + input: &MAAValue, + configurations: &StringMap, +) -> Result { + let names: Vec<&str> = configurations.keys().map(String::as_str).collect(); + let default_index = input + .get("Current") + .and_then(|v| v.as_str()) + .and_then(|current| names.iter().position(|name| *name == current)) + .and_then(|i| NonZero::new(i + 1)); + + let selected_name = SelectD::::from_iter(names, default_index) + .context("Failed to build configuration selection")? + .with_description("a GUI configuration") + .value() + .context("Failed to select GUI configuration")?; + + configurations + .get(&selected_name) + .cloned() + .with_context(|| format!("GUI configuration {selected_name} not found")) +} /// Convert a GUI profile `MAAValue` into maa-cli task config shape. pub(crate) fn convert(input: MAAValue) -> Result { @@ -59,6 +106,40 @@ mod tests { serde_json::from_reader(File::open(path).unwrap()).unwrap() } + #[test] + fn select_configuration_passes_through_legacy_profile() { + let input = object!("legacy" => true); + assert_eq!(select_configuration(input.clone()).unwrap(), input); + } + + #[test] + fn select_configuration_picks_the_only_configuration() { + let input = object!( + "Configurations" => object!( + "Default" => object!("name" => "only") + ) + ); + assert_eq!( + select_configuration(input).unwrap(), + object!("name" => "only") + ); + } + + #[test] + fn select_configuration_uses_current_in_batch_mode() { + let input = object!( + "Current" => "Dev", + "Configurations" => object!( + "Default" => object!("name" => "default"), + "Dev" => object!("name" => "dev"), + ) + ); + assert_eq!( + select_configuration(input).unwrap(), + object!("name" => "dev") + ); + } + #[test] fn missing_task_queue_is_error() { let input = object!("a" => 1); @@ -80,7 +161,7 @@ mod tests { let output = dir.path().join("tasks.json"); let input: MAAValue = serde_json::from_reader(File::open(&input).unwrap()).unwrap(); - let value = convert(input).unwrap(); + let value = convert(select_configuration(input).unwrap()).unwrap(); let file = File::create(&output).unwrap(); serde_json::to_writer_pretty(file, &value).unwrap(); diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index cb627d7e..0baf04c8 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -163,6 +163,7 @@ pub fn convert(file: &Path, out: Option<&Path>, ft: Option, gui: bool) let mut value = MAAValue::from_file(file)?; if gui { + value = gui::select_configuration(value)?; value = gui::convert(value)?; } From 716c496887f1cdd451808761941a9ec3793f6183 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Mon, 13 Jul 2026 13:31:41 +0800 Subject: [PATCH 05/20] refactor: improve GUI convert robustness and consolidate tests - warn when skipping unsupported GUI task $type values - add --gui-config flag and MAA_GUI_CONFIG env for non-interactive multi-profile selection - merge pick_configuration into select_configuration - consolidate per-task tests into gui::tests with gui_task_test! macro --- crates/maa-cli/src/command.rs | 6 + crates/maa-cli/src/config/gui.rs | 709 ++++++++++++++----------------- crates/maa-cli/src/config/mod.rs | 20 +- crates/maa-cli/src/main.rs | 9 +- 4 files changed, 346 insertions(+), 398 deletions(-) diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 3d5c4ab6..113a3f3a 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -191,6 +191,11 @@ pub(crate) enum Command { /// Convert a GUI profile TaskQueue into maa-cli task config #[arg(short = 'g', long)] gui: bool, + /// Select a named configuration when converting a multi-profile GUI export + /// + /// Can also be set with the `MAA_GUI_CONFIG` environment variable. + #[arg(long)] + gui_config: Option, }, /// Show stage activity of given client Activity { @@ -556,6 +561,7 @@ mod test { output: None, format: None, gui: false, + gui_config: None, } if input == Path::new("input.toml") ); diff --git a/crates/maa-cli/src/config/gui.rs b/crates/maa-cli/src/config/gui.rs index 5a53e00b..f9c391e1 100644 --- a/crates/maa-cli/src/config/gui.rs +++ b/crates/maa-cli/src/config/gui.rs @@ -7,10 +7,15 @@ use maa_value::{ userinput::{SelectD, UserInput}, }; +const GUI_CONFIG_ENV: &str = "MAA_GUI_CONFIG"; + /// Pick one configuration from a multi-profile GUI export. /// /// Legacy single-config profiles are returned unchanged. -pub(crate) fn select_configuration(input: MAAValue) -> Result { +/// +/// When multiple configurations exist, `gui_config` or [`GUI_CONFIG_ENV`] can be +/// used to select one without an interactive prompt. +pub(crate) fn select_configuration(input: MAAValue, gui_config: Option<&str>) -> Result { let Some(configurations_value) = input.get("Configurations") else { return Ok(input); }; @@ -21,14 +26,28 @@ pub(crate) fn select_configuration(input: MAAValue) -> Result { match configurations.len() { 0 => bail!("GUI profile has no configuration"), 1 => Ok(configurations.values().next().unwrap().clone()), - _ => pick_configuration(&input, configurations), + _ => { + let selected_name = resolve_configuration_name(&input, configurations, gui_config)?; + configurations + .get(&selected_name) + .cloned() + .with_context(|| format!("GUI configuration {selected_name} not found")) + } } } -fn pick_configuration( +fn resolve_configuration_name( input: &MAAValue, configurations: &StringMap, -) -> Result { + gui_config: Option<&str>, +) -> Result { + if let Some(name) = gui_config { + return Ok(name.to_string()); + } + if let Ok(name) = std::env::var(GUI_CONFIG_ENV) { + return Ok(name); + } + let names: Vec<&str> = configurations.keys().map(String::as_str).collect(); let default_index = input .get("Current") @@ -36,16 +55,11 @@ fn pick_configuration( .and_then(|current| names.iter().position(|name| *name == current)) .and_then(|i| NonZero::new(i + 1)); - let selected_name = SelectD::::from_iter(names, default_index) + SelectD::::from_iter(names, default_index) .context("Failed to build configuration selection")? .with_description("a GUI configuration") .value() - .context("Failed to select GUI configuration")?; - - configurations - .get(&selected_name) - .cloned() - .with_context(|| format!("GUI configuration {selected_name} not found")) + .context("Failed to select GUI configuration") } /// Convert a GUI profile `MAAValue` into maa-cli task config shape. @@ -83,33 +97,38 @@ fn convert_task(task: &MAAValue) -> Result> { "AwardTask" => award::convert_award_task(task), "RoguelikeTask" => roguelike::convert_roguelike_task(task), "ReclamationTask" => reclamation::convert_reclamation_task(task), - _ => Ok(None), + type_tag => { + log::warn!("Skipping unsupported GUI task type: {type_tag}"); + Ok(None) + } } } #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::{fs::File, path::Path}; - use super::*; - const DEFAULT_PROFILE: &str = include_str!("../../fixtures/gui/default_profile.json"); - - fn write_json(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { - let path = dir.join(name); - std::fs::write(&path, content).unwrap(); - path + macro_rules! gui_task_test { + ($name:ident, $converter:path, $task_json:expr, $($expected:tt)*) => { + #[test] + fn $name() { + let task: MAAValue = serde_json::from_str($task_json).unwrap(); + let actual = $converter(&task).unwrap().unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!($($expected)*) + ); + } + }; } - fn read_json(path: &Path) -> serde_json::Value { - serde_json::from_reader(File::open(path).unwrap()).unwrap() - } + const DEFAULT_PROFILE: &str = include_str!("../../fixtures/gui/default_profile.json"); #[test] fn select_configuration_passes_through_legacy_profile() { let input = object!("legacy" => true); - assert_eq!(select_configuration(input.clone()).unwrap(), input); + assert_eq!(select_configuration(input.clone(), None).unwrap(), input); } #[test] @@ -120,7 +139,7 @@ mod tests { ) ); assert_eq!( - select_configuration(input).unwrap(), + select_configuration(input, None).unwrap(), object!("name" => "only") ); } @@ -135,11 +154,43 @@ mod tests { ) ); assert_eq!( - select_configuration(input).unwrap(), + select_configuration(input, None).unwrap(), object!("name" => "dev") ); } + #[test] + fn select_configuration_uses_gui_config_override() { + let input = object!( + "Current" => "Default", + "Configurations" => object!( + "Default" => object!("name" => "default"), + "Dev" => object!("name" => "dev"), + ) + ); + assert_eq!( + select_configuration(input, Some("Dev")).unwrap(), + object!("name" => "dev") + ); + } + + #[test] + fn select_configuration_uses_env_override() { + let input = object!( + "Configurations" => object!( + "Default" => object!("name" => "default"), + "Dev" => object!("name" => "dev"), + ) + ); + + // SAFETY: test-only env var, restored before return. + unsafe { std::env::set_var(GUI_CONFIG_ENV, "Dev") }; + let result = select_configuration(input, None); + unsafe { std::env::remove_var(GUI_CONFIG_ENV) }; + + assert_eq!(result.unwrap(), object!("name" => "dev")); + } + #[test] fn missing_task_queue_is_error() { let input = object!("a" => 1); @@ -155,19 +206,26 @@ mod tests { } #[test] - fn json_to_json_default_profile() { - let dir = tempfile::tempdir().unwrap(); - let input = write_json(dir.path(), "profile.json", DEFAULT_PROFILE); - let output = dir.path().join("tasks.json"); - - let input: MAAValue = serde_json::from_reader(File::open(&input).unwrap()).unwrap(); - let value = convert(select_configuration(input).unwrap()).unwrap(); - let file = File::create(&output).unwrap(); - serde_json::to_writer_pretty(file, &value).unwrap(); + fn unknown_task_type_is_skipped() { + let input = object!( + "TaskQueue" => vec![ + object!("$type" => "UserDataUpdateTask", "Name" => "update"), + object!("$type" => "StartUpTask", "IsEnable" => true), + ]?? + ); + let value = convert(input).unwrap(); + let MAAValue::Array(tasks) = value.get("tasks").unwrap() else { + panic!("tasks should be an array"); + }; + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].get("type").unwrap().as_str().unwrap(), "StartUp"); + } - assert_eq!( - read_json(&output), - serde_json::json!({ + #[test] + fn json_to_json_default_profile() { + let input: MAAValue = serde_json::from_str(DEFAULT_PROFILE).unwrap(); + let value = convert(select_configuration(input, None).unwrap()).unwrap(); + let expected: MAAValue = serde_json::from_value(serde_json::json!({ "tasks": [ { "type": "StartUp", @@ -322,49 +380,12 @@ mod tests { } } ] - }) - ); - } -} - -mod start_up { - use anyhow::Result; - use maa_value::prelude::*; - - pub(super) fn convert_start_up_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI StartUpTask is disabled but will still be converted"); - } - - let mut params = MAAValue::default(); - // -> client_type - insert!(params, "client_type" => object!( - "alternatives" => vec!["Official", "YoStarEN", "YoStarJP"]??, - "description" => "a client type", - "deps" => object!("start_game_enabled" => true) - )); - // -> start_game_enabled - insert!(params, "start_game_enabled" => object!( - "default" => true, - "description" => "start the game" - )); - - let item = object!( - "type" => "StartUp", - "params" => params - ); - - Ok(Some(item)) + })) + .unwrap(); + assert_eq!(value, expected); } - #[cfg(test)] - #[cfg_attr(coverage_nightly, coverage(off))] - mod tests { - use std::{fs::File, path::Path}; - - use super::*; - - const STARTUP_TASK: &str = r#"{ + const STARTUP_TASK: &str = r#"{ "$type": "StartUpTask", "AccountName": "", "Name": "", @@ -372,7 +393,7 @@ mod start_up { "TaskType": "StartUp" }"#; - const STARTUP_TASK_WITH_ACCOUNT: &str = r#"{ + const STARTUP_TASK_WITH_ACCOUNT: &str = r#"{ "$type": "StartUpTask", "AccountName": "123****4567", "Name": "启动游戏", @@ -380,183 +401,51 @@ mod start_up { "TaskType": "StartUp" }"#; - fn write_task_to_file(dir: &Path, name: &str, task_json: &str) -> std::path::PathBuf { - let path = dir.join(name); - std::fs::write(&path, task_json).unwrap(); - path - } - - fn read_task_from_file(path: &Path) -> MAAValue { - serde_json::from_reader(File::open(path).unwrap()).unwrap() - } - - fn write_output_json(output: &Path, value: &MAAValue) { - let file = File::create(output).unwrap(); - serde_json::to_writer_pretty(file, value).unwrap(); - } - - fn read_json_file(path: &Path) -> serde_json::Value { - serde_json::from_reader(File::open(path).unwrap()).unwrap() - } - - #[test] - fn basic() { - let dir = tempfile::tempdir().unwrap(); - let input = write_task_to_file(dir.path(), "startup.json", STARTUP_TASK); - let output = dir.path().join("task.json"); - - let task = read_task_from_file(&input); - let value = super::convert_start_up_task(&task).unwrap().unwrap(); - write_output_json(&output, &value); - - assert_eq!( - read_json_file(&output), - serde_json::json!({ - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type", - "deps": { - "start_game_enabled": true - } - }, - "start_game_enabled": { - "default": true, - "description": "start the game" - } - } - }) - ); - } - - #[test] - fn with_account_and_name() { - let dir = tempfile::tempdir().unwrap(); - let input = write_task_to_file(dir.path(), "startup.json", STARTUP_TASK_WITH_ACCOUNT); - let output = dir.path().join("task.json"); - - let task = read_task_from_file(&input); - let value = super::convert_start_up_task(&task).unwrap().unwrap(); - write_output_json(&output, &value); - - assert_eq!( - read_json_file(&output), - serde_json::json!({ - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type", - "deps": { - "start_game_enabled": true - } - }, - "start_game_enabled": { - "default": true, - "description": "start the game" - } - } - }) - ); - } - } -} - -mod fight { - use anyhow::Result; - use maa_value::prelude::*; - - pub(super) fn convert_fight_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI FightTask is disabled but will still be converted"); - } - - let mut item = object!("type" => "Fight"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut weekday_condition = None; - // UseWeeklySchedule + WeeklySchedule -> condition - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseWeeklySchedule") { - if let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") { - const DAYS: [(&str, &str); 7] = [ - ("Sunday", "Sun"), - ("Monday", "Mon"), - ("Tuesday", "Tue"), - ("Wednesday", "Wed"), - ("Thursday", "Thu"), - ("Friday", "Fri"), - ("Saturday", "Sat"), - ]; - - let mut weekdays = Vec::new(); - for (gui_day, cli_day) in DAYS { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) - { - weekdays.push(cli_day); + gui_task_test!( + convert_start_up_task_basic, + start_up::convert_start_up_task, + STARTUP_TASK, + { + "type": "StartUp", + "params": { + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type", + "deps": { + "start_game_enabled": true } + }, + "start_game_enabled": { + "default": true, + "description": "start the game" } - - weekday_condition = Some(object!( - "type" => "Weekday", - "weekdays" => weekdays?? - )); } } + ); - let mut params = MAAValue::default(); - // StagePlan -> stage - if let Some(MAAValue::Array(stages)) = task.get("StagePlan") { - let mut stage_list = Vec::new(); - for stage in stages { - if let MAAValue::Primitive(MAAPrimitive::String(stage)) = stage { - stage_list.push(stage.as_str()); - } - } - if stage_list.len() == 1 { - insert!(params, "stage" => stage_list[0]); - } else if !stage_list.is_empty() { - insert!(params, "stage" => stage_list??); - } - } - // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = - task.get("UseExpiringMedicine") + gui_task_test!( + convert_start_up_task_with_account_and_name, + start_up::convert_start_up_task, + STARTUP_TASK_WITH_ACCOUNT, { - if let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = - task.get("MedicineExpireDays") - { - insert!(params, "medicine_expire_days" => *days); + "type": "StartUp", + "params": { + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type", + "deps": { + "start_game_enabled": true + } + }, + "start_game_enabled": { + "default": true, + "description": "start the game" + } } } + ); - if let Some(condition) = weekday_condition { - insert!(item, "strategy" => "merge"); - insert!( - item, - "variants" => vec![object!( - "condition" => condition, - "params" => params - )]?? - ); - } else { - insert!(item, "params" => params); - } - - Ok(Some(item)) - } - - #[cfg(test)] - #[cfg_attr(coverage_nightly, coverage(off))] - mod tests { - use std::{fs::File, path::Path}; - - use super::*; - - const FIGHT_TASK: &str = r#"{ + const FIGHT_TASK: &str = r#"{ "$type": "FightTask", "UseMedicine": false, "MedicineCount": 0, @@ -575,7 +464,7 @@ mod fight { "TaskType": "Fight" }"#; - const DISABLED_FIGHT_TASK: &str = r#"{ + const DISABLED_FIGHT_TASK: &str = r#"{ "$type": "FightTask", "UseMedicine": false, "MedicineCount": 0, @@ -594,7 +483,7 @@ mod fight { "TaskType": "Fight" }"#; - const WEEKLY_LS6_TASK: &str = r#"{ + const WEEKLY_LS6_TASK: &str = r#"{ "$type": "FightTask", "UseMedicine": false, "MedicineCount": 0, @@ -635,7 +524,7 @@ mod fight { "TaskType": "Fight" }"#; - const WEEKLY_CE6_TASK: &str = r#"{ + const WEEKLY_CE6_TASK: &str = r#"{ "$type": "FightTask", "UseMedicine": false, "MedicineCount": 0, @@ -676,132 +565,192 @@ mod fight { "TaskType": "Fight" }"#; - fn write_task_to_file(dir: &Path, name: &str, task_json: &str) -> std::path::PathBuf { - let path = dir.join(name); - std::fs::write(&path, task_json).unwrap(); - path + gui_task_test!( + convert_fight_task_basic, + fight::convert_fight_task, + FIGHT_TASK, + { + "type": "Fight", + "name": "日常经验本", + "params": { + "stage": "LS-6" + } } + ); - fn read_task_from_file(path: &Path) -> MAAValue { - serde_json::from_reader(File::open(path).unwrap()).unwrap() + gui_task_test!( + convert_fight_task_when_disabled, + fight::convert_fight_task, + DISABLED_FIGHT_TASK, + { + "type": "Fight", + "name": "日常经验本", + "params": { + "stage": "LS-6" + } } + ); - fn write_output_json(output: &Path, value: &MAAValue) { - let file = File::create(output).unwrap(); - serde_json::to_writer_pretty(file, value).unwrap(); + gui_task_test!( + convert_fight_task_weekly_schedule_ls6, + fight::convert_fight_task, + WEEKLY_LS6_TASK, + { + "type": "Fight", + "name": "日常经验本", + "strategy": "merge", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Mon", "Wed", "Fri"] + }, + "params": { + "medicine_expire_days": 2, + "stage": "LS-6" + } + } + ] } + ); - fn read_json_file(path: &Path) -> serde_json::Value { - serde_json::from_reader(File::open(path).unwrap()).unwrap() + gui_task_test!( + convert_fight_task_weekly_schedule_ce6, + fight::convert_fight_task, + WEEKLY_CE6_TASK, + { + "type": "Fight", + "name": "日常龙门币", + "strategy": "merge", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat"] + }, + "params": { + "medicine_expire_days": 2, + "stage": "CE-6" + } + } + ] } + ); +} - #[test] - fn basic() { - let dir = tempfile::tempdir().unwrap(); - let input = write_task_to_file(dir.path(), "fight.json", FIGHT_TASK); - let output = dir.path().join("task.json"); +mod start_up { + use anyhow::Result; + use maa_value::prelude::*; - let task = read_task_from_file(&input); - let value = super::convert_fight_task(&task).unwrap().unwrap(); - write_output_json(&output, &value); + pub(super) fn convert_start_up_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI StartUpTask is disabled but will still be converted"); + } - assert_eq!( - read_json_file(&output), - serde_json::json!({ - "type": "Fight", - "name": "日常经验本", - "params": { - "stage": "LS-6" - } - }) - ); + let mut params = MAAValue::default(); + // -> client_type + insert!(params, "client_type" => object!( + "alternatives" => vec!["Official", "YoStarEN", "YoStarJP"]??, + "description" => "a client type", + "deps" => object!("start_game_enabled" => true) + )); + // -> start_game_enabled + insert!(params, "start_game_enabled" => object!( + "default" => true, + "description" => "start the game" + )); + + let item = object!( + "type" => "StartUp", + "params" => params + ); + + Ok(Some(item)) + } +} + +mod fight { + use anyhow::Result; + use maa_value::prelude::*; + + pub(super) fn convert_fight_task(task: &MAAValue) -> Result> { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { + log::warn!("GUI FightTask is disabled but will still be converted"); } - #[test] - fn when_disabled() { - let dir = tempfile::tempdir().unwrap(); - let input = write_task_to_file(dir.path(), "fight.json", DISABLED_FIGHT_TASK); - let output = dir.path().join("task.json"); + let mut item = object!("type" => "Fight"); + // -> task name + if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { + insert!(item, "name" => name.as_str()); + } - let task = read_task_from_file(&input); - let value = super::convert_fight_task(&task).unwrap().unwrap(); - write_output_json(&output, &value); + let mut weekday_condition = None; + // UseWeeklySchedule + WeeklySchedule -> condition + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseWeeklySchedule") { + if let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") { + const DAYS: [(&str, &str); 7] = [ + ("Sunday", "Sun"), + ("Monday", "Mon"), + ("Tuesday", "Tue"), + ("Wednesday", "Wed"), + ("Thursday", "Thu"), + ("Friday", "Fri"), + ("Saturday", "Sat"), + ]; - assert_eq!( - read_json_file(&output), - serde_json::json!({ - "type": "Fight", - "name": "日常经验本", - "params": { - "stage": "LS-6" + let mut weekdays = Vec::new(); + for (gui_day, cli_day) in DAYS { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { + weekdays.push(cli_day); } - }) - ); + } + + weekday_condition = Some(object!( + "type" => "Weekday", + "weekdays" => weekdays?? + )); + } } - #[test] - fn weekly_schedule_ls6() { - let dir = tempfile::tempdir().unwrap(); - let input = write_task_to_file(dir.path(), "fight.json", WEEKLY_LS6_TASK); - let output = dir.path().join("task.json"); - - let task = read_task_from_file(&input); - let value = super::convert_fight_task(&task).unwrap().unwrap(); - write_output_json(&output, &value); - - assert_eq!( - read_json_file(&output), - serde_json::json!({ - "type": "Fight", - "name": "日常经验本", - "strategy": "merge", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Mon", "Wed", "Fri"] - }, - "params": { - "medicine_expire_days": 2, - "stage": "LS-6" - } - } - ] - }) - ); + let mut params = MAAValue::default(); + // StagePlan -> stage + if let Some(MAAValue::Array(stages)) = task.get("StagePlan") { + let mut stage_list = Vec::new(); + for stage in stages { + if let MAAValue::Primitive(MAAPrimitive::String(stage)) = stage { + stage_list.push(stage.as_str()); + } + } + if stage_list.len() == 1 { + insert!(params, "stage" => stage_list[0]); + } else if !stage_list.is_empty() { + insert!(params, "stage" => stage_list??); + } + } + // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseExpiringMedicine") + { + if let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = + task.get("MedicineExpireDays") + { + insert!(params, "medicine_expire_days" => *days); + } } - #[test] - fn weekly_schedule_ce6() { - let dir = tempfile::tempdir().unwrap(); - let input = write_task_to_file(dir.path(), "fight.json", WEEKLY_CE6_TASK); - let output = dir.path().join("task.json"); - - let task = read_task_from_file(&input); - let value = super::convert_fight_task(&task).unwrap().unwrap(); - write_output_json(&output, &value); - - assert_eq!( - read_json_file(&output), - serde_json::json!({ - "type": "Fight", - "name": "日常龙门币", - "strategy": "merge", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Tue", "Thu", "Sat"] - }, - "params": { - "medicine_expire_days": 2, - "stage": "CE-6" - } - } - ] - }) + if let Some(condition) = weekday_condition { + insert!(item, "strategy" => "merge"); + insert!( + item, + "variants" => vec![object!( + "condition" => condition, + "params" => params + )]?? ); + } else { + insert!(item, "params" => params); } + + Ok(Some(item)) } } @@ -837,8 +786,7 @@ mod infrast { let mut facility = Vec::new(); for room in rooms { if let MAAValue::Object(map) = room { - if let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") - { + if let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") { facility.push(room.as_str()); } } @@ -848,14 +796,11 @@ mod infrast { } } // UsesOfDrones -> drones - if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") - { + if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") { insert!(params, "drones" => drones.as_str()); } // DormThreshold -> threshold - if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = - task.get("DormThreshold") - { + if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = task.get("DormThreshold") { insert!(params, "threshold" => *threshold as f32 / 100.0); } // OriginiumShardAutoReplenishment -> replenish @@ -871,8 +816,7 @@ mod infrast { insert!(params, "dorm_notstationed_enabled" => *enabled); } // DormTrustEnabled -> dorm_trust_enabled - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = - task.get("DormTrustEnabled") + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("DormTrustEnabled") { insert!(params, "dorm_trust_enabled" => *enabled); } @@ -899,8 +843,7 @@ mod infrast { } } // PlanSelect -> plan_index - if let Some(MAAValue::Primitive(MAAPrimitive::Int(plan_index))) = task.get("PlanSelect") - { + if let Some(MAAValue::Primitive(MAAPrimitive::Int(plan_index))) = task.get("PlanSelect") { if *plan_index >= 0 { insert!(params, "plan_index" => *plan_index); } @@ -936,13 +879,11 @@ mod recruit { insert!(params, "extra_tags_mode" => *mode); } // RefreshLevel3 -> refresh - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") - { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") { insert!(params, "refresh" => *refresh); } // ForceRefresh -> expedite - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") - { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") { insert!(params, "expedite" => *expedite); } // LevelXChoose -> select / confirm @@ -972,9 +913,7 @@ mod recruit { insert!(params, "recruitment_time" => recruitment_time); } // PreferTagEnabled + Level3PreferTags -> first_tags - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = - task.get("PreferTagEnabled") - { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreferTagEnabled") { if let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") { let mut first_tags = Vec::new(); for tag in tags { @@ -988,8 +927,7 @@ mod recruit { } } // PreserveTagEnabled + PreserveTagList -> preserve_tags - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = - task.get("PreserveTagEnabled") + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreserveTagEnabled") { if let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") { let mut preserve_tags = Vec::new(); @@ -1030,8 +968,7 @@ mod mall { insert!(params, "shopping" => *shopping); } // CreditFight -> credit_fight - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = - task.get("CreditFight") + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = task.get("CreditFight") { insert!(params, "credit_fight" => *credit_fight); } @@ -1049,20 +986,14 @@ mod mall { } // FirstList -> buy_first if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("FirstList") { - let buy_first: Vec<_> = list - .split(';') - .filter(|item| !item.is_empty()) - .collect(); + let buy_first: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); if !buy_first.is_empty() { insert!(params, "buy_first" => buy_first??); } } // BlackList -> blacklist if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("BlackList") { - let blacklist: Vec<_> = list - .split(';') - .filter(|item| !item.is_empty()) - .collect(); + let blacklist: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); if !blacklist.is_empty() { insert!(params, "blacklist" => blacklist??); } @@ -1080,8 +1011,7 @@ mod mall { insert!(params, "only_buy_discount" => *only_discount); } // ReserveMaxCredit -> reserve_max_credit - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = - task.get("ReserveMaxCredit") + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = task.get("ReserveMaxCredit") { insert!(params, "reserve_max_credit" => *reserve); } @@ -1232,8 +1162,7 @@ mod roguelike { insert!(params, "stop_at_max_level" => *stop_at_max_level); } // UseSupport -> use_support - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = task.get("UseSupport") - { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = task.get("UseSupport") { insert!(params, "use_support" => *use_support); } // UseSupportNonFriend -> use_nonfriend_support diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index 0baf04c8..89c109e6 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -143,7 +143,13 @@ impl FindFile for T where T: FromFile {} impl FindFileOrDefault for T where T: FromFile + Default {} -pub fn convert(file: &Path, out: Option<&Path>, ft: Option, gui: bool) -> Result<()> { +pub fn convert( + file: &Path, + out: Option<&Path>, + ft: Option, + gui: bool, + gui_config: Option<&str>, +) -> Result<()> { use maa_dirs::Ensure; if gui && !matches!(Filetype::parse_filetype(file), Some(Filetype::Json)) { @@ -163,7 +169,7 @@ pub fn convert(file: &Path, out: Option<&Path>, ft: Option, gui: bool) let mut value = MAAValue::from_file(file)?; if gui { - value = gui::select_configuration(value)?; + value = gui::select_configuration(value, gui_config)?; value = gui::convert(value)?; } @@ -317,9 +323,9 @@ mod tests { let value = object!("z" => 1, "a" => "test", "m" => false); Json.write(&input, &value).unwrap(); - super::super::convert(&input, None, Some(Json), false).unwrap(); - super::super::convert(&input, Some(&toml), None, false).unwrap(); - super::super::convert(&input, Some(&toml), Some(Yaml), false).unwrap(); + super::super::convert(&input, None, Some(Json), false, None).unwrap(); + super::super::convert(&input, Some(&toml), None, false, None).unwrap(); + super::super::convert(&input, Some(&toml), Some(Yaml), false, None).unwrap(); assert_eq!(MAAValue::from_file(&toml).unwrap(), value); assert_eq!(MAAValue::from_file(&yaml).unwrap(), value); @@ -333,7 +339,7 @@ mod tests { let value = object!("z" => 1); Filetype::Json.write(&input, &value).unwrap(); - assert!(super::super::convert(&input, None, None, false).is_err()); + assert!(super::super::convert(&input, None, None, false, None).is_err()); } #[test] @@ -367,7 +373,7 @@ mod tests { .collect(); assert_eq!(inner_keys, ["k2", "k1"]); - super::super::convert(&input, Some(&yaml), None, false).unwrap(); + super::super::convert(&input, Some(&yaml), None, false, None).unwrap(); assert_eq!( std::fs::read_to_string(&yaml).unwrap(), diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 581f06de..3e59c781 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -108,7 +108,14 @@ fn main() -> Result<()> { output, format, gui, - } => config::convert(&input, output.as_deref(), format, gui)?, + gui_config, + } => config::convert( + &input, + output.as_deref(), + format, + gui, + gui_config.as_deref(), + )?, Command::Activity { client } => activity::display_stage_activity(client)?, Command::Remainder { divisor, timezone } => { use crate::config::task::{TimeOffset, remainder_of_day_mod}; From dd25598f8fc224e683bdaf75446d48cd720ae6b5 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Tue, 4 Aug 2026 00:31:54 +0800 Subject: [PATCH 06/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20migrate?= =?UTF-8?q?=20WPF=20GUI=20config=20for=20maa=20v6.16.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retarget GUI migration to maa migrate wpf and the newer gui.new.json shape from maa v6.16.3. StartUp now maps RuntimeSettings ClientType / StartGame and conditional AccountName; other task types remain as-is for follow-up. zh: 按 maa v6.16.3 重构 WPF 迁移,并完成 StartUp 相关逻辑 --- .../maa-cli/config_examples/wpf/gui.new.json | 1439 +++++++++++++++++ .../maa-cli/config_examples/wpf/gui.new.toml | 150 ++ crates/maa-cli/docs/zh-CN/migrate.md | 46 + crates/maa-cli/src/command.rs | 93 +- crates/maa-cli/src/config/migrate/mod.rs | 170 ++ .../src/config/{gui.rs => migrate/wpf.rs} | 921 ++++++++--- crates/maa-cli/src/config/mod.rs | 35 +- crates/maa-cli/src/main.rs | 14 +- 8 files changed, 2566 insertions(+), 302 deletions(-) create mode 100644 crates/maa-cli/config_examples/wpf/gui.new.json create mode 100644 crates/maa-cli/config_examples/wpf/gui.new.toml create mode 100644 crates/maa-cli/docs/zh-CN/migrate.md create mode 100644 crates/maa-cli/src/config/migrate/mod.rs rename crates/maa-cli/src/config/{gui.rs => migrate/wpf.rs} (59%) diff --git a/crates/maa-cli/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json new file mode 100644 index 00000000..2d2b1718 --- /dev/null +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -0,0 +1,1439 @@ +{ + "Configurations": { + "Default": { + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "TO-5", + "LS-6", + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": true, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": false, + "Name": "理智作战", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "CustomFileType": "user_defined", + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" + }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, + { + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/08/03 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": false, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" + }, + { + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" + }, + { + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": false, + "TaskType": "UserDataUpdate" + } + ], + "Gui": { + "ConnectSettings": { + "AutoDetect": false, + "AlwaysAutoDetect": false, + "Config": "MuMuEmulator12", + "AdbPath": "C:\\Program Files\\Netease\\MuMuPlayer\\nx_main\\adb.exe", + "AdbReplaced": false, + "Address": "127.0.0.1:16384", + "AddressHistory": [ + "127.0.0.1:16384" + ], + "Extras": { + "LDPlayer": { + "EmulatorPath": "", + "ManualSetIndex": false, + "InstanceIndex": 0, + "IsEnabled": false + }, + "MuMuEmulator12": { + "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", + "EnableBridgeConnection": false, + "EnableTouch": true, + "InstanceIndex": 0, + "IsEnabled": true + }, + "Win32Extra": { + "ScreencapMethod": "FramePool", + "MouseMethod": "SendMessageWithCursorPos", + "KeyboardMethod": "SendMessage" + }, + "BluestacksExtra": { + "ConfigKeyword": "", + "ConfigPath": "" + } + }, + "AllowAdbRestart": true, + "AllowAdbHardRestart": true, + "TouchMode": "MumuExtras", + "EnableAdbLite": false, + "KillAdbOnExit": false + }, + "Performance": { + "UseGpu": true, + "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", + "GpuInstancePath": "", + "AllowDeprecatedGpu": false + }, + "ExternalNotification": { + "Configs": [], + "SendWhenComplete": true, + "ShowWhenCompleteWithDetails": false, + "SendWhenError": true, + "SendWhenStalled": false + }, + "RemoteControl": { + "RemoteControlGetTaskEndpointUri": "", + "RemoteControlReportStatusUri": "", + "RemoteControlUserIdentity": "", + "RemoteControlDeviceIdentity": "", + "RemoteControlPollIntervalMs": 1000 + }, + "RuntimeSettings": { + "ClientType": "Official", + "StartGame": true, + "DeployWithPause": false, + "AutoRestartOnDrop": true, + "RoguelikeDelayAbortUntilCombatComplete": false, + "PreRunScript": "", + "PostRunScript": "", + "ExecuteScriptOnCopilot": false, + "ExecuteScriptOnManualStop": false, + "BlockSleep": false, + "BlockSleepWithScreenOn": true, + "ReportToPenguin": true, + "PenguinId": "482853894", + "ReportToYituliu": true, + "EnableStallTimeout": true, + "StallTimeoutReminderIntervalMinutes": 30, + "StallTimeoutMinutes": 25 + }, + "StartUpSettings": { + "RunDirectly": false, + "SkipStartupAutoRunAfterUpdate": true, + "StartEmulator": false, + "RestartEmulatorWhenAdbFailed": false, + "EmulatorPath": "", + "EmulatorAddCommand": "", + "EmulatorWaitSeconds": 60 + }, + "WindowTitlePrefix": "", + "PostActions": "ExitEmulator, ExitSelf", + "AchievementPopupDisabled": false, + "AchievementPopupAutoClose": false + }, + "Toolbox": { + "OperBoxExportFormat": "Clipboard", + "GachaShowDisclaimerNoMore": false, + "PeepTargetFps": 20, + "ChooseLevel3": true, + "ChooseLevel3Time": 540, + "ChooseLevel4": true, + "ChooseLevel4Time": 540, + "ChooseLevel5": true, + "ChooseLevel6": true, + "AutoSetTime": true, + "ShowPotential": true + }, + "Copilot": { + "TaskList": [ + { + "Name": "TO-1", + "FilePath": "config\\copilot\\TO-1.json", + "CopilotId": 98585, + "IsRaid": false, + "IsChecked": false, + "Index": 0 + }, + { + "Name": "TO-TR-1", + "FilePath": "config\\copilot\\TO-TR-1.json", + "CopilotId": 98593, + "IsRaid": false, + "IsChecked": false, + "Index": 1 + }, + { + "Name": "TO-2", + "FilePath": "config\\copilot\\TO-2.json", + "CopilotId": 98591, + "IsRaid": false, + "IsChecked": false, + "Index": 2 + }, + { + "Name": "TO-3", + "FilePath": "config\\copilot\\TO-3.json", + "CopilotId": 98592, + "IsRaid": false, + "IsChecked": false, + "Index": 3 + }, + { + "Name": "TO-4", + "FilePath": "config\\copilot\\TO-4.json", + "CopilotId": 98597, + "IsRaid": false, + "IsChecked": false, + "Index": 4 + }, + { + "Name": "TO-5", + "FilePath": "config\\copilot\\TO-5.json", + "CopilotId": 98606, + "IsRaid": false, + "IsChecked": false, + "Index": 5 + }, + { + "Name": "TO-6", + "FilePath": "config\\copilot\\TO-6.json", + "CopilotId": 98608, + "IsRaid": false, + "IsChecked": false, + "Index": 6 + }, + { + "Name": "TO-7", + "FilePath": "config\\copilot\\TO-7.json", + "CopilotId": 98632, + "IsRaid": false, + "IsChecked": false, + "Index": 7 + }, + { + "Name": "TO-8", + "FilePath": "config\\copilot\\TO-8.json", + "CopilotId": 98634, + "IsRaid": false, + "IsChecked": false, + "Index": 8 + }, + { + "Name": "TO-9", + "FilePath": "config\\copilot\\TO-9.json", + "CopilotId": 98636, + "IsRaid": false, + "IsChecked": false, + "Index": 9 + } + ], + "EnableUserAdditional": false, + "UserAdditional": [], + "SupportMode": "Random", + "SelectFormation": 1, + "LoopTimes": 1 + }, + "TaskSelectedIndex": 1 + }, + "Dev": { + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "LS-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "CustomFileType": "user_defined", + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" + }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, + { + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/07/05 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": true, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" + }, + { + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" + }, + { + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" + } + ], + "Gui": { + "ConnectSettings": { + "AutoDetect": false, + "AlwaysAutoDetect": false, + "Config": "MuMuEmulator12", + "AdbPath": "C:\\Program Files\\Netease\\MuMuPlayer\\nx_main\\adb.exe", + "AdbReplaced": false, + "Address": "127.0.0.1:16384", + "AddressHistory": [ + "127.0.0.1:16384" + ], + "Extras": { + "LDPlayer": { + "EmulatorPath": "", + "ManualSetIndex": false, + "InstanceIndex": 0, + "IsEnabled": false + }, + "MuMuEmulator12": { + "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", + "EnableBridgeConnection": false, + "EnableTouch": false, + "InstanceIndex": 0, + "IsEnabled": true + }, + "Win32Extra": { + "ScreencapMethod": "FramePool", + "MouseMethod": "SendMessageWithCursorPos", + "KeyboardMethod": "SendMessage" + }, + "BluestacksExtra": { + "ConfigKeyword": "", + "ConfigPath": "" + } + }, + "AllowAdbRestart": true, + "AllowAdbHardRestart": true, + "TouchMode": "MiniTouch", + "EnableAdbLite": false, + "KillAdbOnExit": false + }, + "Performance": { + "UseGpu": true, + "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", + "GpuInstancePath": "", + "AllowDeprecatedGpu": false + }, + "ExternalNotification": { + "Configs": [], + "SendWhenComplete": true, + "ShowWhenCompleteWithDetails": false, + "SendWhenError": true, + "SendWhenStalled": false + }, + "RemoteControl": { + "RemoteControlGetTaskEndpointUri": "", + "RemoteControlReportStatusUri": "", + "RemoteControlUserIdentity": "", + "RemoteControlDeviceIdentity": "", + "RemoteControlPollIntervalMs": 1000 + }, + "RuntimeSettings": { + "ClientType": "Official", + "StartGame": true, + "DeployWithPause": false, + "AutoRestartOnDrop": true, + "RoguelikeDelayAbortUntilCombatComplete": false, + "PreRunScript": "", + "PostRunScript": "", + "ExecuteScriptOnCopilot": false, + "ExecuteScriptOnManualStop": false, + "BlockSleep": false, + "BlockSleepWithScreenOn": true, + "ReportToPenguin": true, + "PenguinId": "482853894", + "ReportToYituliu": true, + "EnableStallTimeout": true, + "StallTimeoutReminderIntervalMinutes": 30, + "StallTimeoutMinutes": 25 + }, + "StartUpSettings": { + "RunDirectly": false, + "SkipStartupAutoRunAfterUpdate": true, + "StartEmulator": false, + "RestartEmulatorWhenAdbFailed": false, + "EmulatorPath": "", + "EmulatorAddCommand": "", + "EmulatorWaitSeconds": 60 + }, + "WindowTitlePrefix": "", + "PostActions": "ExitEmulator, ExitSelf, Shutdown", + "AchievementPopupDisabled": false, + "AchievementPopupAutoClose": false + }, + "Toolbox": { + "OperBoxExportFormat": "Clipboard", + "GachaShowDisclaimerNoMore": false, + "PeepTargetFps": 20, + "ChooseLevel3": true, + "ChooseLevel3Time": 540, + "ChooseLevel4": true, + "ChooseLevel4Time": 540, + "ChooseLevel5": true, + "ChooseLevel6": true, + "AutoSetTime": true, + "ShowPotential": true + }, + "Copilot": { + "TaskList": [ + { + "Name": "CF-EX-2", + "FilePath": "config\\copilot\\CF-EX-2.json", + "CopilotId": 95705, + "IsRaid": false, + "IsChecked": true, + "Index": 0 + }, + { + "Name": "CF-EX-2", + "FilePath": "config\\copilot\\CF-EX-2.json", + "CopilotId": 95705, + "IsRaid": true, + "IsChecked": true, + "Index": 1 + } + ], + "EnableUserAdditional": false, + "UserAdditional": [], + "SupportMode": "Random", + "SelectFormation": 1, + "LoopTimes": 1 + }, + "TaskSelectedIndex": 1 + }, + "26/07/10 18:36:14": { + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": true, + "TaskType": "StartUp" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "LS-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false + }, + "Name": "日常经验本", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, + "StagePlan": [ + "CE-6" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": false, + "AnnihilationStage": "Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": false, + "Tuesday": true, + "Wednesday": false, + "Thursday": true, + "Friday": false, + "Saturday": true + }, + "Name": "日常龙门币", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "InfrastTask", + "Mode": "Normal", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "CustomFileType": "user_defined", + "Filename": "", + "PlanSelect": -1, + "RoomList": [ + { + "Room": "Mfg" + }, + { + "Room": "Trade" + }, + { + "Room": "Control" + }, + { + "Room": "Power" + }, + { + "Room": "Reception" + }, + { + "Room": "Office" + }, + { + "Room": "Dorm" + }, + { + "Room": "Processing" + }, + { + "Room": "Training" + } + ], + "Name": "", + "IsEnable": true, + "TaskType": "Infrast" + }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, + { + "$type": "MallTask", + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "CreditFightLastTime": "2026/06/08 00:00:00", + "CreditFightOnceADay": true, + "VisitFriends": true, + "VisitFriendsOnceADay": true, + "VisitFriendsLastTime": "2026/07/10 00:00:00", + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "IsCreditFightAvailable": false, + "IsVisitFriendsAvailable": true, + "Name": "", + "IsEnable": true, + "TaskType": "Mall" + }, + { + "$type": "AwardTask", + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "RoguelikeTask", + "Theme": "JieGarden", + "Difficulty": 2147483647, + "Mode": "Investment", + "Squad": "指挥分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 999999, + "CoreChar": "维什戴尔", + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "", + "IsEnable": false, + "TaskType": "Roguelike" + }, + { + "$type": "ReclamationTask", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "Name": "", + "IsEnable": false, + "TaskType": "Reclamation" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" + } + ], + "Gui": { + "ConnectSettings": { + "AutoDetect": false, + "AlwaysAutoDetect": false, + "Config": "MuMuEmulator12", + "AdbPath": "C:\\Program Files\\Netease\\MuMuPlayer\\nx_main\\adb.exe", + "AdbReplaced": false, + "Address": "127.0.0.1:16384", + "AddressHistory": [ + "127.0.0.1:16384" + ], + "Extras": { + "LDPlayer": { + "EmulatorPath": "", + "ManualSetIndex": false, + "InstanceIndex": 0, + "IsEnabled": false + }, + "MuMuEmulator12": { + "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", + "EnableBridgeConnection": false, + "EnableTouch": false, + "InstanceIndex": 0, + "IsEnabled": true + }, + "Win32Extra": { + "ScreencapMethod": "FramePool", + "MouseMethod": "SendMessageWithCursorPos", + "KeyboardMethod": "SendMessage" + }, + "BluestacksExtra": { + "ConfigKeyword": "", + "ConfigPath": "" + } + }, + "AllowAdbRestart": true, + "AllowAdbHardRestart": true, + "TouchMode": "MiniTouch", + "EnableAdbLite": false, + "KillAdbOnExit": false + }, + "Performance": { + "UseGpu": true, + "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", + "GpuInstancePath": "", + "AllowDeprecatedGpu": false + }, + "ExternalNotification": { + "Configs": [], + "SendWhenComplete": true, + "ShowWhenCompleteWithDetails": false, + "SendWhenError": true, + "SendWhenStalled": false + }, + "RemoteControl": { + "RemoteControlGetTaskEndpointUri": "", + "RemoteControlReportStatusUri": "", + "RemoteControlUserIdentity": "", + "RemoteControlDeviceIdentity": "", + "RemoteControlPollIntervalMs": 1000 + }, + "RuntimeSettings": { + "ClientType": "Official", + "StartGame": true, + "DeployWithPause": false, + "AutoRestartOnDrop": true, + "RoguelikeDelayAbortUntilCombatComplete": false, + "PreRunScript": "", + "PostRunScript": "", + "ExecuteScriptOnCopilot": false, + "ExecuteScriptOnManualStop": false, + "BlockSleep": false, + "BlockSleepWithScreenOn": true, + "ReportToPenguin": true, + "PenguinId": "482853894", + "ReportToYituliu": true, + "EnableStallTimeout": true, + "StallTimeoutReminderIntervalMinutes": 30, + "StallTimeoutMinutes": 25 + }, + "StartUpSettings": { + "RunDirectly": false, + "SkipStartupAutoRunAfterUpdate": true, + "StartEmulator": false, + "RestartEmulatorWhenAdbFailed": false, + "EmulatorPath": "", + "EmulatorAddCommand": "", + "EmulatorWaitSeconds": 60 + }, + "WindowTitlePrefix": "", + "PostActions": "ExitEmulator, ExitSelf", + "AchievementPopupDisabled": false, + "AchievementPopupAutoClose": false + }, + "Toolbox": { + "OperBoxExportFormat": "Clipboard", + "GachaShowDisclaimerNoMore": false, + "PeepTargetFps": 20, + "ChooseLevel3": true, + "ChooseLevel3Time": 540, + "ChooseLevel4": true, + "ChooseLevel4Time": 540, + "ChooseLevel5": true, + "ChooseLevel6": true, + "AutoSetTime": true, + "ShowPotential": true + }, + "Copilot": { + "TaskList": [ + { + "Name": "BD-4", + "FilePath": "config\\copilot\\BD-4.json", + "CopilotId": 97204, + "IsRaid": true, + "IsChecked": false, + "Index": 0 + }, + { + "Name": "BD-5", + "FilePath": "config\\copilot\\BD-5.json", + "CopilotId": 97216, + "IsRaid": false, + "IsChecked": false, + "Index": 1 + }, + { + "Name": "BD-5", + "FilePath": "config\\copilot\\BD-5.json", + "CopilotId": 97216, + "IsRaid": true, + "IsChecked": false, + "Index": 2 + }, + { + "Name": "BD-6", + "FilePath": "config\\copilot\\BD-6.json", + "CopilotId": 97218, + "IsRaid": false, + "IsChecked": false, + "Index": 3 + }, + { + "Name": "BD-6", + "FilePath": "config\\copilot\\BD-6.json", + "CopilotId": 97218, + "IsRaid": true, + "IsChecked": false, + "Index": 4 + }, + { + "Name": "BD-7", + "FilePath": "config\\copilot\\BD-7.json", + "CopilotId": 97219, + "IsRaid": false, + "IsChecked": false, + "Index": 5 + }, + { + "Name": "BD-7", + "FilePath": "config\\copilot\\BD-7.json", + "CopilotId": 97219, + "IsRaid": true, + "IsChecked": false, + "Index": 6 + }, + { + "Name": "BD-8", + "FilePath": "config\\copilot\\BD-8.json", + "CopilotId": 97224, + "IsRaid": false, + "IsChecked": false, + "Index": 7 + }, + { + "Name": "BD-8", + "FilePath": "config\\copilot\\BD-8.json", + "CopilotId": 97224, + "IsRaid": true, + "IsChecked": false, + "Index": 8 + }, + { + "Name": "BD-S-2", + "FilePath": "config\\copilot\\BD-S-2.json", + "CopilotId": 97238, + "IsRaid": false, + "IsChecked": false, + "Index": 9 + } + ], + "EnableUserAdditional": false, + "UserAdditional": [], + "SupportMode": "WhenNeeded", + "SelectFormation": 1, + "LoopTimes": 1 + }, + "TaskSelectedIndex": 3 + } + }, + "Timers": { + "ForceScheduledStart": false, + "ShowWindowBeforeForceScheduledStart": false, + "CustomConfig": false, + "List": [ + { + "Id": 0, + "IsEnabled": false, + "Config": "Default", + "Hour": 0, + "Minute": 0 + }, + { + "Id": 1, + "IsEnabled": false, + "Config": "Default", + "Hour": 3, + "Minute": 0 + }, + { + "Id": 2, + "IsEnabled": false, + "Config": "Default", + "Hour": 6, + "Minute": 0 + }, + { + "Id": 3, + "IsEnabled": false, + "Config": "Default", + "Hour": 9, + "Minute": 0 + }, + { + "Id": 4, + "IsEnabled": false, + "Config": "Default", + "Hour": 12, + "Minute": 0 + }, + { + "Id": 5, + "IsEnabled": false, + "Config": "Default", + "Hour": 15, + "Minute": 0 + }, + { + "Id": 6, + "IsEnabled": false, + "Config": "Default", + "Hour": 18, + "Minute": 0 + }, + { + "Id": 7, + "IsEnabled": false, + "Config": "Default", + "Hour": 21, + "Minute": 0 + } + ] + }, + "ConfigVersion": 1, + "Current": "Default", + "Update": { + "Name": "v6.16.3", + "UpdatePackage": "", + "IsFirstBoot": false, + "DoNotShowUpdate": false, + "VersionType": "Beta", + "AllowNightlyUpdates": false, + "HasAcknowledgedNightlyWarning": false, + "UpdateSource": "Github", + "ForceGithubGlobalSource": false, + "MirrorChyanCdk": "", + "MirrorChyanCdkExpiredTime": 0, + "CheckOnStartup": true, + "CheckOnSchedule": false, + "Proxy": "", + "ProxyType": "Http", + "AutoDownloadUpdatePackage": true, + "AutoInstallUpdatePackage": true, + "ShowUpdaterConsole": false, + "ShowUpdaterProgress": true + }, + "AnnouncementInfo": { + "DoNotShowAgain": true, + "DoNotShow": false + }, + "Gui": { + "DarkMode": "SyncWithOs", + "UseNotify": true, + "MainTasksInvertNullFunction": false, + "Localization": "zh-cn", + "OperNameLanguage": "OperNameLanguageMAA", + "UseTray": true, + "MinimizeToTray": false, + "MinimizeOnStartup": false, + "HideCloseButton": false, + "WindowTitleSelectShowList": "2 3 4", + "WindowTitleScrollable": false, + "LogItemDateFormat": "HH:mm:ss", + "WindowPlacement": { + "Length": 44, + "Flags": 0, + "ShowCmd": 1, + "MinPosition": { + "X": -1, + "Y": -1 + }, + "MaxPosition": { + "X": -1, + "Y": -1 + }, + "NormalPosition": { + "Left": 123, + "Top": 220, + "Right": 1563, + "Bottom": 1320 + } + }, + "LoadWindowPlacement": true, + "SaveWindowPlacement": true, + "InverseClearMode": "Clear", + "TaskQueueInverseMode": false, + "UseCardLog": true, + "MaxNumberOfLogThumbnails": 100, + "SoberLanguage": "zh-cn", + "Hangover": false, + "LastBuyWineTime": "2026-06-08T00:00:00+08:00", + "CustomCulture": "", + "IgnoreBadModulesAndUseSoftwareRendering": false, + "OverlayTarget": { + "ProcessId": 0, + "Hwnd": 0 + }, + "SettingOrders": [ + "SwitchConfiguration", + "ScheduleSettings", + "PerformanceSettings", + "GameSettings", + "ConnectionSettings", + "StartupSettings", + "RemoteControlSettings", + "UiSettings", + "BackgroundSettings", + "ExternalNotificationSettings", + "HotKeySettings", + "UpdateSettings", + "AchievementSettings", + "IssueReport", + "AboutUs" + ], + "CollapesStates": [], + "HotKeys": { + "ShowGui": { + "Key": "M", + "Modifiers": "Alt, Control, Shift" + }, + "LinkStart": { + "Key": "L", + "Modifiers": "Alt, Control, Shift" + } + }, + "GuideStep": 7, + "Background": { + "ImagePath": "background/background.png", + "StretchMode": "Fill", + "Opacity": 50, + "BlurEffectRadius": 5 + }, + "BackgroundMonetEnabled": false, + "BackgroundMonetMode": "Auto", + "BackgroundMonetCustomColor": "#326CF3", + "BackgroundMonetCachedColor": "" + } +} \ No newline at end of file diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml new file mode 100644 index 00000000..46867df3 --- /dev/null +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -0,0 +1,150 @@ +[[tasks]] +type = "StartUp" + +[tasks.params] +client_type = "Official" +start_game_enabled = true + +[[tasks]] +type = "Fight" +name = "理智作战" + +[tasks.params] +stage = [ + "TO-5", + "LS-6", + "CE-6", +] +medicine_expire_days = 2 + +[[tasks]] +type = "Infrast" +name = "" + +[tasks.params] +mode = 0 +facility = [ + "Mfg", + "Trade", + "Control", + "Power", + "Reception", + "Office", + "Dorm", + "Processing", + "Training", +] +drones = "Money" +threshold = 0.3 +replenish = true +dorm_notstationed_enabled = true +dorm_trust_enabled = true +reception_message_board = true +reception_clue_exchange = true +reception_send_clue = true + +[[tasks]] +type = "Recruit" +name = "" + +[tasks.params] +times = 4 +extra_tags_mode = 0 +refresh = true +expedite = true +select = [ + 5, + 4, + 3, +] +confirm = [ + 5, + 4, + 3, +] + +[tasks.params.recruitment_time] +4 = 540 +3 = 540 + +[[tasks]] +type = "Mall" +name = "" + +[tasks.params] +shopping = true +credit_fight = false +formation_index = 0 +visit_friends = true +buy_first = [ + "加急许可", + "招聘许可", +] +blacklist = [ + "碳", + "家具", +] +force_shopping_if_credit_full = true +only_buy_discount = false +reserve_max_credit = false + +[[tasks]] +type = "Award" +name = "" + +[tasks.params] +award = true +mail = true +recruit = false +orundum = true +mining = true +specialaccess = true + +[[tasks]] +type = "Roguelike" +name = "" + +[[tasks.variants]] + +[tasks.variants.condition] +type = "Not" + +[tasks.variants.condition.condition] +type = "Always" + +[tasks.variants.params] +theme = "JieGarden" +mode = 1 +squad = "指挥分队" +roles = "稳扎稳打" +core_char = "维什戴尔" +starts_count = 999999 +investment_enabled = true +investments_count = 999 +investment_with_more_score = false +stop_when_investment_full = true +stop_at_final_boss = false +stop_at_max_level = false +use_support = false +use_nonfriend_support = false +refresh_trader_with_dice = false +start_with_elite_two = false +only_start_with_elite_two = false + +[[tasks]] +type = "Reclamation" +name = "" + +[[tasks.variants]] + +[tasks.variants.condition] +type = "Not" + +[tasks.variants.condition.condition] +type = "Always" + +[tasks.variants.params] +theme = "Tales" +mode = 1 +increment_mode = 0 +num_craft_batches = 16 diff --git a/crates/maa-cli/docs/zh-CN/migrate.md b/crates/maa-cli/docs/zh-CN/migrate.md new file mode 100644 index 00000000..c4e1be9b --- /dev/null +++ b/crates/maa-cli/docs/zh-CN/migrate.md @@ -0,0 +1,46 @@ +# 配置迁移 + +`maa migrate` 用于把已有配置迁移到当前 maa-cli 所使用的配置形式。 + +不同来源对应不同的子命令。本文按迁移来源分节说明;目前仅完成 WPF GUI,其他来源会在后续补充。 + +## 目录 + +- [WPF GUI](#wpf-gui) +- [config-v1 到 config-v2](#config-v1-到-config-v2)(TODO) + +## WPF GUI + +把 MAA WPF GUI 的配置迁移为 maa-cli 任务配置: + +```bash +maa migrate wpf [output] [-f toml|yaml|json] [--config ] +``` + +`` 应指向 GUI 导出的配置文件: + +| 平台 | 输入文件 | +| --- | --- | +| Windows | `config/gui.new.json`(相对 MAA 安装或用户配置目录;以你本机实际路径为准) | +| Linux | TODO:待补充路径与测试 | +| macOS | TODO:待补充路径与测试 | + +如果文件中有多套配置,可用 `--config` 或环境变量 `MAA_GUI_CONFIG` 指定名称;未指定时会交互选择,默认使用文件中的当前配置(`Current`)。 + +迁移可能不完整:不支持的任务类型会被跳过;GUI 中已关闭的任务会保留在结果里,但默认不会执行;未能对应的参数会出现在迁移摘要中。命令结束后会输出这些汇总,方便你对照检查。 + +### StartUp + +对应 GUI 任务队列中的启动任务,迁移为 maa-cli 的 `StartUp` 任务。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Configurations.<配置名>.Gui.RuntimeSettings.ClientType` | `tasks[].params.client_type` | 合法值(`Official` / `Bilibili` / `txwy` / `YoStarEN` / `YoStarJP` / `YoStarKR`)直接写入;缺失或不合法时将写入备选值,备选值将在 maa-cli 运行时交互确认最终值 | +| `Configurations.<配置名>.Gui.RuntimeSettings.StartGame` | `tasks[].params.start_game_enabled` | 有值时写入 | +| `Configurations.<配置名>.TaskQueue[] \| {AccountSwitchEnabled, AccountName}` | `tasks[].params.account_name` | 仅当 GUI 配置 `AccountSwitchEnabled` 为 `true` 时读取 `AccountName` 并尝试写入迁移配置 `account_name` | + +TODO:`account_name` 的转换**我没有测试过**(未做实机账号切换验证)。 + +## config-v1 到 config-v2 + +TODO diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 113a3f3a..39231cac 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -171,12 +171,14 @@ pub(crate) enum Command { }, /// Convert file format between TOML, YAML and JSON /// - /// This command will convert a file from TOML, YAML or JSON format to another format. + /// This command keeps the data structure unchanged and only converts serialization + /// format between TOML, YAML and JSON. /// This is useful when you want to write your infrastructure configuration /// in TOML or YAML format, and use it in MaaCore, which only supports JSON format. /// - /// It may also be useful when you want to migrate your cli configuration from - /// one format to another format. + /// To migrate from an external configuration model (e.g. MAA WPF GUI), use + /// `maa migrate` instead. To install an existing maa-cli config into the config + /// directory, use `maa import`. Convert { /// Path of the input file input: PathBuf, @@ -188,15 +190,15 @@ pub(crate) enum Command { /// file. If output file is not specified, the output will be default to "json". #[arg(short, long)] format: Option, - /// Convert a GUI profile TaskQueue into maa-cli task config - #[arg(short = 'g', long)] - gui: bool, - /// Select a named configuration when converting a multi-profile GUI export - /// - /// Can also be set with the `MAA_GUI_CONFIG` environment variable. - #[arg(long)] - gui_config: Option, }, + /// Migrate configuration from an external model into maa-cli config + /// + /// Unlike `convert` (same data structure, different format) and `import` + /// (install an existing maa-cli config into the config directory), `migrate` + /// remaps an external configuration model into maa-cli's config model. + /// The migration may be lossy; skipped tasks and fields are summarized afterward. + #[command(subcommand)] + Migrate(MigrateCommand), /// Show stage activity of given client Activity { #[arg(default_value_t = config::task::ClientType::Official)] @@ -254,6 +256,33 @@ pub(crate) enum Command { }, } +#[derive(Subcommand)] +pub(crate) enum MigrateCommand { + /// Migrate a MAA WPF GUI profile TaskQueue into maa-cli task config + /// + /// The input is typically a GUI-exported JSON profile. When multiple + /// configurations exist, use `--config` or `MAA_GUI_CONFIG` to select one. + /// Disabled GUI tasks are kept inactive (never-true condition) so they will + /// not run until re-enabled manually. + Wpf { + /// Path of the input GUI profile + input: PathBuf, + /// Path of the output file, if not specified, the output will be printed to stdout + output: Option, + /// Format of the output file, can be one of "toml", "yaml" and "json" + /// + /// If not specified, the format will be guessed from the file extension of the output + /// file. If output file is not specified, the output will be default to "json". + #[arg(short, long)] + format: Option, + /// Select a named configuration when migrating a multi-profile GUI export + /// + /// Can also be set with the `MAA_GUI_CONFIG` environment variable. + #[arg(long)] + config: Option, + }, +} + #[cfg(feature = "cli_installer")] #[derive(Subcommand)] #[command(name = "self")] @@ -560,8 +589,6 @@ mod test { input, output: None, format: None, - gui: false, - gui_config: None, } if input == Path::new("input.toml") ); @@ -589,15 +616,47 @@ mod test { .. } if output == Path::new("output.json") ); + } + #[test] + fn migrate() { assert_matches!( - parse_from(["maa", "convert", "-g", "profile.json", "tasks.toml"]).command, - Command::Convert { + parse_from(["maa", "migrate", "wpf", "profile.json"]).command, + Command::Migrate(MigrateCommand::Wpf { + input, + output: None, + format: None, + config: None, + }) if input == Path::new("profile.json") + ); + + assert_matches!( + parse_from([ + "maa", + "migrate", + "wpf", + "profile.json", + "tasks.toml", + "--config", + "Default" + ]) + .command, + Command::Migrate(MigrateCommand::Wpf { input, output: Some(output), - gui: true, + format: None, + config: Some(config), + }) if input == Path::new("profile.json") + && output == Path::new("tasks.toml") + && config == "Default" + ); + + assert_matches!( + parse_from(["maa", "migrate", "wpf", "profile.json", "-f", "yaml"]).command, + Command::Migrate(MigrateCommand::Wpf { + format: Some(config::Filetype::Yaml), .. - } if input == Path::new("profile.json") && output == Path::new("tasks.toml") + }) ); } diff --git a/crates/maa-cli/src/config/migrate/mod.rs b/crates/maa-cli/src/config/migrate/mod.rs new file mode 100644 index 00000000..313b3186 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -0,0 +1,170 @@ +//! Migrate configuration from external models into maa-cli config shapes. +//! +//! Unlike [`super::convert`], which only changes serialization format for the same +//! data structure, migration may select profiles, remap fields, and drop unsupported +//! content. Results are therefore potentially lossy and always accompanied by a summary. + +mod wpf; + +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use maa_value::value::MAAValue; + +use super::{Filetype, FromFile}; + +/// Summary of lossy choices made during migration. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MigrationSummary { + /// Unsupported task types that were not written. + pub skipped_tasks: Vec, + /// Tasks with `IsEnable = false`, kept inactive in the output. + pub disabled_tasks: Vec, + /// Fields present on a supported task that were not mapped. + pub skipped_fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedTask { + pub type_tag: String, + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedField { + pub task_type: String, + pub task_name: Option, + pub field: String, +} + +impl MigrationSummary { + pub fn is_empty(&self) -> bool { + self.skipped_tasks.is_empty() + && self.disabled_tasks.is_empty() + && self.skipped_fields.is_empty() + } + + fn skip_task(&mut self, type_tag: impl Into, name: Option) { + self.skipped_tasks.push(SkippedTask { + type_tag: type_tag.into(), + name, + }); + } + + fn disable_task(&mut self, type_tag: impl Into, name: Option) { + self.disabled_tasks.push(SkippedTask { + type_tag: type_tag.into(), + name, + }); + } + + fn skip_field( + &mut self, + task_type: impl Into, + task_name: Option, + field: impl Into, + ) { + self.skipped_fields.push(SkippedField { + task_type: task_type.into(), + task_name, + field: field.into(), + }); + } + + /// Print a user-facing summary of lossy migration decisions to stderr. + pub fn print(&self) { + if self.is_empty() { + return; + } + + eprintln!("Migration summary (lossy):"); + + if !self.skipped_tasks.is_empty() { + eprintln!(" Skipped tasks (unsupported type):"); + for task in &self.skipped_tasks { + eprintln!(" - {}", format_task_ref(task)); + } + } + + if !self.disabled_tasks.is_empty() { + eprintln!( + " Disabled tasks (kept inactive with never-true condition; will not run):" + ); + for task in &self.disabled_tasks { + eprintln!(" - {}", format_task_ref(task)); + } + } + + if !self.skipped_fields.is_empty() { + eprintln!(" Skipped fields (not migrated):"); + for field in &self.skipped_fields { + match &field.task_name { + Some(name) if !name.is_empty() => { + eprintln!(" - {} \"{}\": {}", field.task_type, name, field.field); + } + _ => { + eprintln!(" - {}: {}", field.task_type, field.field); + } + } + } + } + } +} + +fn format_task_ref(task: &SkippedTask) -> String { + match &task.name { + Some(name) if !name.is_empty() => format!("{} \"{name}\"", task.type_tag), + _ => task.type_tag.clone(), + } +} + +/// Migrate a MAA WPF GUI profile into a maa-cli task config. +/// +/// ```text +/// maa migrate wpf [output] +/// ``` +pub fn wpf( + file: &Path, + out: Option<&Path>, + ft: Option, + config_name: Option<&str>, +) -> Result<()> { + use maa_dirs::Ensure; + + if !matches!(Filetype::parse_filetype(file), Some(Filetype::Json)) { + log::warn!( + "`maa migrate wpf` expects a MAA WPF GUI profile (typically JSON); \ + input {} is not JSON", + file.display() + ); + } + + let ft = ft.or_else(|| { + out.and_then(|path| path.extension()) + .and_then(|ext| ext.to_str()) + .and_then(Filetype::parse_extension) + }); + + let value = MAAValue::from_file(file)?; + let value = wpf::select_configuration(value, config_name)?; + let (value, summary) = wpf::migrate(value)?; + + let Some(format) = ft else { + bail!("Format not given") + }; + + if let Some(file) = out { + let file = file.with_extension(format.to_str()); + if let Some(dir) = file.parent() { + dir.ensure()?; + } + format + .write(&file, &value) + .with_context(|| format!("Failed to write migrated file {}", file.display()))?; + } else { + format.write_to(std::io::stdout().lock(), &value)?; + } + + summary.print(); + Ok(()) +} diff --git a/crates/maa-cli/src/config/gui.rs b/crates/maa-cli/src/config/migrate/wpf.rs similarity index 59% rename from crates/maa-cli/src/config/gui.rs rename to crates/maa-cli/src/config/migrate/wpf.rs index f9c391e1..986d9731 100644 --- a/crates/maa-cli/src/config/gui.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -7,15 +7,20 @@ use maa_value::{ userinput::{SelectD, UserInput}, }; +use super::MigrationSummary; + const GUI_CONFIG_ENV: &str = "MAA_GUI_CONFIG"; +/// Meta / structural keys that are never reported as skipped fields. +const META_FIELDS: &[&str] = &["$type", "TaskType", "Name", "IsEnable"]; + /// Pick one configuration from a multi-profile GUI export. /// /// Legacy single-config profiles are returned unchanged. /// -/// When multiple configurations exist, `gui_config` or [`GUI_CONFIG_ENV`] can be +/// When multiple configurations exist, `config_name` or [`GUI_CONFIG_ENV`] can be /// used to select one without an interactive prompt. -pub(crate) fn select_configuration(input: MAAValue, gui_config: Option<&str>) -> Result { +pub(super) fn select_configuration(input: MAAValue, config_name: Option<&str>) -> Result { let Some(configurations_value) = input.get("Configurations") else { return Ok(input); }; @@ -27,7 +32,7 @@ pub(crate) fn select_configuration(input: MAAValue, gui_config: Option<&str>) -> 0 => bail!("GUI profile has no configuration"), 1 => Ok(configurations.values().next().unwrap().clone()), _ => { - let selected_name = resolve_configuration_name(&input, configurations, gui_config)?; + let selected_name = resolve_configuration_name(&input, configurations, config_name)?; configurations .get(&selected_name) .cloned() @@ -39,9 +44,9 @@ pub(crate) fn select_configuration(input: MAAValue, gui_config: Option<&str>) -> fn resolve_configuration_name( input: &MAAValue, configurations: &StringMap, - gui_config: Option<&str>, + config_name: Option<&str>, ) -> Result { - if let Some(name) = gui_config { + if let Some(name) = config_name { return Ok(name.to_string()); } if let Ok(name) = std::env::var(GUI_CONFIG_ENV) { @@ -62,9 +67,8 @@ fn resolve_configuration_name( .context("Failed to select GUI configuration") } -/// Convert a GUI profile `MAAValue` into maa-cli task config shape. -pub(crate) fn convert(input: MAAValue) -> Result { - // GUI profile task list +/// Migrate a GUI profile `MAAValue` into maa-cli task config shape. +pub(super) fn migrate(input: MAAValue) -> Result<(MAAValue, MigrationSummary)> { let queue = input .get("TaskQueue") .context("GUI profile missing TaskQueue")?; @@ -72,49 +76,145 @@ pub(crate) fn convert(input: MAAValue) -> Result { bail!("GUI profile TaskQueue must be an array"); }; + let mut summary = MigrationSummary::default(); let mut tasks = Vec::new(); for task in queue { - if let Some(item) = convert_task(task)? { + if let Some(item) = migrate_task(task, &input, &mut summary)? { tasks.push(item); } } - Ok(object!("tasks" => tasks??)) + Ok((object!("tasks" => tasks??), summary)) } -fn convert_task(task: &MAAValue) -> Result> { - // GUI task discriminator, e.g. "FightTask" +fn migrate_task( + task: &MAAValue, + config: &MAAValue, + summary: &mut MigrationSummary, +) -> Result> { let type_tag = task .get("$type") .and_then(|v| v.as_str()) .context("GUI task missing $type")?; - match type_tag { - "StartUpTask" => start_up::convert_start_up_task(task), - "FightTask" => fight::convert_fight_task(task), - "InfrastTask" => infrast::convert_infrast_task(task), - "RecruitTask" => recruit::convert_recruit_task(task), - "MallTask" => mall::convert_mall_task(task), - "AwardTask" => award::convert_award_task(task), - "RoguelikeTask" => roguelike::convert_roguelike_task(task), - "ReclamationTask" => reclamation::convert_reclamation_task(task), + let name = task_name(task); + let disabled = matches!( + task.get("IsEnable"), + Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) + ); + + let mut item = match type_tag { + "StartUpTask" => start_up::migrate_start_up_task(task, config, summary)?, + "FightTask" => fight::migrate_fight_task(task, summary)?, + "InfrastTask" => infrast::migrate_infrast_task(task, summary)?, + "RecruitTask" => recruit::migrate_recruit_task(task, summary)?, + "MallTask" => mall::migrate_mall_task(task, summary)?, + "AwardTask" => award::migrate_award_task(task, summary)?, + "RoguelikeTask" => roguelike::migrate_roguelike_task(task, summary)?, + "ReclamationTask" => reclamation::migrate_reclamation_task(task, summary)?, type_tag => { - log::warn!("Skipping unsupported GUI task type: {type_tag}"); - Ok(None) + summary.skip_task(type_tag, name); + return Ok(None); + } + }; + + if disabled && let Some(item) = item.as_mut() { + apply_disabled_condition(item); + summary.disable_task(type_tag, name); + } + + Ok(item) +} + +fn task_name(task: &MAAValue) -> Option { + task.get("Name") + .and_then(|v| v.as_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string) +} + +/// Keep a disabled GUI task in the output, but ensure it never becomes active. +fn apply_disabled_condition(item: &mut MAAValue) { + let never = never_condition(); + + if let Some(MAAValue::Array(variants)) = item.get_mut("variants") { + for variant in variants.iter_mut() { + let new_cond = match variant.get("condition") { + Some(cond) => object!( + "type" => "And", + "conditions" => vec![cond.clone(), never.clone()]?? + ), + None => never.clone(), + }; + variant.insert("condition", new_cond); + } + return; + } + + let params = item.get("params").cloned().unwrap_or_default(); + if let Some(map) = item.as_mut_map() { + map.shift_remove("params"); + } + item.insert( + "variants", + MAAValue::Array(vec![object!( + "condition" => never, + "params" => params + )]), + ); +} + +fn never_condition() -> MAAValue { + object!( + "type" => "Not", + "condition" => object!("type" => "Always") + ) +} + +fn report_unhandled_fields( + summary: &mut MigrationSummary, + task: &MAAValue, + task_type: &str, + handled: &[&str], +) { + let name = task_name(task); + let Some(map) = task.as_map() else { + return; + }; + for (key, value) in map { + if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { + continue; + } + if is_meaningful_unhandled(value) { + summary.skip_field(task_type, name.clone(), key.clone()); } } } +/// Whether an unhandled GUI field likely affects runtime behavior if dropped. +fn is_meaningful_unhandled(value: &MAAValue) -> bool { + match value { + MAAValue::Primitive(MAAPrimitive::Bool(v)) => *v, + MAAValue::Primitive(MAAPrimitive::String(s)) => !s.is_empty(), + MAAValue::Primitive(MAAPrimitive::Int(i)) => *i != 0 && *i != i32::MAX, + MAAValue::Primitive(MAAPrimitive::Float(f)) => *f != 0.0, + MAAValue::Array(items) => !items.is_empty(), + MAAValue::Object(map) => !map.is_empty(), + } +} + #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; + use crate::config::migrate::{SkippedField, SkippedTask}; macro_rules! gui_task_test { ($name:ident, $converter:path, $task_json:expr, $($expected:tt)*) => { #[test] fn $name() { let task: MAAValue = serde_json::from_str($task_json).unwrap(); - let actual = $converter(&task).unwrap().unwrap(); + let mut summary = MigrationSummary::default(); + let actual = $converter(&task, &mut summary).unwrap().unwrap(); assert_eq!( serde_json::to_value(&actual).unwrap(), serde_json::json!($($expected)*) @@ -123,7 +223,7 @@ mod tests { }; } - const DEFAULT_PROFILE: &str = include_str!("../../fixtures/gui/default_profile.json"); + const DEFAULT_PROFILE: &str = include_str!("../../../fixtures/gui/default_profile.json"); #[test] fn select_configuration_passes_through_legacy_profile() { @@ -160,7 +260,7 @@ mod tests { } #[test] - fn select_configuration_uses_gui_config_override() { + fn select_configuration_uses_config_name_override() { let input = object!( "Current" => "Default", "Configurations" => object!( @@ -194,14 +294,14 @@ mod tests { #[test] fn missing_task_queue_is_error() { let input = object!("a" => 1); - let err = convert(input).unwrap_err(); + let err = migrate(input).unwrap_err(); assert!(err.to_string().contains("TaskQueue")); } #[test] fn task_queue_not_array_is_error() { let input = object!("TaskQueue" => "not-an-array"); - let err = convert(input).unwrap_err(); + let err = migrate(input).unwrap_err(); assert!(err.to_string().contains("TaskQueue must be an array")); } @@ -213,34 +313,106 @@ mod tests { object!("$type" => "StartUpTask", "IsEnable" => true), ]?? ); - let value = convert(input).unwrap(); + let (value, summary) = migrate(input).unwrap(); let MAAValue::Array(tasks) = value.get("tasks").unwrap() else { panic!("tasks should be an array"); }; assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].get("type").unwrap().as_str().unwrap(), "StartUp"); + assert_eq!( + summary.skipped_tasks, + vec![SkippedTask { + type_tag: "UserDataUpdateTask".into(), + name: Some("update".into()), + }] + ); + } + + #[test] + fn disabled_task_is_kept_inactive() { + let input = object!( + "TaskQueue" => vec![ + object!( + "$type" => "FightTask", + "Name" => "日常经验本", + "IsEnable" => false, + "StagePlan" => vec!["LS-6"]??, + ), + ]?? + ); + let (value, summary) = migrate(input).unwrap(); + let expected: MAAValue = serde_json::from_value(serde_json::json!({ + "tasks": [{ + "type": "Fight", + "name": "日常经验本", + "variants": [{ + "condition": { + "type": "Not", + "condition": { "type": "Always" } + }, + "params": { "stage": "LS-6" } + }] + }] + })) + .unwrap(); + assert_eq!(value, expected); + assert_eq!( + summary.disabled_tasks, + vec![SkippedTask { + type_tag: "FightTask".into(), + name: Some("日常经验本".into()), + }] + ); + } + + #[test] + fn skipped_fields_are_reported() { + let input = object!( + "TaskQueue" => vec![ + object!( + "$type" => "StartUpTask", + "AccountName" => "123****4567", + "AccountSwitchEnabled" => false, + "Name" => "启动游戏", + "IsEnable" => true, + ), + object!( + "$type" => "ReclamationTask", + "Theme" => "Tales", + "Mode" => "ProsperityInSave", + "ClearStore" => true, + "IsEnable" => true, + ), + ]?? + ); + let (_, summary) = migrate(input).unwrap(); + assert!(summary.skipped_fields.contains(&SkippedField { + task_type: "ReclamationTask".into(), + task_name: None, + field: "ClearStore".into(), + })); + // Account switching is off, so AccountName is intentionally ignored (handled). + assert!( + !summary + .skipped_fields + .iter() + .any(|field| { field.task_type == "StartUpTask" && field.field == "AccountName" }) + ); } #[test] fn json_to_json_default_profile() { let input: MAAValue = serde_json::from_str(DEFAULT_PROFILE).unwrap(); - let value = convert(select_configuration(input, None).unwrap()).unwrap(); + let (value, summary) = migrate(select_configuration(input, None).unwrap()).unwrap(); let expected: MAAValue = serde_json::from_value(serde_json::json!({ "tasks": [ { "type": "StartUp", "params": { "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type", - "deps": { - "start_game_enabled": true - } + "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"], }, - "start_game_enabled": { - "default": true, - "description": "start the game" - } + "start_game_enabled": "false" } }, { @@ -349,40 +521,84 @@ mod tests { { "type": "Roguelike", "name": "", - "params": { - "theme": "JieGarden", - "mode": 1, - "squad": "指挥分队", - "roles": "稳扎稳打", - "core_char": "维什戴尔", - "starts_count": 999999, - "investment_enabled": true, - "investments_count": 999, - "investment_with_more_score": false, - "stop_when_investment_full": true, - "stop_at_final_boss": false, - "stop_at_max_level": false, - "use_support": false, - "use_nonfriend_support": false, - "refresh_trader_with_dice": false, - "start_with_elite_two": false, - "only_start_with_elite_two": false - } + "variants": [{ + "condition": { + "type": "Not", + "condition": { "type": "Always" } + }, + "params": { + "theme": "JieGarden", + "mode": 1, + "squad": "指挥分队", + "roles": "稳扎稳打", + "core_char": "维什戴尔", + "starts_count": 999999, + "investment_enabled": true, + "investments_count": 999, + "investment_with_more_score": false, + "stop_when_investment_full": true, + "stop_at_final_boss": false, + "stop_at_max_level": false, + "use_support": false, + "use_nonfriend_support": false, + "refresh_trader_with_dice": false, + "start_with_elite_two": false, + "only_start_with_elite_two": false + } + }] }, { "type": "Reclamation", "name": "", - "params": { - "theme": "Tales", - "mode": 1, - "increment_mode": 0, - "num_craft_batches": 16 - } + "variants": [{ + "condition": { + "type": "Not", + "condition": { "type": "Always" } + }, + "params": { + "theme": "Tales", + "mode": 1, + "increment_mode": 0, + "num_craft_batches": 16 + } + }] } ] })) .unwrap(); assert_eq!(value, expected); + assert_eq!( + summary.skipped_tasks, + vec![SkippedTask { + type_tag: "UserDataUpdateTask".into(), + name: None, + }] + ); + assert_eq!( + summary.disabled_tasks, + vec![ + SkippedTask { + type_tag: "RoguelikeTask".into(), + name: None, + }, + SkippedTask { + type_tag: "ReclamationTask".into(), + name: None, + }, + ] + ); + assert!( + summary + .skipped_fields + .iter() + .any(|field| field.field == "ClearStore") + ); + assert!( + summary + .skipped_fields + .iter() + .any(|field| field.task_type == "FightTask" && field.field == "StageResetMode") + ); } const STARTUP_TASK: &str = r#"{ @@ -401,49 +617,128 @@ mod tests { "TaskType": "StartUp" }"#; - gui_task_test!( - convert_start_up_task_basic, - start_up::convert_start_up_task, - STARTUP_TASK, - { - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type", - "deps": { - "start_game_enabled": true + #[test] + fn migrate_start_up_task_without_runtime_settings() { + let task: MAAValue = serde_json::from_str(STARTUP_TASK).unwrap(); + let config = object!(); + let mut summary = MigrationSummary::default(); + let actual = start_up::migrate_start_up_task(&task, &config, &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!({ + "type": "StartUp", + "params": { + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type" + }, + "start_game_enabled": { + "default": true, + "description": "start the game" } - }, - "start_game_enabled": { - "default": true, - "description": "start the game" } - } - } - ); + }) + ); + } - gui_task_test!( - convert_start_up_task_with_account_and_name, - start_up::convert_start_up_task, - STARTUP_TASK_WITH_ACCOUNT, - { - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type", - "deps": { - "start_game_enabled": true - } - }, - "start_game_enabled": { - "default": true, - "description": "start the game" + #[test] + fn migrate_start_up_task_from_runtime_settings() { + let task: MAAValue = serde_json::from_str(STARTUP_TASK).unwrap(); + let config = object!( + "Gui" => object!( + "RuntimeSettings" => object!( + "ClientType" => "Official", + "StartGame" => true, + ) + ) + ); + let mut summary = MigrationSummary::default(); + let actual = start_up::migrate_start_up_task(&task, &config, &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!({ + "type": "StartUp", + "params": { + "client_type": "Official", + "start_game_enabled": true } - } - } - ); + }) + ); + } + + #[test] + fn migrate_start_up_task_invalid_client_type_falls_back() { + let task: MAAValue = serde_json::from_str(STARTUP_TASK_WITH_ACCOUNT).unwrap(); + let config = object!( + "Gui" => object!( + "RuntimeSettings" => object!( + "ClientType" => "NotAClient", + "StartGame" => false, + ) + ) + ); + let mut summary = MigrationSummary::default(); + let actual = start_up::migrate_start_up_task(&task, &config, &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!({ + "type": "StartUp", + "params": { + "client_type": { + "alternatives": ["Official", "YoStarEN", "YoStarJP"], + "description": "a client type" + }, + "start_game_enabled": false + } + }) + ); + } + + #[test] + fn migrate_start_up_task_writes_account_name_when_switch_enabled() { + let task = object!( + "$type" => "StartUpTask", + "AccountName" => "123****4567", + "AccountSwitchEnabled" => true, + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let actual = start_up::migrate_start_up_task(&task, &object!(), &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + actual + .get("params") + .and_then(|params| params.get("account_name")), + Some(&MAAValue::from("123****4567")) + ); + } + + #[test] + fn migrate_start_up_task_omits_account_name_when_switch_disabled() { + let task = object!( + "$type" => "StartUpTask", + "AccountName" => "123****4567", + "AccountSwitchEnabled" => false, + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let actual = start_up::migrate_start_up_task(&task, &object!(), &mut summary) + .unwrap() + .unwrap(); + assert!( + actual + .get("params") + .and_then(|params| params.get("account_name")) + .is_none() + ); + } const FIGHT_TASK: &str = r#"{ "$type": "FightTask", @@ -464,25 +759,6 @@ mod tests { "TaskType": "Fight" }"#; - const DISABLED_FIGHT_TASK: &str = r#"{ - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": ["LS-6"], - "IsDrGrandet": false, - "Name": "日常经验本", - "IsEnable": false, - "TaskType": "Fight" -}"#; - const WEEKLY_LS6_TASK: &str = r#"{ "$type": "FightTask", "UseMedicine": false, @@ -566,8 +842,8 @@ mod tests { }"#; gui_task_test!( - convert_fight_task_basic, - fight::convert_fight_task, + migrate_fight_task_basic, + fight::migrate_fight_task, FIGHT_TASK, { "type": "Fight", @@ -579,21 +855,8 @@ mod tests { ); gui_task_test!( - convert_fight_task_when_disabled, - fight::convert_fight_task, - DISABLED_FIGHT_TASK, - { - "type": "Fight", - "name": "日常经验本", - "params": { - "stage": "LS-6" - } - } - ); - - gui_task_test!( - convert_fight_task_weekly_schedule_ls6, - fight::convert_fight_task, + migrate_fight_task_weekly_schedule_ls6, + fight::migrate_fight_task, WEEKLY_LS6_TASK, { "type": "Fight", @@ -615,8 +878,8 @@ mod tests { ); gui_task_test!( - convert_fight_task_weekly_schedule_ce6, - fight::convert_fight_task, + migrate_fight_task_weekly_schedule_ce6, + fight::migrate_fight_task, WEEKLY_CE6_TASK, { "type": "Fight", @@ -640,25 +903,64 @@ mod tests { mod start_up { use anyhow::Result; + use log::warn; use maa_value::prelude::*; - pub(super) fn convert_start_up_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI StartUpTask is disabled but will still be converted"); - } + use super::MigrationSummary; + + /// Valid GUI / maa-cli client type strings. + const VALID_CLIENT_TYPES: &[&str] = &[ + "Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR", + ]; + + pub(super) fn migrate_start_up_task( + task: &MAAValue, + config: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { + let runtime = config.get("Gui").and_then(|gui| gui.get("RuntimeSettings")); let mut params = MAAValue::default(); - // -> client_type - insert!(params, "client_type" => object!( - "alternatives" => vec!["Official", "YoStarEN", "YoStarJP"]??, - "description" => "a client type", - "deps" => object!("start_game_enabled" => true) - )); - // -> start_game_enabled - insert!(params, "start_game_enabled" => object!( - "default" => true, - "description" => "start the game" - )); + + // Gui.RuntimeSettings.ClientType -> client_type + // Missing / invalid values fall back to an interactive prompt. + match runtime + .and_then(|settings| settings.get("ClientType")) + .and_then(|value| value.as_str()) + .filter(|value| VALID_CLIENT_TYPES.contains(value)) + { + Some(client_type) => { + insert!(params, "client_type" => client_type); + } + None => { + insert!(params, "client_type" => object!( + "alternatives" => VALID_CLIENT_TYPES.iter().map(|s| s.to_string()).collect::>()??, + )); + } + } + + // Gui.RuntimeSettings.StartGame -> start_game_enabled + match runtime.and_then(|settings| settings.get("StartGame")) { + Some(MAAValue::Primitive(MAAPrimitive::Bool(start_game))) => { + insert!(params, "start_game_enabled" => *start_game); + } + _ => {} + } + + // AccountSwitchEnabled + AccountName -> account_name + // Only emit account_name when account switching is enabled. + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = + task.get("AccountSwitchEnabled") + { + if let Some(MAAValue::Primitive(MAAPrimitive::String(account_name))) = + task.get("AccountName") + { + insert!(params, "account_name" => account_name.as_str()); + } else { + summary.skip_field("StartUpTask", None, "AccountName"); + warn!("AccountName is missing, but GUI enable account switching"); + } + } let item = object!( "type" => "StartUp", @@ -673,11 +975,12 @@ mod fight { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_fight_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI FightTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_fight_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Fight"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -686,30 +989,30 @@ mod fight { let mut weekday_condition = None; // UseWeeklySchedule + WeeklySchedule -> condition - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseWeeklySchedule") { - if let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") { - const DAYS: [(&str, &str); 7] = [ - ("Sunday", "Sun"), - ("Monday", "Mon"), - ("Tuesday", "Tue"), - ("Wednesday", "Wed"), - ("Thursday", "Thu"), - ("Friday", "Fri"), - ("Saturday", "Sat"), - ]; - - let mut weekdays = Vec::new(); - for (gui_day, cli_day) in DAYS { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { - weekdays.push(cli_day); - } + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseWeeklySchedule") + && let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") + { + const DAYS: [(&str, &str); 7] = [ + ("Sunday", "Sun"), + ("Monday", "Mon"), + ("Tuesday", "Tue"), + ("Wednesday", "Wed"), + ("Thursday", "Thu"), + ("Friday", "Fri"), + ("Saturday", "Sat"), + ]; + + let mut weekdays = Vec::new(); + for (gui_day, cli_day) in DAYS { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { + weekdays.push(cli_day); } - - weekday_condition = Some(object!( - "type" => "Weekday", - "weekdays" => weekdays?? - )); } + + weekday_condition = Some(object!( + "type" => "Weekday", + "weekdays" => weekdays?? + )); } let mut params = MAAValue::default(); @@ -729,12 +1032,10 @@ mod fight { } // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseExpiringMedicine") - { - if let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = + && let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = task.get("MedicineExpireDays") - { - insert!(params, "medicine_expire_days" => *days); - } + { + insert!(params, "medicine_expire_days" => *days); } if let Some(condition) = weekday_condition { @@ -750,6 +1051,19 @@ mod fight { insert!(item, "params" => params); } + report_unhandled_fields( + summary, + task, + "FightTask", + &[ + "UseWeeklySchedule", + "WeeklySchedule", + "StagePlan", + "UseExpiringMedicine", + "MedicineExpireDays", + ], + ); + Ok(Some(item)) } } @@ -758,11 +1072,12 @@ mod infrast { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_infrast_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI InfrastTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_infrast_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Infrast"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -770,6 +1085,20 @@ mod infrast { } let mut params = MAAValue::default(); + let mut handled = vec![ + "Mode", + "RoomList", + "UsesOfDrones", + "DormThreshold", + "OriginiumShardAutoReplenishment", + "DormFilterNotStationed", + "DormTrustEnabled", + "ReceptionMessageBoard", + "ReceptionClueExchange", + "SendClue", + "Filename", + "PlanSelect", + ]; // Mode -> mode if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { if let Some(mode) = match mode.as_str() { @@ -779,16 +1108,18 @@ mod infrast { _ => None, } { insert!(params, "mode" => mode); + } else { + handled.retain(|field| *field != "Mode"); } } // RoomList -> facility if let Some(MAAValue::Array(rooms)) = task.get("RoomList") { let mut facility = Vec::new(); for room in rooms { - if let MAAValue::Object(map) = room { - if let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") { - facility.push(room.as_str()); - } + if let MAAValue::Object(map) = room + && let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") + { + facility.push(room.as_str()); } } if !facility.is_empty() { @@ -837,19 +1168,20 @@ mod infrast { insert!(params, "reception_send_clue" => *enabled); } // Filename -> filename - if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") { - if !filename.is_empty() { - insert!(params, "filename" => filename.as_str()); - } + if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") + && !filename.is_empty() + { + insert!(params, "filename" => filename.as_str()); } // PlanSelect -> plan_index - if let Some(MAAValue::Primitive(MAAPrimitive::Int(plan_index))) = task.get("PlanSelect") { - if *plan_index >= 0 { - insert!(params, "plan_index" => *plan_index); - } + if let Some(MAAValue::Primitive(MAAPrimitive::Int(plan_index))) = task.get("PlanSelect") + && *plan_index >= 0 + { + insert!(params, "plan_index" => *plan_index); } insert!(item, "params" => params); + report_unhandled_fields(summary, task, "InfrastTask", &handled); Ok(Some(item)) } } @@ -858,11 +1190,12 @@ mod recruit { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_recruit_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI RecruitTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_recruit_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Recruit"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -913,36 +1246,58 @@ mod recruit { insert!(params, "recruitment_time" => recruitment_time); } // PreferTagEnabled + Level3PreferTags -> first_tags - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreferTagEnabled") { - if let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") { - let mut first_tags = Vec::new(); - for tag in tags { - if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { - first_tags.push(tag.as_str()); - } - } - if !first_tags.is_empty() { - insert!(params, "first_tags" => first_tags??); + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreferTagEnabled") + && let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") + { + let mut first_tags = Vec::new(); + for tag in tags { + if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { + first_tags.push(tag.as_str()); } } + if !first_tags.is_empty() { + insert!(params, "first_tags" => first_tags??); + } } // PreserveTagEnabled + PreserveTagList -> preserve_tags if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreserveTagEnabled") + && let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") { - if let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") { - let mut preserve_tags = Vec::new(); - for tag in tags { - if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { - preserve_tags.push(tag.as_str()); - } - } - if !preserve_tags.is_empty() { - insert!(params, "preserve_tags" => preserve_tags??); + let mut preserve_tags = Vec::new(); + for tag in tags { + if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { + preserve_tags.push(tag.as_str()); } } + if !preserve_tags.is_empty() { + insert!(params, "preserve_tags" => preserve_tags??); + } } insert!(item, "params" => params); + report_unhandled_fields( + summary, + task, + "RecruitTask", + &[ + "MaxTimes", + "ExtraTagMode", + "RefreshLevel3", + "ForceRefresh", + "Level6Choose", + "Level6Time", + "Level5Choose", + "Level5Time", + "Level4Choose", + "Level4Time", + "Level3Choose", + "Level3Time", + "PreferTagEnabled", + "Level3PreferTags", + "PreserveTagEnabled", + "PreserveTagList", + ], + ); Ok(Some(item)) } } @@ -951,11 +1306,12 @@ mod mall { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_mall_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI MallTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_mall_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Mall"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -1017,6 +1373,22 @@ mod mall { } insert!(item, "params" => params); + report_unhandled_fields( + summary, + task, + "MallTask", + &[ + "Shopping", + "CreditFight", + "CreditFightFormation", + "VisitFriends", + "FirstList", + "BlackList", + "ShoppingIgnoreBlackListWhenFull", + "OnlyBuyDiscount", + "ReserveMaxCredit", + ], + ); Ok(Some(item)) } } @@ -1025,11 +1397,12 @@ mod award { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_award_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI AwardTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_award_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Award"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -1065,6 +1438,19 @@ mod award { } insert!(item, "params" => params); + report_unhandled_fields( + summary, + task, + "AwardTask", + &[ + "Award", + "Mail", + "FreeGacha", + "Orundum", + "Mining", + "SpecialAccess", + ], + ); Ok(Some(item)) } } @@ -1073,11 +1459,12 @@ mod roguelike { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_roguelike_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI RoguelikeTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_roguelike_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Roguelike"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -1085,6 +1472,26 @@ mod roguelike { } let mut params = MAAValue::default(); + let mut handled = vec![ + "Theme", + "Mode", + "Squad", + "Roles", + "CoreChar", + "StartCount", + "Difficulty", + "Investment", + "InvestCount", + "InvestWithMoreScore", + "StopWhenDepositFull", + "StopAtFinalBoss", + "StopWhenLevelMax", + "UseSupport", + "UseSupportNonFriend", + "RefreshTraderWithDice", + "StartWithEliteTwo", + "StartWithEliteTwoOnly", + ]; // Theme -> theme if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { insert!(params, "theme" => theme.as_str()); @@ -1101,6 +1508,8 @@ mod roguelike { _ => None, } { insert!(params, "mode" => mode); + } else { + handled.retain(|field| *field != "Mode"); } } // Squad -> squad @@ -1120,10 +1529,10 @@ mod roguelike { insert!(params, "starts_count" => *starts_count); } // Difficulty -> difficulty - if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") { - if *difficulty != i32::MAX { - insert!(params, "difficulty" => *difficulty); - } + if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") + && *difficulty != i32::MAX + { + insert!(params, "difficulty" => *difficulty); } // Investment -> investment_enabled if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_enabled))) = @@ -1191,6 +1600,7 @@ mod roguelike { } insert!(item, "params" => params); + report_unhandled_fields(summary, task, "RoguelikeTask", &handled); Ok(Some(item)) } } @@ -1199,11 +1609,12 @@ mod reclamation { use anyhow::Result; use maa_value::prelude::*; - pub(super) fn convert_reclamation_task(task: &MAAValue) -> Result> { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) = task.get("IsEnable") { - log::warn!("GUI ReclamationTask is disabled but will still be converted"); - } + use super::{MigrationSummary, report_unhandled_fields}; + pub(super) fn migrate_reclamation_task( + task: &MAAValue, + summary: &mut MigrationSummary, + ) -> Result> { let mut item = object!("type" => "Reclamation"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -1211,6 +1622,13 @@ mod reclamation { } let mut params = MAAValue::default(); + let mut handled = vec![ + "Theme", + "Mode", + "ToolToCraft", + "IncrementMode", + "MaxCraftCountPerRound", + ]; // Theme -> theme if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { insert!(params, "theme" => theme.as_str()); @@ -1223,13 +1641,15 @@ mod reclamation { _ => None, } { insert!(params, "mode" => mode); + } else { + handled.retain(|field| *field != "Mode"); } } // ToolToCraft -> tools_to_craft - if let Some(MAAValue::Primitive(MAAPrimitive::String(tool))) = task.get("ToolToCraft") { - if !tool.is_empty() { - insert!(params, "tools_to_craft" => vec![tool.as_str()]??); - } + if let Some(MAAValue::Primitive(MAAPrimitive::String(tool))) = task.get("ToolToCraft") + && !tool.is_empty() + { + insert!(params, "tools_to_craft" => vec![tool.as_str()]??); } // IncrementMode -> increment_mode if let Some(MAAValue::Primitive(MAAPrimitive::Int(increment_mode))) = @@ -1245,6 +1665,7 @@ mod reclamation { } insert!(item, "params" => params); + report_unhandled_fields(summary, task, "ReclamationTask", &handled); Ok(Some(item)) } } diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index 89c109e6..7a63f514 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -143,35 +143,16 @@ impl FindFile for T where T: FromFile {} impl FindFileOrDefault for T where T: FromFile + Default {} -pub fn convert( - file: &Path, - out: Option<&Path>, - ft: Option, - gui: bool, - gui_config: Option<&str>, -) -> Result<()> { +pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result<()> { use maa_dirs::Ensure; - if gui && !matches!(Filetype::parse_filetype(file), Some(Filetype::Json)) { - log::warn!( - "The --gui flag is for converting MAA GUI profiles, which are typically JSON files; \ - input {} is not JSON", - file.display() - ); - } - let ft = ft.or_else(|| { out.and_then(|path| path.extension()) .and_then(|ext| ext.to_str()) .and_then(Filetype::parse_extension) }); - let mut value = MAAValue::from_file(file)?; - - if gui { - value = gui::select_configuration(value, gui_config)?; - value = gui::convert(value)?; - } + let value = MAAValue::from_file(file)?; let Some(format) = ft else { bail!("Format not given") @@ -192,7 +173,7 @@ pub fn convert( pub mod import; -mod gui; +pub mod migrate; pub mod asst; @@ -323,9 +304,9 @@ mod tests { let value = object!("z" => 1, "a" => "test", "m" => false); Json.write(&input, &value).unwrap(); - super::super::convert(&input, None, Some(Json), false, None).unwrap(); - super::super::convert(&input, Some(&toml), None, false, None).unwrap(); - super::super::convert(&input, Some(&toml), Some(Yaml), false, None).unwrap(); + super::super::convert(&input, None, Some(Json)).unwrap(); + super::super::convert(&input, Some(&toml), None).unwrap(); + super::super::convert(&input, Some(&toml), Some(Yaml)).unwrap(); assert_eq!(MAAValue::from_file(&toml).unwrap(), value); assert_eq!(MAAValue::from_file(&yaml).unwrap(), value); @@ -339,7 +320,7 @@ mod tests { let value = object!("z" => 1); Filetype::Json.write(&input, &value).unwrap(); - assert!(super::super::convert(&input, None, None, false, None).is_err()); + assert!(super::super::convert(&input, None, None).is_err()); } #[test] @@ -373,7 +354,7 @@ mod tests { .collect(); assert_eq!(inner_keys, ["k2", "k1"]); - super::super::convert(&input, Some(&yaml), None, false, None).unwrap(); + super::super::convert(&input, Some(&yaml), None).unwrap(); assert_eq!( std::fs::read_to_string(&yaml).unwrap(), diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 3e59c781..9b3dc588 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -107,15 +107,13 @@ fn main() -> Result<()> { input, output, format, - gui, - gui_config, - } => config::convert( - &input, - output.as_deref(), + } => config::convert(&input, output.as_deref(), format)?, + Command::Migrate(command::MigrateCommand::Wpf { + input, + output, format, - gui, - gui_config.as_deref(), - )?, + config, + }) => config::migrate::wpf(&input, output.as_deref(), format, config.as_deref())?, Command::Activity { client } => activity::display_stage_activity(client)?, Command::Remainder { divisor, timezone } => { use crate::config::task::{TimeOffset, remainder_of_day_mod}; From 36c620d3abc139e56ebe65e008ea22dfc07952c4 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Tue, 4 Aug 2026 03:59:04 +0800 Subject: [PATCH 07/20] =?UTF-8?q?=E2=9C=A8=20feat:=20migrate=20WPF=20Fight?= =?UTF-8?q?=20tasks=20with=20stage=20open=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire UseWeeklySchedule and UseOptionalStage into Fight variants Map medicine, stone, times, drops, series, and expiring medicine into shared params Derive per-stage Always/Weekday/OnSideStory conditions via StageActivityV2 Document the Fight migration rules in zh-CN migrate.md 从 WPF 战斗任务迁移关卡变体与开放条件,并补充中文文档 --- .../maa-cli/config_examples/wpf/gui.new.json | 4 +- .../maa-cli/config_examples/wpf/gui.new.toml | 36 +- crates/maa-cli/docs/zh-CN/migrate.md | 63 +++ crates/maa-cli/src/activity.rs | 35 +- crates/maa-cli/src/config/migrate/wpf.rs | 472 +++++++++++++++--- 5 files changed, 540 insertions(+), 70 deletions(-) diff --git a/crates/maa-cli/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json index 2d2b1718..32d22a30 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.json +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -25,8 +25,8 @@ "Series": 0, "StagePlan": [ "TO-5", - "LS-6", - "CE-6" + "CE-6", + "LS-6" ], "IsDrGrandet": false, "UseExpiringMedicine": true, diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml index 46867df3..262bc037 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.toml +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -9,13 +9,39 @@ start_game_enabled = true type = "Fight" name = "理智作战" -[tasks.params] -stage = [ - "TO-5", - "LS-6", - "CE-6", +[[tasks.variants]] + +[tasks.variants.condition] +type = "OnSideStory" + +[tasks.variants.params] +medicine_expire_days = 2 +stage = "TO-5" + +[[tasks.variants]] + +[tasks.variants.condition] +type = "Weekday" +weekdays = [ + "Tue", + "Thu", + "Sat", + "Sun", ] +timezone = "Official" + +[tasks.variants.params] +medicine_expire_days = 2 +stage = "CE-6" + +[[tasks.variants]] + +[tasks.variants.condition] +type = "Always" + +[tasks.variants.params] medicine_expire_days = 2 +stage = "LS-6" [[tasks]] type = "Infrast" diff --git a/crates/maa-cli/docs/zh-CN/migrate.md b/crates/maa-cli/docs/zh-CN/migrate.md index c4e1be9b..8261c14e 100644 --- a/crates/maa-cli/docs/zh-CN/migrate.md +++ b/crates/maa-cli/docs/zh-CN/migrate.md @@ -41,6 +41,69 @@ maa migrate wpf [output] [-f toml|yaml|json] [--config ] TODO:`account_name` 的转换**我没有测试过**(未做实机账号切换验证)。 +### Fight + +对应 GUI 任务队列中的战斗任务,迁移为 maa-cli 的 `Fight` 任务。 +`Name` 会写入任务的 `name`;其余公共参数写入 `params`(若存在 `variants`,则写入每个变体的 `params`)。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `UseMedicine` + `MedicineCount` | `params.medicine` | 仅当 `UseMedicine` 为 `true` 时写入 `MedicineCount` | +| `UseStone` + `StoneCount` | `params.stone` | 仅当 `UseStone` 为 `true` 时写入 `StoneCount`;迁移时会警告该设置可能消耗源石 | +| `EnableTimesLimit` + `TimesLimit` | `params.times` | 仅当 `EnableTimesLimit` 为 `true` 时写入 `TimesLimit` | +| `EnableTargetDrop` + `DropId` + `DropCount` | `params.drops` | 仅当 `EnableTargetDrop` 为 `true` 时写入,格式为 `{ = }` | +| `Series` | `params.series` | 仅当值不为 `0` 时写入 | +| `UseExpiringMedicine` + `MedicineExpireDays` | `params.medicine_expire_days` | 仅当 `UseExpiringMedicine` 为 `true` 时写入 `MedicineExpireDays` | +| `StagePlan` | `params.stage` | 见下方「关卡与变体」 | + +#### 关卡与变体 + +关卡与条件变体由 `UseWeeklySchedule` 与 `UseOptionalStage` 共同决定。 + +| `UseWeeklySchedule` | `UseOptionalStage` | `StagePlan` 要求 | 输出结构 | +| --- | --- | --- | --- | +| `false` | `false` | 单个关卡(字符串,或仅含一个字符串的数组) | 无 `variants`,关卡写入顶层 `params.stage` | +| `true` | `false` | 同上 | 一个 `variants`;`condition` 为周计划对应的 `Weekday`;关卡写入该变体的 `params.stage` | +| `false` | `true` | 非空字符串数组 | 每个关卡一个 `variants`;`condition` 由关卡开放条件决定 | +| `true` | `true` | 非空字符串数组 | 每个关卡一个 `variants`;`condition` 为 `And`,依次合并周计划与关卡开放条件 | + +周计划来自 `WeeklySchedule`:GUI 中为 `true` 的星期会写入 `condition = { type = "Weekday", weekdays = [...] }`(如 `Sun` / `Mon` / … / `Sat`)。 +若 `UseWeeklySchedule` 为 `true` 但缺少有效的 `WeeklySchedule`,迁移会报错退出。 +若 `UseOptionalStage` 为 `false` 但 `StagePlan` 含多个关卡,迁移会报错退出。 + +启用 `UseOptionalStage` 时,每个关卡的开放条件大致如下: + +| 关卡 | 条件 | +| --- | --- | +| `CE-6` | `Weekday`:`Tue` / `Thu` / `Sat` / `Sun`(`timezone = "Official"`) | +| `AP-5` | `Weekday`:`Mon` / `Thu` / `Sat` / `Sun`(`timezone = "Official"`) | +| `CA-5` | `Weekday`:`Tue` / `Wed` / `Fri` / `Sun`(`timezone = "Official"`) | +| `SK-5` | `Weekday`:`Mon` / `Wed` / `Fri` / `Sat`(`timezone = "Official"`) | +| `PR-A-1` / `PR-A-2` | `Weekday`:`Mon` / `Thu` / `Fri` / `Sun`(`timezone = "Official"`) | +| `PR-B-1` / `PR-B-2` | `Weekday`:`Mon` / `Tue` / `Fri` / `Sat`(`timezone = "Official"`) | +| `PR-C-1` / `PR-C-2` | `Weekday`:`Wed` / `Thu` / `Sat` / `Sun`(`timezone = "Official"`) | +| `PR-D-1` / `PR-D-2` | `Weekday`:`Tue` / `Wed` / `Sat` / `Sun`(`timezone = "Official"`) | +| `LS-6` / `Annihilation` / `OF-1` / `OF-F3` | `Always` | +| 热更新 `StageActivityV2.json` 中的侧边故事关卡 | `OnSideStory`(TODO: 暂时只按官服活动表判断) | +| 其他未识别关卡(如主线) | `Always`,并输出警告 | + +示例:仅启用可选关卡、刷 `CE-6` 与 `LS-6` 时,迁移结果类似: + +```toml +[[tasks]] +type = "Fight" +name = "理智作战" + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { medicine_expire_days = 2, stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { medicine_expire_days = 2, stage = "LS-6" } +``` + ## config-v1 到 config-v2 TODO diff --git a/crates/maa-cli/src/activity.rs b/crates/maa-cli/src/activity.rs index ad20d389..a2b935df 100644 --- a/crates/maa-cli/src/activity.rs +++ b/crates/maa-cli/src/activity.rs @@ -19,6 +19,18 @@ pub fn has_side_story_open(client: ClientType) -> bool { .unwrap_or(false) } +/// Stage codes listed under side-story activities for `client`. +/// +/// Used to decide whether a fight stage should use an `OnSideStory` condition. +/// Returns an empty list when the activity file is missing or the client has no entry. +pub fn side_story_stages(client: ClientType) -> Vec { + STAGE_ACTIVITY + .as_ref() + .and_then(|stage_activity| stage_activity.get_stage_activity(client)) + .map(StageActivityContent::side_story_stage_codes) + .unwrap_or_default() +} + pub fn display_stage_activity(client: ClientType) -> std::io::Result<()> { if let Some(stage_activity) = STAGE_ACTIVITY.as_ref() { stage_activity.display(std::io::stdout(), client)?; @@ -116,6 +128,26 @@ impl StageActivityContent { .values() .any(|activity| activity.activity.is_active()) } + + /// Collect unique fight stage codes from all side-story activities. + /// + /// Prefers `Value` when present, otherwise falls back to `Display`. + pub fn side_story_stage_codes(&self) -> Vec { + let mut stages = Vec::new(); + for activity in self.side_story_stage.values() { + for stage in &activity.stages { + let code = if stage.value.is_empty() { + stage.display.as_str() + } else { + stage.value.as_str() + }; + if !stages.iter().any(|s| s == code) { + stages.push(code.to_owned()); + } + } + } + stages + } } #[cfg_attr(test, derive(Debug, PartialEq, Clone))] @@ -147,7 +179,8 @@ impl ActivityInfo { #[serde(rename_all = "PascalCase")] struct StageInfo { display: String, - // value: String, + #[serde(default)] + value: String, drop: String, } diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 986d9731..55fed9a1 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -418,7 +418,6 @@ mod tests { { "type": "Fight", "name": "日常经验本", - "strategy": "merge", "variants": [ { "condition": { @@ -435,7 +434,6 @@ mod tests { { "type": "Fight", "name": "日常龙门币", - "strategy": "merge", "variants": [ { "condition": { @@ -861,7 +859,6 @@ mod tests { { "type": "Fight", "name": "日常经验本", - "strategy": "merge", "variants": [ { "condition": { @@ -884,7 +881,6 @@ mod tests { { "type": "Fight", "name": "日常龙门币", - "strategy": "merge", "variants": [ { "condition": { @@ -899,6 +895,152 @@ mod tests { ] } ); + + #[test] + fn migrate_fight_task_rejects_multi_stage_without_optional() { + let task = object!( + "$type" => "FightTask", + "StagePlan" => vec!["CE-6", "LS-6"]??, + "UseWeeklySchedule" => false, + "UseOptionalStage" => false, + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let err = fight::migrate_fight_task(&task, &mut summary).unwrap_err(); + assert!( + err.to_string().contains("single stage string"), + "unexpected error: {err}" + ); + } + + #[test] + fn migrate_fight_task_shared_params() { + let task = object!( + "$type" => "FightTask", + "UseMedicine" => true, + "MedicineCount" => 3, + "UseStone" => true, + "StoneCount" => 2, + "EnableTimesLimit" => true, + "TimesLimit" => 10, + "EnableTargetDrop" => true, + "DropId" => "30012", + "DropCount" => 100, + "Series" => 6, + "StagePlan" => vec!["1-7"]??, + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let actual = fight::migrate_fight_task(&task, &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!({ + "type": "Fight", + "params": { + "medicine": 3, + "stone": 2, + "times": 10, + "drops": { "30012": 100 }, + "series": 6, + "stage": "1-7" + } + }) + ); + } + + #[test] + fn migrate_fight_task_optional_stage_conditions() { + let task = object!( + "$type" => "FightTask", + "UseOptionalStage" => true, + "UseWeeklySchedule" => false, + "StagePlan" => vec!["CE-6", "LS-6", "Annihilation", "1-7"]??, + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let actual = fight::migrate_fight_task(&task, &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!({ + "type": "Fight", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat", "Sun"], + "timezone": "Official" + }, + "params": { "stage": "CE-6" } + }, + { + "condition": { "type": "Always" }, + "params": { "stage": "LS-6" } + }, + { + "condition": { "type": "Always" }, + "params": { "stage": "Annihilation" } + }, + { + "condition": { "type": "Always" }, + "params": { "stage": "1-7" } + } + ] + }) + ); + } + + #[test] + fn migrate_fight_task_weekly_and_optional_and_condition() { + let task = object!( + "$type" => "FightTask", + "UseOptionalStage" => true, + "UseWeeklySchedule" => true, + "WeeklySchedule" => object!( + "Sunday" => true, + "Monday" => false, + "Tuesday" => false, + "Wednesday" => false, + "Thursday" => false, + "Friday" => false, + "Saturday" => false, + ), + "StagePlan" => vec!["CE-6"]??, + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let actual = fight::migrate_fight_task(&task, &mut summary) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::json!({ + "type": "Fight", + "variants": [ + { + "condition": { + "type": "And", + "conditions": [ + { + "type": "Weekday", + "weekdays": ["Sun"] + }, + { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat", "Sun"], + "timezone": "Official" + } + ] + }, + "params": { "stage": "CE-6" } + } + ] + }) + ); + } } mod start_up { @@ -972,10 +1114,12 @@ mod start_up { } mod fight { - use anyhow::Result; + use anyhow::{Result, bail}; + use log::warn; use maa_value::prelude::*; use super::{MigrationSummary, report_unhandled_fields}; + use crate::config::task::ClientType; pub(super) fn migrate_fight_task( task: &MAAValue, @@ -987,69 +1131,69 @@ mod fight { insert!(item, "name" => name.as_str()); } - let mut weekday_condition = None; - // UseWeeklySchedule + WeeklySchedule -> condition - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseWeeklySchedule") - && let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") - { - const DAYS: [(&str, &str); 7] = [ - ("Sunday", "Sun"), - ("Monday", "Mon"), - ("Tuesday", "Tue"), - ("Wednesday", "Wed"), - ("Thursday", "Thu"), - ("Friday", "Fri"), - ("Saturday", "Sat"), - ]; - - let mut weekdays = Vec::new(); - for (gui_day, cli_day) in DAYS { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { - weekdays.push(cli_day); - } - } + let shared_params = shared_fight_params(task); - weekday_condition = Some(object!( - "type" => "Weekday", - "weekdays" => weekdays?? - )); - } + let use_weekly = flag(task, "UseWeeklySchedule"); + let use_optional = flag(task, "UseOptionalStage"); - let mut params = MAAValue::default(); - // StagePlan -> stage - if let Some(MAAValue::Array(stages)) = task.get("StagePlan") { - let mut stage_list = Vec::new(); - for stage in stages { - if let MAAValue::Primitive(MAAPrimitive::String(stage)) = stage { - stage_list.push(stage.as_str()); + match (use_weekly, use_optional) { + // No variants: StagePlan must be a single stage. + (false, false) => { + let stage = stage_plan_single(task)?; + let mut params = shared_params; + insert!(params, "stage" => stage); + insert!(item, "params" => params); + } + // One variant gated by weekly schedule; StagePlan must be a single stage. + (true, false) => { + let stage = stage_plan_single(task)?; + let weekly = weekly_schedule_condition(task)?; + let mut params = shared_params; + insert!(params, "stage" => stage); + insert!( + item, + "variants" => vec![object!( + "condition" => weekly, + "params" => params + )]?? + ); + } + // One variant per optional stage, each with its own open-condition. + (false, true) => { + let stages = stage_plan_array(task)?; + let mut variants = Vec::with_capacity(stages.len()); + for stage in stages { + let mut params = shared_params.clone(); + insert!(params, "stage" => stage); + variants.push(object!( + "condition" => stage_condition(stage), + "params" => params + )); } + insert!(item, "variants" => variants??); } - if stage_list.len() == 1 { - insert!(params, "stage" => stage_list[0]); - } else if !stage_list.is_empty() { - insert!(params, "stage" => stage_list??); + // Weekly schedule AND each stage's open-condition. + (true, true) => { + let stages = stage_plan_array(task)?; + let weekly = weekly_schedule_condition(task)?; + let mut variants = Vec::with_capacity(stages.len()); + for stage in stages { + let mut params = shared_params.clone(); + insert!(params, "stage" => stage); + variants.push(object!( + "condition" => object!( + "type" => "And", + "conditions" => vec![ + weekly.clone(), + stage_condition(stage) + ]?? + ), + "params" => params + )); + } + insert!(item, "variants" => variants??); } } - // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("UseExpiringMedicine") - && let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = - task.get("MedicineExpireDays") - { - insert!(params, "medicine_expire_days" => *days); - } - - if let Some(condition) = weekday_condition { - insert!(item, "strategy" => "merge"); - insert!( - item, - "variants" => vec![object!( - "condition" => condition, - "params" => params - )]?? - ); - } else { - insert!(item, "params" => params); - } report_unhandled_fields( summary, @@ -1058,7 +1202,18 @@ mod fight { &[ "UseWeeklySchedule", "WeeklySchedule", + "UseOptionalStage", "StagePlan", + "UseMedicine", + "MedicineCount", + "UseStone", + "StoneCount", + "EnableTimesLimit", + "TimesLimit", + "EnableTargetDrop", + "DropId", + "DropCount", + "Series", "UseExpiringMedicine", "MedicineExpireDays", ], @@ -1066,6 +1221,199 @@ mod fight { Ok(Some(item)) } + + fn flag(task: &MAAValue, key: &str) -> bool { + matches!( + task.get(key), + Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) + ) + } + + /// Shared Fight params that do not depend on stage / condition branching. + fn shared_fight_params(task: &MAAValue) -> MAAValue { + let mut params = MAAValue::default(); + + // UseMedicine + MedicineCount -> medicine + if flag(task, "UseMedicine") + && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("MedicineCount") + { + insert!(params, "medicine" => *count); + } + + // UseStone + StoneCount -> stone + if flag(task, "UseStone") + && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("StoneCount") + { + warn!( + "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" + ); + insert!(params, "stone" => *count); + } + + // EnableTimesLimit + TimesLimit -> times + if flag(task, "EnableTimesLimit") + && let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("TimesLimit") + { + insert!(params, "times" => *times); + } + + // EnableTargetDrop + DropId/DropCount -> drops = { = } + if flag(task, "EnableTargetDrop") + && let ( + Some(MAAValue::Primitive(MAAPrimitive::String(drop_id))), + Some(MAAValue::Primitive(MAAPrimitive::Int(drop_count))), + ) = (task.get("DropId"), task.get("DropCount")) + { + let mut drop_map = maa_value::map::StringMap::new(); + drop_map.insert(drop_id.clone(), (*drop_count).into()); + insert!(params, "drops" => MAAValue::Object(drop_map)); + } + + // Series -> series (only when non-zero) + if let Some(MAAValue::Primitive(MAAPrimitive::Int(series))) = task.get("Series") + && *series != 0 + { + insert!(params, "series" => *series); + } + + // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days + if flag(task, "UseExpiringMedicine") + && let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = + task.get("MedicineExpireDays") + { + insert!(params, "medicine_expire_days" => *days); + } + + params + } + + /// `UseWeeklySchedule` + `WeeklySchedule` -> `Weekday` condition. + fn weekly_schedule_condition(task: &MAAValue) -> Result { + let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") else { + bail!("FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid"); + }; + + const DAYS: [(&str, &str); 7] = [ + ("Sunday", "Sun"), + ("Monday", "Mon"), + ("Tuesday", "Tue"), + ("Wednesday", "Wed"), + ("Thursday", "Thu"), + ("Friday", "Fri"), + ("Saturday", "Sat"), + ]; + + let mut weekdays = Vec::new(); + for (gui_day, cli_day) in DAYS { + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { + weekdays.push(cli_day); + } + } + + Ok(object!( + "type" => "Weekday", + "weekdays" => weekdays?? + )) + } + + /// Parse `StagePlan` as a single stage name. + /// + /// GUI usually stores a single stage as a one-element array (`["CE-6"]`). + /// A multi-element array is rejected when `UseOptionalStage` is false. + fn stage_plan_single(task: &MAAValue) -> Result<&str> { + match task.get("StagePlan") { + Some(MAAValue::Primitive(MAAPrimitive::String(stage))) => Ok(stage.as_str()), + Some(MAAValue::Array(stages)) if stages.len() == 1 => match &stages[0] { + MAAValue::Primitive(MAAPrimitive::String(stage)) => Ok(stage.as_str()), + _ => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), + }, + Some(MAAValue::Array(_)) => { + bail!( + "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ + got a string array; enable UseOptionalStage for multiple stages" + ) + } + Some(_) => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), + None => bail!("FightTask missing StagePlan"), + } + } + + /// Parse `StagePlan` as a non-empty string array (optional-stage mode). + fn stage_plan_array(task: &MAAValue) -> Result> { + match task.get("StagePlan") { + Some(MAAValue::Array(stages)) => { + let mut stage_list = Vec::with_capacity(stages.len()); + for stage in stages { + match stage { + MAAValue::Primitive(MAAPrimitive::String(stage)) => { + stage_list.push(stage.as_str()); + } + _ => bail!( + "FightTask StagePlan array elements must be strings when UseOptionalStage is true" + ), + } + } + if stage_list.is_empty() { + bail!( + "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" + ); + } + Ok(stage_list) + } + Some(MAAValue::Primitive(MAAPrimitive::String(_))) => { + bail!( + "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" + ) + } + Some(_) => { + bail!("FightTask StagePlan must be a string array when UseOptionalStage is true") + } + None => bail!("FightTask missing StagePlan"), + } + } + + /// Build the open-condition for a fight stage name. + /// + /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) + /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` + /// - Side-story stages listed in `StageActivityV2.json` → `OnSideStory` + fn stage_condition(stage: &str) -> MAAValue { + match stage { + // 资源本 / 芯片本(同开放日合并) + "CE-6" => weekday_condition(&["Tue", "Thu", "Sat", "Sun"]), + "AP-5" => weekday_condition(&["Mon", "Thu", "Sat", "Sun"]), + "CA-5" => weekday_condition(&["Tue", "Wed", "Fri", "Sun"]), + "SK-5" => weekday_condition(&["Mon", "Wed", "Fri", "Sat"]), + "PR-A-1" | "PR-A-2" => weekday_condition(&["Mon", "Thu", "Fri", "Sun"]), + "PR-B-1" | "PR-B-2" => weekday_condition(&["Mon", "Tue", "Fri", "Sat"]), + "PR-C-1" | "PR-C-2" => weekday_condition(&["Wed", "Thu", "Sat", "Sun"]), + "PR-D-1" | "PR-D-2" => weekday_condition(&["Tue", "Wed", "Sat", "Sun"]), + // 永久开启 + "LS-6" | "Annihilation" | "OF-1" | "OF-F3" => always_condition(), + _ => { + let side_story = crate::activity::side_story_stages(ClientType::Official); + if side_story.iter().any(|code| code == stage) { + return object!("type" => "OnSideStory"); + } + warn!( + "FightTask stage `{stage}` has no known open schedule; treating as Always" + ); + always_condition() + } + } + } + + fn always_condition() -> MAAValue { + object!("type" => "Always") + } + + fn weekday_condition(weekdays: &[&str]) -> MAAValue { + object!( + "type" => "Weekday", + "weekdays" => weekdays.iter().copied().collect::>()??, + "timezone" => "Official" + ) + } } mod infrast { From dd670a7fff6ef91902ab808057330ea626bc5d0b Mon Sep 17 00:00:00 2001 From: nan-mu Date: Tue, 4 Aug 2026 04:11:29 +0800 Subject: [PATCH 08/20] =?UTF-8?q?=E2=9C=A8=20feat:=20migrate=20WPF=20GUI?= =?UTF-8?q?=20tasks=20into=20maa-cli=20task=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add maa migrate wpf for semantic migration from GUI TaskQueue Preserve disabled tasks with Never conditions and report skipped fields Map Fight stage variants, shared params, and open-day conditions Reject unsupported custom Infrast plans instead of silently dropping them Document WPF task mappings in zh-CN migrate.md 将 WPF GUI 任务迁移为 maa-cli 配置,并补充跳过汇总与中文文档 --- crates/maa-cli/docs/zh-CN/migrate.md | 136 +++++++++++++++++++++++ crates/maa-cli/src/config/migrate/wpf.rs | 61 +++++++--- 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/crates/maa-cli/docs/zh-CN/migrate.md b/crates/maa-cli/docs/zh-CN/migrate.md index 8261c14e..3e46ff5b 100644 --- a/crates/maa-cli/docs/zh-CN/migrate.md +++ b/crates/maa-cli/docs/zh-CN/migrate.md @@ -104,6 +104,142 @@ condition = { type = "Always" } params = { medicine_expire_days = 2, stage = "LS-6" } ``` +### Infrast + +对应 GUI 任务队列中的基建换班任务,迁移为 maa-cli 的 `Infrast` 任务。 +`Name` 会写入任务的 `name`;其余字段写入 `params`。 + +自定义基建**暂不支持**:若 `Mode` 为 `Custom`,或 `Filename` 非空,迁移会报错退出。 +`PlanSelect` 不会写入迁移结果(默认值会被忽略;自定义计划本身亦不可用)。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `Mode` | `params.mode` | `Normal` → `0`;`Rotation` → `20000`;`Custom` → 报错;其他未知值记入迁移摘要 | +| `RoomList[].Room` | `params.facility` | 按顺序收集房间名数组(如 `Mfg` / `Trade` / `Dorm`) | +| `UsesOfDrones` | `params.drones` | 有值时写入(如 `Money`) | +| `DormThreshold` | `params.threshold` | GUI 为 0–100 的整数百分比,写入时除以 `100` 转为浮点(如 `30` → `0.3`) | +| `OriginiumShardAutoReplenishment` | `params.replenish` | 有值时写入 | +| `DormFilterNotStationed` | `params.dorm_notstationed_enabled` | 有值时写入 | +| `DormTrustEnabled` | `params.dorm_trust_enabled` | 有值时写入 | +| `ReceptionMessageBoard` | `params.reception_message_board` | 有值时写入 | +| `ReceptionClueExchange` | `params.reception_clue_exchange` | 有值时写入 | +| `SendClue` | `params.reception_send_clue` | 有值时写入 | + +示例: + +```toml +[[tasks]] +type = "Infrast" +name = "" + +[tasks.params] +mode = 0 +facility = ["Mfg", "Trade", "Control", "Power", "Reception", "Office", "Dorm", "Processing", "Training"] +drones = "Money" +threshold = 0.3 +replenish = true +dorm_notstationed_enabled = true +dorm_trust_enabled = true +reception_message_board = true +reception_clue_exchange = true +reception_send_clue = true +``` + +### Recruit + +对应 GUI 任务队列中的公开招募任务,迁移为 maa-cli 的 `Recruit` 任务。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `MaxTimes` | `params.times` | 有值时写入 | +| `ExtraTagMode` | `params.extra_tags_mode` | 有值时写入 | +| `RefreshLevel3` | `params.refresh` | 有值时写入 | +| `ForceRefresh` | `params.expedite` | 有值时写入 | +| `Level3Choose` / `Level4Choose` / `Level5Choose` / `Level6Choose` | `params.select` / `params.confirm` | 为 `true` 的星级会同时写入 `select` 与 `confirm`(如 `3` / `4` / `5`) | +| `Level3Time` / `Level4Time` / `Level5Time` / `Level6Time` | `params.recruitment_time` | 有值时写入对应星级的招募时长(分钟),如 `{ "3" = 540, "4" = 540 }` | +| `PreferTagEnabled` + `Level3PreferTags` | `params.first_tags` | 仅当 `PreferTagEnabled` 为 `true` 且标签列表非空时写入 | +| `PreserveTagEnabled` + `PreserveTagList` | `params.preserve_tags` | 仅当 `PreserveTagEnabled` 为 `true` 且标签列表非空时写入 | + +### Mall + +对应 GUI 任务队列中的信用商店任务,迁移为 maa-cli 的 `Mall` 任务。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `Shopping` | `params.shopping` | 有值时写入 | +| `CreditFight` | `params.credit_fight` | 有值时写入 | +| `CreditFightFormation` | `params.formation_index` | 有值时写入 | +| `VisitFriends` | `params.visit_friends` | 有值时写入 | +| `FirstList` | `params.buy_first` | 以 `;` 分隔的字符串拆成数组(空段丢弃) | +| `BlackList` | `params.blacklist` | 同上 | +| `ShoppingIgnoreBlackListWhenFull` | `params.force_shopping_if_credit_full` | 有值时写入 | +| `OnlyBuyDiscount` | `params.only_buy_discount` | 有值时写入 | +| `ReserveMaxCredit` | `params.reserve_max_credit` | 有值时写入 | + +`CreditFightOnceADay` / `VisitFriendsOnceADay` 及对应的上次执行时间等字段目前不会迁移,有实际取值时会出现在迁移摘要中。 + +### Award + +对应 GUI 任务队列中的领取奖励任务,迁移为 maa-cli 的 `Award` 任务。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `Award` | `params.award` | 日常任务奖励 | +| `Mail` | `params.mail` | 邮件 | +| `FreeGacha` | `params.recruit` | 免费十连 | +| `Orundum` | `params.orundum` | 合成玉 | +| `Mining` | `params.mining` | 限时开采 | +| `SpecialAccess` | `params.specialaccess` | 月卡等特殊访问 | + +上述布尔字段有值时原样写入。 + +### Roguelike + +对应 GUI 任务队列中的集成战略任务,迁移为 maa-cli 的 `Roguelike` 任务。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `Theme` | `params.theme` | 有值时写入(如 `JieGarden`) | +| `Mode` | `params.mode` | `Exp` → `0`;`Investment` → `1`;`Collect` → `4`;`CollapsalParadigms` → `5`;`MonthlySquad` → `6`;`DeepExploration` → `7`;未知值记入迁移摘要 | +| `Squad` | `params.squad` | 有值时写入 | +| `Roles` | `params.roles` | 有值时写入 | +| `CoreChar` | `params.core_char` | 有值时写入 | +| `StartCount` | `params.starts_count` | 有值时写入 | +| `Difficulty` | `params.difficulty` | 有值且不为 `2147483647`(`i32::MAX`,表示未指定)时写入 | +| `Investment` | `params.investment_enabled` | 有值时写入 | +| `InvestCount` | `params.investments_count` | 有值时写入 | +| `InvestWithMoreScore` | `params.investment_with_more_score` | 有值时写入 | +| `StopWhenDepositFull` | `params.stop_when_investment_full` | 有值时写入 | +| `StopAtFinalBoss` | `params.stop_at_final_boss` | 有值时写入 | +| `StopWhenLevelMax` | `params.stop_at_max_level` | 有值时写入 | +| `UseSupport` | `params.use_support` | 有值时写入 | +| `UseSupportNonFriend` | `params.use_nonfriend_support` | 有值时写入 | +| `RefreshTraderWithDice` | `params.refresh_trader_with_dice` | 有值时写入 | +| `StartWithEliteTwo` | `params.start_with_elite_two` | 有值时写入 | +| `StartWithEliteTwoOnly` | `params.only_start_with_elite_two` | 有值时写入 | + +主题专属字段(如萨米密文板、月度分队自动迭代、种子开局等)目前不会迁移,有实际取值时会出现在迁移摘要中。 + +### Reclamation + +对应 GUI 任务队列中的生息演算任务,迁移为 maa-cli 的 `Reclamation` 任务。 + +| GUI 字段 | maa-cli 字段 | 规则 | +| --- | --- | --- | +| `Name` | `name` | 有值时写入 | +| `Theme` | `params.theme` | 有值时写入(如 `Tales`) | +| `Mode` | `params.mode` | `ProsperityNoSave` → `0`;`ProsperityInSave` → `1`;未知值记入迁移摘要 | +| `ToolToCraft` | `params.tools_to_craft` | 非空时写入单元素数组 | +| `IncrementMode` | `params.increment_mode` | 有值时写入 | +| `MaxCraftCountPerRound` | `params.num_craft_batches` | 有值时写入 | + +其余未映射字段(如 `ClearStore`)有实际取值时会出现在迁移摘要中。 + ## config-v1 到 config-v2 TODO diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 55fed9a1..546102d6 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -1041,6 +1041,37 @@ mod tests { }) ); } + + #[test] + fn migrate_infrast_task_rejects_custom_mode() { + let task = object!( + "$type" => "InfrastTask", + "Mode" => "Custom", + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let err = infrast::migrate_infrast_task(&task, &mut summary).unwrap_err(); + assert!( + err.to_string().contains("not supported yet"), + "unexpected error: {err}" + ); + } + + #[test] + fn migrate_infrast_task_rejects_custom_filename() { + let task = object!( + "$type" => "InfrastTask", + "Mode" => "Normal", + "Filename" => "custom.json", + "IsEnable" => true, + ); + let mut summary = MigrationSummary::default(); + let err = infrast::migrate_infrast_task(&task, &mut summary).unwrap_err(); + assert!( + err.to_string().contains("not supported yet"), + "unexpected error: {err}" + ); + } } mod start_up { @@ -1417,7 +1448,7 @@ mod fight { } mod infrast { - use anyhow::Result; + use anyhow::{Result, bail}; use maa_value::prelude::*; use super::{MigrationSummary, report_unhandled_fields}; @@ -1426,6 +1457,18 @@ mod infrast { task: &MAAValue, summary: &mut MigrationSummary, ) -> Result> { + // Custom infrastructure plans are not migrated yet. + if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") + && mode == "Custom" + { + bail!("InfrastTask custom mode is not supported yet"); + } + if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") + && !filename.is_empty() + { + bail!("InfrastTask custom plan (Filename) is not supported yet"); + } + let mut item = object!("type" => "Infrast"); // -> task name if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { @@ -1444,14 +1487,14 @@ mod infrast { "ReceptionMessageBoard", "ReceptionClueExchange", "SendClue", + // Custom plan fields: rejected above when set; keep handled so defaults are quiet. "Filename", "PlanSelect", ]; - // Mode -> mode + // Mode -> mode (Custom is rejected above) if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { if let Some(mode) = match mode.as_str() { "Normal" => Some(0), - "Custom" => Some(10000), "Rotation" => Some(20000), _ => None, } { @@ -1515,18 +1558,6 @@ mod infrast { if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("SendClue") { insert!(params, "reception_send_clue" => *enabled); } - // Filename -> filename - if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") - && !filename.is_empty() - { - insert!(params, "filename" => filename.as_str()); - } - // PlanSelect -> plan_index - if let Some(MAAValue::Primitive(MAAPrimitive::Int(plan_index))) = task.get("PlanSelect") - && *plan_index >= 0 - { - insert!(params, "plan_index" => *plan_index); - } insert!(item, "params" => params); report_unhandled_fields(summary, task, "InfrastTask", &handled); From 793d418e7b4e3510ebc162da9db975428f058d35 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Tue, 4 Aug 2026 15:07:06 +0800 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=94=A7=20fix:=20silence=20clippy=20?= =?UTF-8?q?lints=20in=20WPF=20migrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use if-let for single-pattern StartGame matching Prefer slice to_vec for Weekday condition weekdays 修复 WPF 迁移中的 clippy 警告 --- crates/maa-cli/src/config/migrate/wpf.rs | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 546102d6..402aabd6 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -411,8 +411,7 @@ mod tests { "params": { "client_type": { "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"], - }, - "start_game_enabled": "false" + } } }, { @@ -629,12 +628,7 @@ mod tests { "type": "StartUp", "params": { "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type" - }, - "start_game_enabled": { - "default": true, - "description": "start the game" + "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"] } } }) @@ -689,8 +683,7 @@ mod tests { "type": "StartUp", "params": { "client_type": { - "alternatives": ["Official", "YoStarEN", "YoStarJP"], - "description": "a client type" + "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"] }, "start_game_enabled": false } @@ -1113,11 +1106,10 @@ mod start_up { } // Gui.RuntimeSettings.StartGame -> start_game_enabled - match runtime.and_then(|settings| settings.get("StartGame")) { - Some(MAAValue::Primitive(MAAPrimitive::Bool(start_game))) => { - insert!(params, "start_game_enabled" => *start_game); - } - _ => {} + if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_game))) = + runtime.and_then(|settings| settings.get("StartGame")) + { + insert!(params, "start_game_enabled" => *start_game); } // AccountSwitchEnabled + AccountName -> account_name @@ -1441,7 +1433,7 @@ mod fight { fn weekday_condition(weekdays: &[&str]) -> MAAValue { object!( "type" => "Weekday", - "weekdays" => weekdays.iter().copied().collect::>()??, + "weekdays" => weekdays.to_vec()??, "timezone" => "Official" ) } From b0370e8999bb0b55aba66f7ce5c6b40e4e47af6f Mon Sep 17 00:00:00 2001 From: nan-mu Date: Wed, 5 Aug 2026 14:08:25 +0800 Subject: [PATCH 10/20] =?UTF-8?q?=F0=9F=94=A7=20chore:=20exclude=20WPF=20G?= =?UTF-8?q?UI=20sample=20from=20typos=20and=20run=20rustfmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip gui.new.json so upstream GUI field names do not fail spellcheck Keep CI typos/rustfmt checks green for the migrate branch 对 typos 排除 WPF GUI 示例文件使其通过并让 rustfmt 检查通过 --- Cargo.toml | 2 +- crates/maa-cli/src/config/migrate/mod.rs | 4 +- crates/maa-cli/src/config/migrate/wpf.rs | 188 ++++++++++------------- 3 files changed, 79 insertions(+), 115 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0f3196cd..37c72fed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [ ] } [workspace.metadata.typos.files] -extend-exclude = ["CHANGELOG.md"] +extend-exclude = ["CHANGELOG.md", "crates/maa-cli/config_examples/wpf/gui.new.json"] [profile.release-lto] inherits = "release" diff --git a/crates/maa-cli/src/config/migrate/mod.rs b/crates/maa-cli/src/config/migrate/mod.rs index 313b3186..913b3a46 100644 --- a/crates/maa-cli/src/config/migrate/mod.rs +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -87,9 +87,7 @@ impl MigrationSummary { } if !self.disabled_tasks.is_empty() { - eprintln!( - " Disabled tasks (kept inactive with never-true condition; will not run):" - ); + eprintln!(" Disabled tasks (kept inactive with never-true condition; will not run):"); for task in &self.disabled_tasks { eprintln!(" - {}", format_task_ref(task)); } diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 402aabd6..954f123c 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -319,13 +319,10 @@ mod tests { }; assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].get("type").unwrap().as_str().unwrap(), "StartUp"); - assert_eq!( - summary.skipped_tasks, - vec![SkippedTask { - type_tag: "UserDataUpdateTask".into(), - name: Some("update".into()), - }] - ); + assert_eq!(summary.skipped_tasks, vec![SkippedTask { + type_tag: "UserDataUpdateTask".into(), + name: Some("update".into()), + }]); } #[test] @@ -356,13 +353,10 @@ mod tests { })) .unwrap(); assert_eq!(value, expected); - assert_eq!( - summary.disabled_tasks, - vec![SkippedTask { - type_tag: "FightTask".into(), - name: Some("日常经验本".into()), - }] - ); + assert_eq!(summary.disabled_tasks, vec![SkippedTask { + type_tag: "FightTask".into(), + name: Some("日常经验本".into()), + }]); } #[test] @@ -564,26 +558,20 @@ mod tests { })) .unwrap(); assert_eq!(value, expected); - assert_eq!( - summary.skipped_tasks, - vec![SkippedTask { - type_tag: "UserDataUpdateTask".into(), + assert_eq!(summary.skipped_tasks, vec![SkippedTask { + type_tag: "UserDataUpdateTask".into(), + name: None, + }]); + assert_eq!(summary.disabled_tasks, vec![ + SkippedTask { + type_tag: "RoguelikeTask".into(), name: None, - }] - ); - assert_eq!( - summary.disabled_tasks, - vec![ - SkippedTask { - type_tag: "RoguelikeTask".into(), - name: None, - }, - SkippedTask { - type_tag: "ReclamationTask".into(), - name: None, - }, - ] - ); + }, + SkippedTask { + type_tag: "ReclamationTask".into(), + name: None, + }, + ]); assert!( summary .skipped_fields @@ -1218,29 +1206,24 @@ mod fight { } } - report_unhandled_fields( - summary, - task, - "FightTask", - &[ - "UseWeeklySchedule", - "WeeklySchedule", - "UseOptionalStage", - "StagePlan", - "UseMedicine", - "MedicineCount", - "UseStone", - "StoneCount", - "EnableTimesLimit", - "TimesLimit", - "EnableTargetDrop", - "DropId", - "DropCount", - "Series", - "UseExpiringMedicine", - "MedicineExpireDays", - ], - ); + report_unhandled_fields(summary, task, "FightTask", &[ + "UseWeeklySchedule", + "WeeklySchedule", + "UseOptionalStage", + "StagePlan", + "UseMedicine", + "MedicineCount", + "UseStone", + "StoneCount", + "EnableTimesLimit", + "TimesLimit", + "EnableTargetDrop", + "DropId", + "DropCount", + "Series", + "UseExpiringMedicine", + "MedicineExpireDays", + ]); Ok(Some(item)) } @@ -1418,9 +1401,7 @@ mod fight { if side_story.iter().any(|code| code == stage) { return object!("type" => "OnSideStory"); } - warn!( - "FightTask stage `{stage}` has no known open schedule; treating as Always" - ); + warn!("FightTask stage `{stage}` has no known open schedule; treating as Always"); always_condition() } } @@ -1646,29 +1627,24 @@ mod recruit { } insert!(item, "params" => params); - report_unhandled_fields( - summary, - task, - "RecruitTask", - &[ - "MaxTimes", - "ExtraTagMode", - "RefreshLevel3", - "ForceRefresh", - "Level6Choose", - "Level6Time", - "Level5Choose", - "Level5Time", - "Level4Choose", - "Level4Time", - "Level3Choose", - "Level3Time", - "PreferTagEnabled", - "Level3PreferTags", - "PreserveTagEnabled", - "PreserveTagList", - ], - ); + report_unhandled_fields(summary, task, "RecruitTask", &[ + "MaxTimes", + "ExtraTagMode", + "RefreshLevel3", + "ForceRefresh", + "Level6Choose", + "Level6Time", + "Level5Choose", + "Level5Time", + "Level4Choose", + "Level4Time", + "Level3Choose", + "Level3Time", + "PreferTagEnabled", + "Level3PreferTags", + "PreserveTagEnabled", + "PreserveTagList", + ]); Ok(Some(item)) } } @@ -1744,22 +1720,17 @@ mod mall { } insert!(item, "params" => params); - report_unhandled_fields( - summary, - task, - "MallTask", - &[ - "Shopping", - "CreditFight", - "CreditFightFormation", - "VisitFriends", - "FirstList", - "BlackList", - "ShoppingIgnoreBlackListWhenFull", - "OnlyBuyDiscount", - "ReserveMaxCredit", - ], - ); + report_unhandled_fields(summary, task, "MallTask", &[ + "Shopping", + "CreditFight", + "CreditFightFormation", + "VisitFriends", + "FirstList", + "BlackList", + "ShoppingIgnoreBlackListWhenFull", + "OnlyBuyDiscount", + "ReserveMaxCredit", + ]); Ok(Some(item)) } } @@ -1809,19 +1780,14 @@ mod award { } insert!(item, "params" => params); - report_unhandled_fields( - summary, - task, - "AwardTask", - &[ - "Award", - "Mail", - "FreeGacha", - "Orundum", - "Mining", - "SpecialAccess", - ], - ); + report_unhandled_fields(summary, task, "AwardTask", &[ + "Award", + "Mail", + "FreeGacha", + "Orundum", + "Mining", + "SpecialAccess", + ]); Ok(Some(item)) } } From d217b2863b88bbf7a29ca0f3f73771b928f2de3c Mon Sep 17 00:00:00 2001 From: nan-mu Date: Thu, 6 Aug 2026 13:57:40 +0800 Subject: [PATCH 11/20] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20chore:=20remove?= =?UTF-8?q?=20default=5Fprofile.json=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh: 删除default_profile.json example --- .../maa-cli/fixtures/gui/default_profile.json | 844 ------------------ 1 file changed, 844 deletions(-) delete mode 100644 crates/maa-cli/fixtures/gui/default_profile.json diff --git a/crates/maa-cli/fixtures/gui/default_profile.json b/crates/maa-cli/fixtures/gui/default_profile.json deleted file mode 100644 index 248648b4..00000000 --- a/crates/maa-cli/fixtures/gui/default_profile.json +++ /dev/null @@ -1,844 +0,0 @@ -{ - "Configurations": { - "Default": { - "InfrastOrder": {}, - "TaskQueue": [ - { - "$type": "StartUpTask", - "AccountName": "", - "AccountSwitchEnabled": false, - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "LS-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "CE-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "InfrastTask", - "Mode": "Normal", - "UsesOfDrones": "Money", - "DormThreshold": 30, - "DormTrustEnabled": true, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - "ContinueTraining": true, - "Filename": "", - "PlanSelect": -1, - "RoomList": [ - { - "Room": "Mfg" - }, - { - "Room": "Trade" - }, - { - "Room": "Control" - }, - { - "Room": "Power" - }, - { - "Room": "Reception" - }, - { - "Room": "Office" - }, - { - "Room": "Dorm" - }, - { - "Room": "Processing" - }, - { - "Room": "Training" - } - ], - "Name": "", - "IsEnable": true, - "TaskType": "Infrast" - }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" - ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, - { - "$type": "MallTask", - "Shopping": true, - "CreditFight": false, - "CreditFightFormation": 0, - "CreditFightLastTime": "2026/06/08 00:00:00", - "CreditFightOnceADay": true, - "VisitFriends": true, - "VisitFriendsOnceADay": true, - "VisitFriendsLastTime": "2026/07/10 00:00:00", - "FirstList": "加急许可;招聘许可", - "BlackList": "碳;家具;", - "ShoppingIgnoreBlackListWhenFull": true, - "OnlyBuyDiscount": false, - "ReserveMaxCredit": false, - "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": false, - "Name": "", - "IsEnable": true, - "TaskType": "Mall" - }, - { - "$type": "AwardTask", - "Award": true, - "Mail": true, - "FreeGacha": false, - "Orundum": true, - "Mining": true, - "SpecialAccess": true, - "Name": "", - "IsEnable": true, - "TaskType": "Award" - }, - { - "$type": "RoguelikeTask", - "Theme": "JieGarden", - "Difficulty": 2147483647, - "Mode": "Investment", - "Squad": "指挥分队", - "SquadCollectible": "指挥分队", - "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", - "Investment": true, - "InvestCount": 999, - "InvestWithMoreScore": false, - "CollectibleStartAwards": "HotWater", - "CollectibleShopping": false, - "StartWithEliteTwo": false, - "StartWithEliteTwoOnly": false, - "ExpectedCollapsalParadigms": "", - "MonthlySquadAutoIterate": true, - "MonthlySquadCheckComms": true, - "DeepExplorationAutoIterate": true, - "FindPlaytimeTarget": "Ling", - "UseSupport": false, - "UseSupportNonFriend": false, - "RefreshTraderWithDice": false, - "SquadIsFoldartal": false, - "SamiFirstFloorFoldartal": false, - "SamiFirstFloorFoldartals": "", - "SamiNewSquad2StartingFoldartal": false, - "SamiNewSquad2StartingFoldartals": "", - "StopWhenDepositFull": true, - "StopAtFinalBoss": false, - "StopWhenLevelMax": false, - "StartWithSeed": false, - "Seed": "", - "Name": "", - "IsEnable": false, - "TaskType": "Roguelike" - }, - { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" - }, - { - "$type": "UserDataUpdateTask", - "UpdateOperBox": true, - "UpdateDepot": true, - "TriggerInterval": "EveryTime", - "IsTriggered": true, - "Name": "", - "IsEnable": true, - "TaskType": "UserDataUpdate" - } - ], - "TaskSelectedIndex": 3, - "DragItemIsChecked": {} - }, - "Dev": { - "InfrastOrder": {}, - "TaskQueue": [ - { - "$type": "StartUpTask", - "AccountName": "", - "AccountSwitchEnabled": false, - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "LS-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "CE-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "InfrastTask", - "Mode": "Normal", - "UsesOfDrones": "Money", - "DormThreshold": 30, - "DormTrustEnabled": true, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - "ContinueTraining": true, - "Filename": "", - "PlanSelect": -1, - "RoomList": [ - { - "Room": "Mfg" - }, - { - "Room": "Trade" - }, - { - "Room": "Control" - }, - { - "Room": "Power" - }, - { - "Room": "Reception" - }, - { - "Room": "Office" - }, - { - "Room": "Dorm" - }, - { - "Room": "Processing" - }, - { - "Room": "Training" - } - ], - "Name": "", - "IsEnable": true, - "TaskType": "Infrast" - }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" - ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, - { - "$type": "MallTask", - "Shopping": true, - "CreditFight": false, - "CreditFightFormation": 0, - "CreditFightLastTime": "2026/06/08 00:00:00", - "CreditFightOnceADay": true, - "VisitFriends": true, - "VisitFriendsOnceADay": true, - "VisitFriendsLastTime": "2026/07/05 00:00:00", - "FirstList": "加急许可;招聘许可", - "BlackList": "碳;家具;", - "ShoppingIgnoreBlackListWhenFull": true, - "OnlyBuyDiscount": false, - "ReserveMaxCredit": false, - "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": true, - "Name": "", - "IsEnable": true, - "TaskType": "Mall" - }, - { - "$type": "AwardTask", - "Award": true, - "Mail": true, - "FreeGacha": false, - "Orundum": true, - "Mining": true, - "SpecialAccess": true, - "Name": "", - "IsEnable": true, - "TaskType": "Award" - }, - { - "$type": "RoguelikeTask", - "Theme": "JieGarden", - "Difficulty": 2147483647, - "Mode": "Investment", - "Squad": "指挥分队", - "SquadCollectible": "指挥分队", - "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", - "Investment": true, - "InvestCount": 999, - "InvestWithMoreScore": false, - "CollectibleStartAwards": "HotWater", - "CollectibleShopping": false, - "StartWithEliteTwo": false, - "StartWithEliteTwoOnly": false, - "ExpectedCollapsalParadigms": "", - "MonthlySquadAutoIterate": true, - "MonthlySquadCheckComms": true, - "DeepExplorationAutoIterate": true, - "FindPlaytimeTarget": "Ling", - "UseSupport": false, - "UseSupportNonFriend": false, - "RefreshTraderWithDice": false, - "SquadIsFoldartal": false, - "SamiFirstFloorFoldartal": false, - "SamiFirstFloorFoldartals": "", - "SamiNewSquad2StartingFoldartal": false, - "SamiNewSquad2StartingFoldartals": "", - "StopWhenDepositFull": true, - "StopAtFinalBoss": false, - "StopWhenLevelMax": false, - "StartWithSeed": false, - "Seed": "", - "Name": "", - "IsEnable": false, - "TaskType": "Roguelike" - }, - { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" - }, - { - "$type": "UserDataUpdateTask", - "UpdateOperBox": true, - "UpdateDepot": true, - "TriggerInterval": "EveryTime", - "IsTriggered": true, - "Name": "", - "IsEnable": true, - "TaskType": "UserDataUpdate" - } - ], - "TaskSelectedIndex": 1, - "DragItemIsChecked": {} - }, - "26/07/10 18:36:14": { - "InfrastOrder": {}, - "TaskQueue": [ - { - "$type": "StartUpTask", - "AccountName": "", - "AccountSwitchEnabled": false, - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "LS-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "CE-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "InfrastTask", - "Mode": "Normal", - "UsesOfDrones": "Money", - "DormThreshold": 30, - "DormTrustEnabled": true, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - "ContinueTraining": true, - "Filename": "", - "PlanSelect": -1, - "RoomList": [ - { - "Room": "Mfg" - }, - { - "Room": "Trade" - }, - { - "Room": "Control" - }, - { - "Room": "Power" - }, - { - "Room": "Reception" - }, - { - "Room": "Office" - }, - { - "Room": "Dorm" - }, - { - "Room": "Processing" - }, - { - "Room": "Training" - } - ], - "Name": "", - "IsEnable": true, - "TaskType": "Infrast" - }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" - ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, - { - "$type": "MallTask", - "Shopping": true, - "CreditFight": false, - "CreditFightFormation": 0, - "CreditFightLastTime": "2026/06/08 00:00:00", - "CreditFightOnceADay": true, - "VisitFriends": true, - "VisitFriendsOnceADay": true, - "VisitFriendsLastTime": "2026/07/10 00:00:00", - "FirstList": "加急许可;招聘许可", - "BlackList": "碳;家具;", - "ShoppingIgnoreBlackListWhenFull": true, - "OnlyBuyDiscount": false, - "ReserveMaxCredit": false, - "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": false, - "Name": "", - "IsEnable": true, - "TaskType": "Mall" - }, - { - "$type": "AwardTask", - "Award": true, - "Mail": true, - "FreeGacha": false, - "Orundum": true, - "Mining": true, - "SpecialAccess": true, - "Name": "", - "IsEnable": true, - "TaskType": "Award" - }, - { - "$type": "RoguelikeTask", - "Theme": "JieGarden", - "Difficulty": 2147483647, - "Mode": "Investment", - "Squad": "指挥分队", - "SquadCollectible": "指挥分队", - "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", - "Investment": true, - "InvestCount": 999, - "InvestWithMoreScore": false, - "CollectibleStartAwards": "HotWater", - "CollectibleShopping": false, - "StartWithEliteTwo": false, - "StartWithEliteTwoOnly": false, - "ExpectedCollapsalParadigms": "", - "MonthlySquadAutoIterate": true, - "MonthlySquadCheckComms": true, - "DeepExplorationAutoIterate": true, - "FindPlaytimeTarget": "Ling", - "UseSupport": false, - "UseSupportNonFriend": false, - "RefreshTraderWithDice": false, - "SquadIsFoldartal": false, - "SamiFirstFloorFoldartal": false, - "SamiFirstFloorFoldartals": "", - "SamiNewSquad2StartingFoldartal": false, - "SamiNewSquad2StartingFoldartals": "", - "StopWhenDepositFull": true, - "StopAtFinalBoss": false, - "StopWhenLevelMax": false, - "StartWithSeed": false, - "Seed": "", - "Name": "", - "IsEnable": false, - "TaskType": "Roguelike" - }, - { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" - }, - { - "$type": "UserDataUpdateTask", - "UpdateOperBox": true, - "UpdateDepot": true, - "TriggerInterval": "EveryTime", - "IsTriggered": true, - "Name": "", - "IsEnable": true, - "TaskType": "UserDataUpdate" - } - ], - "TaskSelectedIndex": 3, - "DragItemIsChecked": {} - } - }, - "Timers": {}, - "ConfigVersion": 1, - "Current": "Default", - "VersionUpdate": { - "Name": "", - "Body": "", - "IsFirstBoot": false, - "Package": "", - "Proxy": "", - "VersionType": "Stable", - "UpdateCheck": true, - "UpdateAutoCheck": false, - "AutoDownloadUpdatePackage": true, - "AutoInstallUpdatePackage": false, - "ShowUpdaterConsole": false, - "ShowUpdaterProgress": true - }, - "AnnouncementInfo": { - "DoNotShowAgain": true, - "DoNotShow": false - }, - "GUI": { - "DarkMode": "SyncWithOs", - "UseNotify": true, - "MainTasksInvertNullFunction": false, - "Localization": "zh-cn", - "MinimizeToTray": false, - "HideCloseButton": false, - "LogItemDateFormat": "HH:mm:ss", - "LoadWindowPlacement": true, - "SaveWindowPlacement": true, - "InverseClearMode": "Clear", - "WindowTitlePrefix": "", - "IgnoreBadModulesAndUseSoftwareRendering": false, - "ExpanderSwitchConfiguration": true, - "ExpanderScheduleSettings": true, - "ExpanderPerformanceSettings": true, - "ExpanderGameSettings": true, - "ExpanderConnectionSettings": true, - "ExpanderStartupSettings": true, - "ExpanderRemoteControlSettings": true, - "ExpanderUiSettings": true, - "ExpanderBackgroundSettings": true, - "ExpanderExternalNotificationSettings": true, - "ExpanderHotKeySettings": true, - "ExpanderAchievementSettings": true, - "ExpanderUpdateSettings": true, - "ExpanderIssueReport": true, - "ExpanderAboutUs": true, - "BackgroundMonetEnabled": false, - "BackgroundMonetMode": "Auto", - "BackgroundMonetCustomColor": "#326CF3", - "BackgroundMonetCachedColor": "" - } -} \ No newline at end of file From bdcb6f8c6c7eb7658ef10880c2d54f8d95eb32ab Mon Sep 17 00:00:00 2001 From: nan-mu Date: Thu, 6 Aug 2026 16:31:31 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20rework?= =?UTF-8?q?=20WPF=20migrate=20entry=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the CLI entry into wpf.rs and parse profiles as serde_json::Value first Drop --format and MAA_GUI_CONFIG; default to adjacent .toml when output is omitted Require .json input and take the selected configuration by ownership 重构 WPF 迁移入口并简化配置选择与输出路径 --- crates/maa-cli/src/command.rs | 18 - crates/maa-cli/src/config/migrate/mod.rs | 58 +- crates/maa-cli/src/config/migrate/wpf.rs | 978 ++--------------------- crates/maa-cli/src/main.rs | 3 +- 4 files changed, 74 insertions(+), 983 deletions(-) diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 39231cac..bf07a4a9 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -269,15 +269,7 @@ pub(crate) enum MigrateCommand { input: PathBuf, /// Path of the output file, if not specified, the output will be printed to stdout output: Option, - /// Format of the output file, can be one of "toml", "yaml" and "json" - /// - /// If not specified, the format will be guessed from the file extension of the output - /// file. If output file is not specified, the output will be default to "json". - #[arg(short, long)] - format: Option, /// Select a named configuration when migrating a multi-profile GUI export - /// - /// Can also be set with the `MAA_GUI_CONFIG` environment variable. #[arg(long)] config: Option, }, @@ -625,7 +617,6 @@ mod test { Command::Migrate(MigrateCommand::Wpf { input, output: None, - format: None, config: None, }) if input == Path::new("profile.json") ); @@ -644,20 +635,11 @@ mod test { Command::Migrate(MigrateCommand::Wpf { input, output: Some(output), - format: None, config: Some(config), }) if input == Path::new("profile.json") && output == Path::new("tasks.toml") && config == "Default" ); - - assert_matches!( - parse_from(["maa", "migrate", "wpf", "profile.json", "-f", "yaml"]).command, - Command::Migrate(MigrateCommand::Wpf { - format: Some(config::Filetype::Yaml), - .. - }) - ); } #[test] diff --git a/crates/maa-cli/src/config/migrate/mod.rs b/crates/maa-cli/src/config/migrate/mod.rs index 913b3a46..e163192c 100644 --- a/crates/maa-cli/src/config/migrate/mod.rs +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -6,12 +6,7 @@ mod wpf; -use std::path::Path; - -use anyhow::{Context, Result, bail}; -use maa_value::value::MAAValue; - -use super::{Filetype, FromFile}; +pub(crate) use wpf::wpf; /// Summary of lossy choices made during migration. #[derive(Debug, Default, PartialEq, Eq)] @@ -115,54 +110,3 @@ fn format_task_ref(task: &SkippedTask) -> String { _ => task.type_tag.clone(), } } - -/// Migrate a MAA WPF GUI profile into a maa-cli task config. -/// -/// ```text -/// maa migrate wpf [output] -/// ``` -pub fn wpf( - file: &Path, - out: Option<&Path>, - ft: Option, - config_name: Option<&str>, -) -> Result<()> { - use maa_dirs::Ensure; - - if !matches!(Filetype::parse_filetype(file), Some(Filetype::Json)) { - log::warn!( - "`maa migrate wpf` expects a MAA WPF GUI profile (typically JSON); \ - input {} is not JSON", - file.display() - ); - } - - let ft = ft.or_else(|| { - out.and_then(|path| path.extension()) - .and_then(|ext| ext.to_str()) - .and_then(Filetype::parse_extension) - }); - - let value = MAAValue::from_file(file)?; - let value = wpf::select_configuration(value, config_name)?; - let (value, summary) = wpf::migrate(value)?; - - let Some(format) = ft else { - bail!("Format not given") - }; - - if let Some(file) = out { - let file = file.with_extension(format.to_str()); - if let Some(dir) = file.parent() { - dir.ensure()?; - } - format - .write(&file, &value) - .with_context(|| format!("Failed to write migrated file {}", file.display()))?; - } else { - format.write_to(std::io::stdout().lock(), &value)?; - } - - summary.print(); - Ok(()) -} diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 954f123c..371003db 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -1,63 +1,93 @@ -use std::num::NonZero; +use std::{io::BufReader, num::NonZero, path::Path}; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, bail, ensure}; +use log::trace; use maa_value::{ map::StringMap, prelude::*, userinput::{SelectD, UserInput}, }; +use serde_json::Value; use super::MigrationSummary; - -const GUI_CONFIG_ENV: &str = "MAA_GUI_CONFIG"; +use crate::config::Filetype; /// Meta / structural keys that are never reported as skipped fields. const META_FIELDS: &[&str] = &["$type", "TaskType", "Name", "IsEnable"]; +/// Migrate a MAA WPF GUI profile into a maa-cli task config. +/// +/// ```text +/// maa migrate wpf [output] +/// ``` +pub(crate) fn wpf(file: &Path, out: Option<&Path>, config: Option) -> Result<()> { + ensure!( + file.extension() == Some("json".as_ref()), + "`maa migrate wpf` expected a MAA GUI profile (typically .json); input {file:?} is not a JSON file" + ); + + let value = BufReader::new(std::fs::File::open(file).context("Trying to open wpf profile")?); + let value = serde_json::from_reader(value)?; + let value = select_configuration(value, config)?; + let (value, summary) = migrate(value)?; + + let mut temp_path = Default::default(); + let out = out.unwrap_or_else(|| { + temp_path = file.with_extension("toml"); + temp_path.as_path() + }); + let ft = Filetype::parse_filetype(out).unwrap(); + if let Some(dir) = out.parent() { + use maa_dirs::Ensure; + dir.ensure()?; + } + ft.write(&out, &value) + .with_context(|| format!("Failed to write migrated file {}", out.display())); + + summary.print(); + Ok(()) +} + /// Pick one configuration from a multi-profile GUI export. /// /// Legacy single-config profiles are returned unchanged. /// -/// When multiple configurations exist, `config_name` or [`GUI_CONFIG_ENV`] can be +/// When multiple configurations exist, `config_name` can be /// used to select one without an interactive prompt. -pub(super) fn select_configuration(input: MAAValue, config_name: Option<&str>) -> Result { - let Some(configurations_value) = input.get("Configurations") else { - return Ok(input); - }; - let Some(configurations) = configurations_value.as_map() else { - bail!("GUI profile Configurations must be an object"); +pub(super) fn select_configuration(mut input: Value, config_name: Option) -> Result { + let current = input.get("Current").and_then(|v| v.as_str()).map(str::to_string).unwrap_or_default(); + let Some(configurations) = input.get_mut("Configurations") else { + bail!("GUI profile missing Configurations"); }; - match configurations.len() { - 0 => bail!("GUI profile has no configuration"), - 1 => Ok(configurations.values().next().unwrap().clone()), - _ => { - let selected_name = resolve_configuration_name(&input, configurations, config_name)?; - configurations - .get(&selected_name) - .cloned() - .with_context(|| format!("GUI configuration {selected_name} not found")) + let config_name = match config_name { + Some(name) => name, + None => { + let Some(object) = configurations.as_object() else { + bail!("GUI profile Configurations is not an object"); + }; + match object.len() { + 0 => bail!("GUI profile has no configuration"), + 1 => object.keys().next().unwrap().to_string(), + _ => resolve_configuration_name(object, current)? + } } - } + }; + trace!("Selected configuration: {config_name}"); + let Some(profile) = configurations.get_mut(&config_name) else { + bail!("GUI configuration {config_name} not found"); + }; + Ok(profile.take()) } fn resolve_configuration_name( - input: &MAAValue, - configurations: &StringMap, - config_name: Option<&str>, + object: &serde_json::map::Map, + current: String, ) -> Result { - if let Some(name) = config_name { - return Ok(name.to_string()); - } - if let Ok(name) = std::env::var(GUI_CONFIG_ENV) { - return Ok(name); - } - - let names: Vec<&str> = configurations.keys().map(String::as_str).collect(); - let default_index = input - .get("Current") - .and_then(|v| v.as_str()) - .and_then(|current| names.iter().position(|name| *name == current)) + let names: Vec<&str> = object.keys().map(String::as_str).collect(); + let default_index = object + .iter() + .position(|(name, _)| *name == current) .and_then(|i| NonZero::new(i + 1)); SelectD::::from_iter(names, default_index) @@ -68,23 +98,12 @@ fn resolve_configuration_name( } /// Migrate a GUI profile `MAAValue` into maa-cli task config shape. -pub(super) fn migrate(input: MAAValue) -> Result<(MAAValue, MigrationSummary)> { - let queue = input - .get("TaskQueue") - .context("GUI profile missing TaskQueue")?; - let MAAValue::Array(queue) = queue else { - bail!("GUI profile TaskQueue must be an array"); - }; - - let mut summary = MigrationSummary::default(); - let mut tasks = Vec::new(); - for task in queue { - if let Some(item) = migrate_task(task, &input, &mut summary)? { - tasks.push(item); - } - } - - Ok((object!("tasks" => tasks??), summary)) +pub(super) fn migrate( + input: Value, +) -> Result<(MAAValue, MigrationSummary)> { + let summary = MigrationSummary::default(); + + unimplemented!() } fn migrate_task( @@ -202,859 +221,6 @@ fn is_meaningful_unhandled(value: &MAAValue) -> bool { } } -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::*; - use crate::config::migrate::{SkippedField, SkippedTask}; - - macro_rules! gui_task_test { - ($name:ident, $converter:path, $task_json:expr, $($expected:tt)*) => { - #[test] - fn $name() { - let task: MAAValue = serde_json::from_str($task_json).unwrap(); - let mut summary = MigrationSummary::default(); - let actual = $converter(&task, &mut summary).unwrap().unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!($($expected)*) - ); - } - }; - } - - const DEFAULT_PROFILE: &str = include_str!("../../../fixtures/gui/default_profile.json"); - - #[test] - fn select_configuration_passes_through_legacy_profile() { - let input = object!("legacy" => true); - assert_eq!(select_configuration(input.clone(), None).unwrap(), input); - } - - #[test] - fn select_configuration_picks_the_only_configuration() { - let input = object!( - "Configurations" => object!( - "Default" => object!("name" => "only") - ) - ); - assert_eq!( - select_configuration(input, None).unwrap(), - object!("name" => "only") - ); - } - - #[test] - fn select_configuration_uses_current_in_batch_mode() { - let input = object!( - "Current" => "Dev", - "Configurations" => object!( - "Default" => object!("name" => "default"), - "Dev" => object!("name" => "dev"), - ) - ); - assert_eq!( - select_configuration(input, None).unwrap(), - object!("name" => "dev") - ); - } - - #[test] - fn select_configuration_uses_config_name_override() { - let input = object!( - "Current" => "Default", - "Configurations" => object!( - "Default" => object!("name" => "default"), - "Dev" => object!("name" => "dev"), - ) - ); - assert_eq!( - select_configuration(input, Some("Dev")).unwrap(), - object!("name" => "dev") - ); - } - - #[test] - fn select_configuration_uses_env_override() { - let input = object!( - "Configurations" => object!( - "Default" => object!("name" => "default"), - "Dev" => object!("name" => "dev"), - ) - ); - - // SAFETY: test-only env var, restored before return. - unsafe { std::env::set_var(GUI_CONFIG_ENV, "Dev") }; - let result = select_configuration(input, None); - unsafe { std::env::remove_var(GUI_CONFIG_ENV) }; - - assert_eq!(result.unwrap(), object!("name" => "dev")); - } - - #[test] - fn missing_task_queue_is_error() { - let input = object!("a" => 1); - let err = migrate(input).unwrap_err(); - assert!(err.to_string().contains("TaskQueue")); - } - - #[test] - fn task_queue_not_array_is_error() { - let input = object!("TaskQueue" => "not-an-array"); - let err = migrate(input).unwrap_err(); - assert!(err.to_string().contains("TaskQueue must be an array")); - } - - #[test] - fn unknown_task_type_is_skipped() { - let input = object!( - "TaskQueue" => vec![ - object!("$type" => "UserDataUpdateTask", "Name" => "update"), - object!("$type" => "StartUpTask", "IsEnable" => true), - ]?? - ); - let (value, summary) = migrate(input).unwrap(); - let MAAValue::Array(tasks) = value.get("tasks").unwrap() else { - panic!("tasks should be an array"); - }; - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0].get("type").unwrap().as_str().unwrap(), "StartUp"); - assert_eq!(summary.skipped_tasks, vec![SkippedTask { - type_tag: "UserDataUpdateTask".into(), - name: Some("update".into()), - }]); - } - - #[test] - fn disabled_task_is_kept_inactive() { - let input = object!( - "TaskQueue" => vec![ - object!( - "$type" => "FightTask", - "Name" => "日常经验本", - "IsEnable" => false, - "StagePlan" => vec!["LS-6"]??, - ), - ]?? - ); - let (value, summary) = migrate(input).unwrap(); - let expected: MAAValue = serde_json::from_value(serde_json::json!({ - "tasks": [{ - "type": "Fight", - "name": "日常经验本", - "variants": [{ - "condition": { - "type": "Not", - "condition": { "type": "Always" } - }, - "params": { "stage": "LS-6" } - }] - }] - })) - .unwrap(); - assert_eq!(value, expected); - assert_eq!(summary.disabled_tasks, vec![SkippedTask { - type_tag: "FightTask".into(), - name: Some("日常经验本".into()), - }]); - } - - #[test] - fn skipped_fields_are_reported() { - let input = object!( - "TaskQueue" => vec![ - object!( - "$type" => "StartUpTask", - "AccountName" => "123****4567", - "AccountSwitchEnabled" => false, - "Name" => "启动游戏", - "IsEnable" => true, - ), - object!( - "$type" => "ReclamationTask", - "Theme" => "Tales", - "Mode" => "ProsperityInSave", - "ClearStore" => true, - "IsEnable" => true, - ), - ]?? - ); - let (_, summary) = migrate(input).unwrap(); - assert!(summary.skipped_fields.contains(&SkippedField { - task_type: "ReclamationTask".into(), - task_name: None, - field: "ClearStore".into(), - })); - // Account switching is off, so AccountName is intentionally ignored (handled). - assert!( - !summary - .skipped_fields - .iter() - .any(|field| { field.task_type == "StartUpTask" && field.field == "AccountName" }) - ); - } - - #[test] - fn json_to_json_default_profile() { - let input: MAAValue = serde_json::from_str(DEFAULT_PROFILE).unwrap(); - let (value, summary) = migrate(select_configuration(input, None).unwrap()).unwrap(); - let expected: MAAValue = serde_json::from_value(serde_json::json!({ - "tasks": [ - { - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"], - } - } - }, - { - "type": "Fight", - "name": "日常经验本", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Mon", "Wed", "Fri"] - }, - "params": { - "medicine_expire_days": 2, - "stage": "LS-6" - } - } - ] - }, - { - "type": "Fight", - "name": "日常龙门币", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Tue", "Thu", "Sat"] - }, - "params": { - "medicine_expire_days": 2, - "stage": "CE-6" - } - } - ] - }, - { - "type": "Infrast", - "name": "", - "params": { - "mode": 0, - "facility": [ - "Mfg", - "Trade", - "Control", - "Power", - "Reception", - "Office", - "Dorm", - "Processing", - "Training" - ], - "drones": "Money", - "threshold": 0.3, - "replenish": true, - "dorm_notstationed_enabled": true, - "dorm_trust_enabled": true, - "reception_message_board": true, - "reception_clue_exchange": true, - "reception_send_clue": true - } - }, - { - "type": "Recruit", - "name": "", - "params": { - "times": 4, - "extra_tags_mode": 0, - "refresh": true, - "expedite": true, - "select": [5, 4, 3], - "confirm": [5, 4, 3], - "recruitment_time": { - "3": 540, - "4": 540 - } - } - }, - { - "type": "Mall", - "name": "", - "params": { - "shopping": true, - "credit_fight": false, - "formation_index": 0, - "visit_friends": true, - "buy_first": ["加急许可", "招聘许可"], - "blacklist": ["碳", "家具"], - "force_shopping_if_credit_full": true, - "only_buy_discount": false, - "reserve_max_credit": false - } - }, - { - "type": "Award", - "name": "", - "params": { - "award": true, - "mail": true, - "recruit": false, - "orundum": true, - "mining": true, - "specialaccess": true - } - }, - { - "type": "Roguelike", - "name": "", - "variants": [{ - "condition": { - "type": "Not", - "condition": { "type": "Always" } - }, - "params": { - "theme": "JieGarden", - "mode": 1, - "squad": "指挥分队", - "roles": "稳扎稳打", - "core_char": "维什戴尔", - "starts_count": 999999, - "investment_enabled": true, - "investments_count": 999, - "investment_with_more_score": false, - "stop_when_investment_full": true, - "stop_at_final_boss": false, - "stop_at_max_level": false, - "use_support": false, - "use_nonfriend_support": false, - "refresh_trader_with_dice": false, - "start_with_elite_two": false, - "only_start_with_elite_two": false - } - }] - }, - { - "type": "Reclamation", - "name": "", - "variants": [{ - "condition": { - "type": "Not", - "condition": { "type": "Always" } - }, - "params": { - "theme": "Tales", - "mode": 1, - "increment_mode": 0, - "num_craft_batches": 16 - } - }] - } - ] - })) - .unwrap(); - assert_eq!(value, expected); - assert_eq!(summary.skipped_tasks, vec![SkippedTask { - type_tag: "UserDataUpdateTask".into(), - name: None, - }]); - assert_eq!(summary.disabled_tasks, vec![ - SkippedTask { - type_tag: "RoguelikeTask".into(), - name: None, - }, - SkippedTask { - type_tag: "ReclamationTask".into(), - name: None, - }, - ]); - assert!( - summary - .skipped_fields - .iter() - .any(|field| field.field == "ClearStore") - ); - assert!( - summary - .skipped_fields - .iter() - .any(|field| field.task_type == "FightTask" && field.field == "StageResetMode") - ); - } - - const STARTUP_TASK: &str = r#"{ - "$type": "StartUpTask", - "AccountName": "", - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" -}"#; - - const STARTUP_TASK_WITH_ACCOUNT: &str = r#"{ - "$type": "StartUpTask", - "AccountName": "123****4567", - "Name": "启动游戏", - "IsEnable": true, - "TaskType": "StartUp" -}"#; - - #[test] - fn migrate_start_up_task_without_runtime_settings() { - let task: MAAValue = serde_json::from_str(STARTUP_TASK).unwrap(); - let config = object!(); - let mut summary = MigrationSummary::default(); - let actual = start_up::migrate_start_up_task(&task, &config, &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!({ - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"] - } - } - }) - ); - } - - #[test] - fn migrate_start_up_task_from_runtime_settings() { - let task: MAAValue = serde_json::from_str(STARTUP_TASK).unwrap(); - let config = object!( - "Gui" => object!( - "RuntimeSettings" => object!( - "ClientType" => "Official", - "StartGame" => true, - ) - ) - ); - let mut summary = MigrationSummary::default(); - let actual = start_up::migrate_start_up_task(&task, &config, &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!({ - "type": "StartUp", - "params": { - "client_type": "Official", - "start_game_enabled": true - } - }) - ); - } - - #[test] - fn migrate_start_up_task_invalid_client_type_falls_back() { - let task: MAAValue = serde_json::from_str(STARTUP_TASK_WITH_ACCOUNT).unwrap(); - let config = object!( - "Gui" => object!( - "RuntimeSettings" => object!( - "ClientType" => "NotAClient", - "StartGame" => false, - ) - ) - ); - let mut summary = MigrationSummary::default(); - let actual = start_up::migrate_start_up_task(&task, &config, &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!({ - "type": "StartUp", - "params": { - "client_type": { - "alternatives": ["Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR"] - }, - "start_game_enabled": false - } - }) - ); - } - - #[test] - fn migrate_start_up_task_writes_account_name_when_switch_enabled() { - let task = object!( - "$type" => "StartUpTask", - "AccountName" => "123****4567", - "AccountSwitchEnabled" => true, - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let actual = start_up::migrate_start_up_task(&task, &object!(), &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - actual - .get("params") - .and_then(|params| params.get("account_name")), - Some(&MAAValue::from("123****4567")) - ); - } - - #[test] - fn migrate_start_up_task_omits_account_name_when_switch_disabled() { - let task = object!( - "$type" => "StartUpTask", - "AccountName" => "123****4567", - "AccountSwitchEnabled" => false, - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let actual = start_up::migrate_start_up_task(&task, &object!(), &mut summary) - .unwrap() - .unwrap(); - assert!( - actual - .get("params") - .and_then(|params| params.get("account_name")) - .is_none() - ); - } - - const FIGHT_TASK: &str = r#"{ - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": ["LS-6"], - "IsDrGrandet": false, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" -}"#; - - const WEEKLY_LS6_TASK: &str = r#"{ - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": ["LS-6"], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" -}"#; - - const WEEKLY_CE6_TASK: &str = r#"{ - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": ["CE-6"], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" -}"#; - - gui_task_test!( - migrate_fight_task_basic, - fight::migrate_fight_task, - FIGHT_TASK, - { - "type": "Fight", - "name": "日常经验本", - "params": { - "stage": "LS-6" - } - } - ); - - gui_task_test!( - migrate_fight_task_weekly_schedule_ls6, - fight::migrate_fight_task, - WEEKLY_LS6_TASK, - { - "type": "Fight", - "name": "日常经验本", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Mon", "Wed", "Fri"] - }, - "params": { - "medicine_expire_days": 2, - "stage": "LS-6" - } - } - ] - } - ); - - gui_task_test!( - migrate_fight_task_weekly_schedule_ce6, - fight::migrate_fight_task, - WEEKLY_CE6_TASK, - { - "type": "Fight", - "name": "日常龙门币", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Tue", "Thu", "Sat"] - }, - "params": { - "medicine_expire_days": 2, - "stage": "CE-6" - } - } - ] - } - ); - - #[test] - fn migrate_fight_task_rejects_multi_stage_without_optional() { - let task = object!( - "$type" => "FightTask", - "StagePlan" => vec!["CE-6", "LS-6"]??, - "UseWeeklySchedule" => false, - "UseOptionalStage" => false, - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let err = fight::migrate_fight_task(&task, &mut summary).unwrap_err(); - assert!( - err.to_string().contains("single stage string"), - "unexpected error: {err}" - ); - } - - #[test] - fn migrate_fight_task_shared_params() { - let task = object!( - "$type" => "FightTask", - "UseMedicine" => true, - "MedicineCount" => 3, - "UseStone" => true, - "StoneCount" => 2, - "EnableTimesLimit" => true, - "TimesLimit" => 10, - "EnableTargetDrop" => true, - "DropId" => "30012", - "DropCount" => 100, - "Series" => 6, - "StagePlan" => vec!["1-7"]??, - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let actual = fight::migrate_fight_task(&task, &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!({ - "type": "Fight", - "params": { - "medicine": 3, - "stone": 2, - "times": 10, - "drops": { "30012": 100 }, - "series": 6, - "stage": "1-7" - } - }) - ); - } - - #[test] - fn migrate_fight_task_optional_stage_conditions() { - let task = object!( - "$type" => "FightTask", - "UseOptionalStage" => true, - "UseWeeklySchedule" => false, - "StagePlan" => vec!["CE-6", "LS-6", "Annihilation", "1-7"]??, - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let actual = fight::migrate_fight_task(&task, &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!({ - "type": "Fight", - "variants": [ - { - "condition": { - "type": "Weekday", - "weekdays": ["Tue", "Thu", "Sat", "Sun"], - "timezone": "Official" - }, - "params": { "stage": "CE-6" } - }, - { - "condition": { "type": "Always" }, - "params": { "stage": "LS-6" } - }, - { - "condition": { "type": "Always" }, - "params": { "stage": "Annihilation" } - }, - { - "condition": { "type": "Always" }, - "params": { "stage": "1-7" } - } - ] - }) - ); - } - - #[test] - fn migrate_fight_task_weekly_and_optional_and_condition() { - let task = object!( - "$type" => "FightTask", - "UseOptionalStage" => true, - "UseWeeklySchedule" => true, - "WeeklySchedule" => object!( - "Sunday" => true, - "Monday" => false, - "Tuesday" => false, - "Wednesday" => false, - "Thursday" => false, - "Friday" => false, - "Saturday" => false, - ), - "StagePlan" => vec!["CE-6"]??, - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let actual = fight::migrate_fight_task(&task, &mut summary) - .unwrap() - .unwrap(); - assert_eq!( - serde_json::to_value(&actual).unwrap(), - serde_json::json!({ - "type": "Fight", - "variants": [ - { - "condition": { - "type": "And", - "conditions": [ - { - "type": "Weekday", - "weekdays": ["Sun"] - }, - { - "type": "Weekday", - "weekdays": ["Tue", "Thu", "Sat", "Sun"], - "timezone": "Official" - } - ] - }, - "params": { "stage": "CE-6" } - } - ] - }) - ); - } - - #[test] - fn migrate_infrast_task_rejects_custom_mode() { - let task = object!( - "$type" => "InfrastTask", - "Mode" => "Custom", - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let err = infrast::migrate_infrast_task(&task, &mut summary).unwrap_err(); - assert!( - err.to_string().contains("not supported yet"), - "unexpected error: {err}" - ); - } - - #[test] - fn migrate_infrast_task_rejects_custom_filename() { - let task = object!( - "$type" => "InfrastTask", - "Mode" => "Normal", - "Filename" => "custom.json", - "IsEnable" => true, - ); - let mut summary = MigrationSummary::default(); - let err = infrast::migrate_infrast_task(&task, &mut summary).unwrap_err(); - assert!( - err.to_string().contains("not supported yet"), - "unexpected error: {err}" - ); - } -} - mod start_up { use anyhow::Result; use log::warn; diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 9b3dc588..ef536e08 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -111,9 +111,8 @@ fn main() -> Result<()> { Command::Migrate(command::MigrateCommand::Wpf { input, output, - format, config, - }) => config::migrate::wpf(&input, output.as_deref(), format, config.as_deref())?, + }) => config::migrate::wpf(&input, output.as_deref(), config)?, Command::Activity { client } => activity::display_stage_activity(client)?, Command::Remainder { divisor, timezone } => { use crate::config::task::{TimeOffset, remainder_of_day_mod}; From 507f1899c36bb4e3b112dab5f54515f146bb38bf Mon Sep 17 00:00:00 2001 From: nan-mu Date: Thu, 6 Aug 2026 16:51:09 +0800 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=92=AC=20chore:=20comment=20out=20o?= =?UTF-8?q?ld=20logic,=20prepare=20for=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注释旧逻辑,为迁移做准备 --- crates/maa-cli/src/config/migrate/wpf.rs | 2134 +++++++++++----------- 1 file changed, 1079 insertions(+), 1055 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 371003db..ad0a36c0 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -106,1069 +106,1093 @@ pub(super) fn migrate( unimplemented!() } -fn migrate_task( - task: &MAAValue, - config: &MAAValue, - summary: &mut MigrationSummary, -) -> Result> { - let type_tag = task - .get("$type") - .and_then(|v| v.as_str()) - .context("GUI task missing $type")?; - let name = task_name(task); - let disabled = matches!( - task.get("IsEnable"), - Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) - ); - - let mut item = match type_tag { - "StartUpTask" => start_up::migrate_start_up_task(task, config, summary)?, - "FightTask" => fight::migrate_fight_task(task, summary)?, - "InfrastTask" => infrast::migrate_infrast_task(task, summary)?, - "RecruitTask" => recruit::migrate_recruit_task(task, summary)?, - "MallTask" => mall::migrate_mall_task(task, summary)?, - "AwardTask" => award::migrate_award_task(task, summary)?, - "RoguelikeTask" => roguelike::migrate_roguelike_task(task, summary)?, - "ReclamationTask" => reclamation::migrate_reclamation_task(task, summary)?, - type_tag => { - summary.skip_task(type_tag, name); - return Ok(None); - } - }; - - if disabled && let Some(item) = item.as_mut() { - apply_disabled_condition(item); - summary.disable_task(type_tag, name); - } - - Ok(item) -} - -fn task_name(task: &MAAValue) -> Option { - task.get("Name") - .and_then(|v| v.as_str()) - .filter(|name| !name.is_empty()) - .map(str::to_string) -} - -/// Keep a disabled GUI task in the output, but ensure it never becomes active. -fn apply_disabled_condition(item: &mut MAAValue) { - let never = never_condition(); - - if let Some(MAAValue::Array(variants)) = item.get_mut("variants") { - for variant in variants.iter_mut() { - let new_cond = match variant.get("condition") { - Some(cond) => object!( - "type" => "And", - "conditions" => vec![cond.clone(), never.clone()]?? - ), - None => never.clone(), - }; - variant.insert("condition", new_cond); - } - return; - } - - let params = item.get("params").cloned().unwrap_or_default(); - if let Some(map) = item.as_mut_map() { - map.shift_remove("params"); - } - item.insert( - "variants", - MAAValue::Array(vec![object!( - "condition" => never, - "params" => params - )]), - ); +struct Configuration { + task_queue: Vec, + gui: GuiSettings, } -fn never_condition() -> MAAValue { - object!( - "type" => "Not", - "condition" => object!("type" => "Always") - ) +enum WpfTask { + StartUpTask(start_up::StartUpTask), + // FightTask(FightTask), + // InfrastTask(InfrastTask), + // RecruitTask(RecruitTask), + // MallTask(MallTask), + // AwardTask(AwardTask), + // RoguelikeTask(RoguelikeTask), + // ReclamationTask(ReclamationTask), + Unsupported { + type_tag: String, + raw: serde_json::Value, + }, } -fn report_unhandled_fields( - summary: &mut MigrationSummary, - task: &MAAValue, - task_type: &str, - handled: &[&str], -) { - let name = task_name(task); - let Some(map) = task.as_map() else { - return; - }; - for (key, value) in map { - if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { - continue; - } - if is_meaningful_unhandled(value) { - summary.skip_field(task_type, name.clone(), key.clone()); - } - } -} - -/// Whether an unhandled GUI field likely affects runtime behavior if dropped. -fn is_meaningful_unhandled(value: &MAAValue) -> bool { - match value { - MAAValue::Primitive(MAAPrimitive::Bool(v)) => *v, - MAAValue::Primitive(MAAPrimitive::String(s)) => !s.is_empty(), - MAAValue::Primitive(MAAPrimitive::Int(i)) => *i != 0 && *i != i32::MAX, - MAAValue::Primitive(MAAPrimitive::Float(f)) => *f != 0.0, - MAAValue::Array(items) => !items.is_empty(), - MAAValue::Object(map) => !map.is_empty(), - } -} +struct GuiSettings {} + +// fn migrate_task( +// task: &MAAValue, +// config: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let type_tag = task +// .get("$type") +// .and_then(|v| v.as_str()) +// .context("GUI task missing $type")?; +// let name = task_name(task); +// let disabled = matches!( +// task.get("IsEnable"), +// Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) +// ); + +// let mut item = match type_tag { +// "StartUpTask" => start_up::migrate_start_up_task(task, config, summary)?, +// "FightTask" => fight::migrate_fight_task(task, summary)?, +// "InfrastTask" => infrast::migrate_infrast_task(task, summary)?, +// "RecruitTask" => recruit::migrate_recruit_task(task, summary)?, +// "MallTask" => mall::migrate_mall_task(task, summary)?, +// "AwardTask" => award::migrate_award_task(task, summary)?, +// "RoguelikeTask" => roguelike::migrate_roguelike_task(task, summary)?, +// "ReclamationTask" => reclamation::migrate_reclamation_task(task, summary)?, +// type_tag => { +// summary.skip_task(type_tag, name); +// return Ok(None); +// } +// }; + +// if disabled && let Some(item) = item.as_mut() { +// apply_disabled_condition(item); +// summary.disable_task(type_tag, name); +// } + +// Ok(item) +// } + +// fn task_name(task: &MAAValue) -> Option { +// task.get("Name") +// .and_then(|v| v.as_str()) +// .filter(|name| !name.is_empty()) +// .map(str::to_string) +// } + +// /// Keep a disabled GUI task in the output, but ensure it never becomes active. +// fn apply_disabled_condition(item: &mut MAAValue) { +// let never = never_condition(); + +// if let Some(MAAValue::Array(variants)) = item.get_mut("variants") { +// for variant in variants.iter_mut() { +// let new_cond = match variant.get("condition") { +// Some(cond) => object!( +// "type" => "And", +// "conditions" => vec![cond.clone(), never.clone()]?? +// ), +// None => never.clone(), +// }; +// variant.insert("condition", new_cond); +// } +// return; +// } + +// let params = item.get("params").cloned().unwrap_or_default(); +// if let Some(map) = item.as_mut_map() { +// map.shift_remove("params"); +// } +// item.insert( +// "variants", +// MAAValue::Array(vec![object!( +// "condition" => never, +// "params" => params +// )]), +// ); +// } + +// fn never_condition() -> MAAValue { +// object!( +// "type" => "Not", +// "condition" => object!("type" => "Always") +// ) +// } + +// fn report_unhandled_fields( +// summary: &mut MigrationSummary, +// task: &MAAValue, +// task_type: &str, +// handled: &[&str], +// ) { +// let name = task_name(task); +// let Some(map) = task.as_map() else { +// return; +// }; +// for (key, value) in map { +// if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { +// continue; +// } +// if is_meaningful_unhandled(value) { +// summary.skip_field(task_type, name.clone(), key.clone()); +// } +// } +// } + +// /// Whether an unhandled GUI field likely affects runtime behavior if dropped. +// fn is_meaningful_unhandled(value: &MAAValue) -> bool { +// match value { +// MAAValue::Primitive(MAAPrimitive::Bool(v)) => *v, +// MAAValue::Primitive(MAAPrimitive::String(s)) => !s.is_empty(), +// MAAValue::Primitive(MAAPrimitive::Int(i)) => *i != 0 && *i != i32::MAX, +// MAAValue::Primitive(MAAPrimitive::Float(f)) => *f != 0.0, +// MAAValue::Array(items) => !items.is_empty(), +// MAAValue::Object(map) => !map.is_empty(), +// } +// } mod start_up { - use anyhow::Result; - use log::warn; - use maa_value::prelude::*; - - use super::MigrationSummary; - - /// Valid GUI / maa-cli client type strings. - const VALID_CLIENT_TYPES: &[&str] = &[ - "Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR", - ]; - - pub(super) fn migrate_start_up_task( - task: &MAAValue, - config: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let runtime = config.get("Gui").and_then(|gui| gui.get("RuntimeSettings")); - - let mut params = MAAValue::default(); - - // Gui.RuntimeSettings.ClientType -> client_type - // Missing / invalid values fall back to an interactive prompt. - match runtime - .and_then(|settings| settings.get("ClientType")) - .and_then(|value| value.as_str()) - .filter(|value| VALID_CLIENT_TYPES.contains(value)) - { - Some(client_type) => { - insert!(params, "client_type" => client_type); - } - None => { - insert!(params, "client_type" => object!( - "alternatives" => VALID_CLIENT_TYPES.iter().map(|s| s.to_string()).collect::>()??, - )); - } - } - - // Gui.RuntimeSettings.StartGame -> start_game_enabled - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_game))) = - runtime.and_then(|settings| settings.get("StartGame")) - { - insert!(params, "start_game_enabled" => *start_game); - } - - // AccountSwitchEnabled + AccountName -> account_name - // Only emit account_name when account switching is enabled. - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = - task.get("AccountSwitchEnabled") - { - if let Some(MAAValue::Primitive(MAAPrimitive::String(account_name))) = - task.get("AccountName") - { - insert!(params, "account_name" => account_name.as_str()); - } else { - summary.skip_field("StartUpTask", None, "AccountName"); - warn!("AccountName is missing, but GUI enable account switching"); - } - } - - let item = object!( - "type" => "StartUp", - "params" => params - ); - - Ok(Some(item)) - } + // use anyhow::Result; + // use log::warn; + // use maa_value::prelude::*; + + // use super::MigrationSummary; + + // /// Valid GUI / maa-cli client type strings. + // const VALID_CLIENT_TYPES: &[&str] = &[ + // "Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR", + // ]; + + pub(super) struct StartUpTask {} + + // pub(super) fn migrate_start_up_task( + // task: &MAAValue, + // config: &MAAValue, + // summary: &mut MigrationSummary, + // ) -> Result> { + // let runtime = config.get("Gui").and_then(|gui| gui.get("RuntimeSettings")); + + // let mut params = MAAValue::default(); + + // // Gui.RuntimeSettings.ClientType -> client_type + // // Missing / invalid values fall back to an interactive prompt. + // match runtime + // .and_then(|settings| settings.get("ClientType")) + // .and_then(|value| value.as_str()) + // .filter(|value| VALID_CLIENT_TYPES.contains(value)) + // { + // Some(client_type) => { + // insert!(params, "client_type" => client_type); + // } + // None => { + // insert!(params, "client_type" => object!( + // "alternatives" => VALID_CLIENT_TYPES.iter().map(|s| s.to_string()).collect::>()??, + // )); + // } + // } + + // // Gui.RuntimeSettings.StartGame -> start_game_enabled + // if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_game))) = + // runtime.and_then(|settings| settings.get("StartGame")) + // { + // insert!(params, "start_game_enabled" => *start_game); + // } + + // // AccountSwitchEnabled + AccountName -> account_name + // // Only emit account_name when account switching is enabled. + // if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = + // task.get("AccountSwitchEnabled") + // { + // if let Some(MAAValue::Primitive(MAAPrimitive::String(account_name))) = + // task.get("AccountName") + // { + // insert!(params, "account_name" => account_name.as_str()); + // } else { + // summary.skip_field("StartUpTask", None, "AccountName"); + // warn!("AccountName is missing, but GUI enable account switching"); + // } + // } + + // let item = object!( + // "type" => "StartUp", + // "params" => params + // ); + + // Ok(Some(item)) + // } } mod fight { - use anyhow::{Result, bail}; - use log::warn; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - use crate::config::task::ClientType; - - pub(super) fn migrate_fight_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let mut item = object!("type" => "Fight"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let shared_params = shared_fight_params(task); - - let use_weekly = flag(task, "UseWeeklySchedule"); - let use_optional = flag(task, "UseOptionalStage"); - - match (use_weekly, use_optional) { - // No variants: StagePlan must be a single stage. - (false, false) => { - let stage = stage_plan_single(task)?; - let mut params = shared_params; - insert!(params, "stage" => stage); - insert!(item, "params" => params); - } - // One variant gated by weekly schedule; StagePlan must be a single stage. - (true, false) => { - let stage = stage_plan_single(task)?; - let weekly = weekly_schedule_condition(task)?; - let mut params = shared_params; - insert!(params, "stage" => stage); - insert!( - item, - "variants" => vec![object!( - "condition" => weekly, - "params" => params - )]?? - ); - } - // One variant per optional stage, each with its own open-condition. - (false, true) => { - let stages = stage_plan_array(task)?; - let mut variants = Vec::with_capacity(stages.len()); - for stage in stages { - let mut params = shared_params.clone(); - insert!(params, "stage" => stage); - variants.push(object!( - "condition" => stage_condition(stage), - "params" => params - )); - } - insert!(item, "variants" => variants??); - } - // Weekly schedule AND each stage's open-condition. - (true, true) => { - let stages = stage_plan_array(task)?; - let weekly = weekly_schedule_condition(task)?; - let mut variants = Vec::with_capacity(stages.len()); - for stage in stages { - let mut params = shared_params.clone(); - insert!(params, "stage" => stage); - variants.push(object!( - "condition" => object!( - "type" => "And", - "conditions" => vec![ - weekly.clone(), - stage_condition(stage) - ]?? - ), - "params" => params - )); - } - insert!(item, "variants" => variants??); - } - } - - report_unhandled_fields(summary, task, "FightTask", &[ - "UseWeeklySchedule", - "WeeklySchedule", - "UseOptionalStage", - "StagePlan", - "UseMedicine", - "MedicineCount", - "UseStone", - "StoneCount", - "EnableTimesLimit", - "TimesLimit", - "EnableTargetDrop", - "DropId", - "DropCount", - "Series", - "UseExpiringMedicine", - "MedicineExpireDays", - ]); - - Ok(Some(item)) - } - - fn flag(task: &MAAValue, key: &str) -> bool { - matches!( - task.get(key), - Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) - ) - } - - /// Shared Fight params that do not depend on stage / condition branching. - fn shared_fight_params(task: &MAAValue) -> MAAValue { - let mut params = MAAValue::default(); - - // UseMedicine + MedicineCount -> medicine - if flag(task, "UseMedicine") - && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("MedicineCount") - { - insert!(params, "medicine" => *count); - } - - // UseStone + StoneCount -> stone - if flag(task, "UseStone") - && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("StoneCount") - { - warn!( - "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" - ); - insert!(params, "stone" => *count); - } - - // EnableTimesLimit + TimesLimit -> times - if flag(task, "EnableTimesLimit") - && let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("TimesLimit") - { - insert!(params, "times" => *times); - } - - // EnableTargetDrop + DropId/DropCount -> drops = { = } - if flag(task, "EnableTargetDrop") - && let ( - Some(MAAValue::Primitive(MAAPrimitive::String(drop_id))), - Some(MAAValue::Primitive(MAAPrimitive::Int(drop_count))), - ) = (task.get("DropId"), task.get("DropCount")) - { - let mut drop_map = maa_value::map::StringMap::new(); - drop_map.insert(drop_id.clone(), (*drop_count).into()); - insert!(params, "drops" => MAAValue::Object(drop_map)); - } - - // Series -> series (only when non-zero) - if let Some(MAAValue::Primitive(MAAPrimitive::Int(series))) = task.get("Series") - && *series != 0 - { - insert!(params, "series" => *series); - } - - // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days - if flag(task, "UseExpiringMedicine") - && let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = - task.get("MedicineExpireDays") - { - insert!(params, "medicine_expire_days" => *days); - } - - params - } - - /// `UseWeeklySchedule` + `WeeklySchedule` -> `Weekday` condition. - fn weekly_schedule_condition(task: &MAAValue) -> Result { - let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") else { - bail!("FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid"); - }; - - const DAYS: [(&str, &str); 7] = [ - ("Sunday", "Sun"), - ("Monday", "Mon"), - ("Tuesday", "Tue"), - ("Wednesday", "Wed"), - ("Thursday", "Thu"), - ("Friday", "Fri"), - ("Saturday", "Sat"), - ]; - - let mut weekdays = Vec::new(); - for (gui_day, cli_day) in DAYS { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { - weekdays.push(cli_day); - } - } - - Ok(object!( - "type" => "Weekday", - "weekdays" => weekdays?? - )) - } - - /// Parse `StagePlan` as a single stage name. - /// - /// GUI usually stores a single stage as a one-element array (`["CE-6"]`). - /// A multi-element array is rejected when `UseOptionalStage` is false. - fn stage_plan_single(task: &MAAValue) -> Result<&str> { - match task.get("StagePlan") { - Some(MAAValue::Primitive(MAAPrimitive::String(stage))) => Ok(stage.as_str()), - Some(MAAValue::Array(stages)) if stages.len() == 1 => match &stages[0] { - MAAValue::Primitive(MAAPrimitive::String(stage)) => Ok(stage.as_str()), - _ => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), - }, - Some(MAAValue::Array(_)) => { - bail!( - "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ - got a string array; enable UseOptionalStage for multiple stages" - ) - } - Some(_) => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), - None => bail!("FightTask missing StagePlan"), - } - } - - /// Parse `StagePlan` as a non-empty string array (optional-stage mode). - fn stage_plan_array(task: &MAAValue) -> Result> { - match task.get("StagePlan") { - Some(MAAValue::Array(stages)) => { - let mut stage_list = Vec::with_capacity(stages.len()); - for stage in stages { - match stage { - MAAValue::Primitive(MAAPrimitive::String(stage)) => { - stage_list.push(stage.as_str()); - } - _ => bail!( - "FightTask StagePlan array elements must be strings when UseOptionalStage is true" - ), - } - } - if stage_list.is_empty() { - bail!( - "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" - ); - } - Ok(stage_list) - } - Some(MAAValue::Primitive(MAAPrimitive::String(_))) => { - bail!( - "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" - ) - } - Some(_) => { - bail!("FightTask StagePlan must be a string array when UseOptionalStage is true") - } - None => bail!("FightTask missing StagePlan"), - } - } - - /// Build the open-condition for a fight stage name. - /// - /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) - /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` - /// - Side-story stages listed in `StageActivityV2.json` → `OnSideStory` - fn stage_condition(stage: &str) -> MAAValue { - match stage { - // 资源本 / 芯片本(同开放日合并) - "CE-6" => weekday_condition(&["Tue", "Thu", "Sat", "Sun"]), - "AP-5" => weekday_condition(&["Mon", "Thu", "Sat", "Sun"]), - "CA-5" => weekday_condition(&["Tue", "Wed", "Fri", "Sun"]), - "SK-5" => weekday_condition(&["Mon", "Wed", "Fri", "Sat"]), - "PR-A-1" | "PR-A-2" => weekday_condition(&["Mon", "Thu", "Fri", "Sun"]), - "PR-B-1" | "PR-B-2" => weekday_condition(&["Mon", "Tue", "Fri", "Sat"]), - "PR-C-1" | "PR-C-2" => weekday_condition(&["Wed", "Thu", "Sat", "Sun"]), - "PR-D-1" | "PR-D-2" => weekday_condition(&["Tue", "Wed", "Sat", "Sun"]), - // 永久开启 - "LS-6" | "Annihilation" | "OF-1" | "OF-F3" => always_condition(), - _ => { - let side_story = crate::activity::side_story_stages(ClientType::Official); - if side_story.iter().any(|code| code == stage) { - return object!("type" => "OnSideStory"); - } - warn!("FightTask stage `{stage}` has no known open schedule; treating as Always"); - always_condition() - } - } - } - - fn always_condition() -> MAAValue { - object!("type" => "Always") - } - - fn weekday_condition(weekdays: &[&str]) -> MAAValue { - object!( - "type" => "Weekday", - "weekdays" => weekdays.to_vec()??, - "timezone" => "Official" - ) - } +// use anyhow::{Result, bail}; +// use log::warn; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; +// use crate::config::task::ClientType; + +// pub(super) fn migrate_fight_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let mut item = object!("type" => "Fight"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let shared_params = shared_fight_params(task); + +// let use_weekly = flag(task, "UseWeeklySchedule"); +// let use_optional = flag(task, "UseOptionalStage"); + +// match (use_weekly, use_optional) { +// // No variants: StagePlan must be a single stage. +// (false, false) => { +// let stage = stage_plan_single(task)?; +// let mut params = shared_params; +// insert!(params, "stage" => stage); +// insert!(item, "params" => params); +// } +// // One variant gated by weekly schedule; StagePlan must be a single stage. +// (true, false) => { +// let stage = stage_plan_single(task)?; +// let weekly = weekly_schedule_condition(task)?; +// let mut params = shared_params; +// insert!(params, "stage" => stage); +// insert!( +// item, +// "variants" => vec![object!( +// "condition" => weekly, +// "params" => params +// )]?? +// ); +// } +// // One variant per optional stage, each with its own open-condition. +// (false, true) => { +// let stages = stage_plan_array(task)?; +// let mut variants = Vec::with_capacity(stages.len()); +// for stage in stages { +// let mut params = shared_params.clone(); +// insert!(params, "stage" => stage); +// variants.push(object!( +// "condition" => stage_condition(stage), +// "params" => params +// )); +// } +// insert!(item, "variants" => variants??); +// } +// // Weekly schedule AND each stage's open-condition. +// (true, true) => { +// let stages = stage_plan_array(task)?; +// let weekly = weekly_schedule_condition(task)?; +// let mut variants = Vec::with_capacity(stages.len()); +// for stage in stages { +// let mut params = shared_params.clone(); +// insert!(params, "stage" => stage); +// variants.push(object!( +// "condition" => object!( +// "type" => "And", +// "conditions" => vec![ +// weekly.clone(), +// stage_condition(stage) +// ]?? +// ), +// "params" => params +// )); +// } +// insert!(item, "variants" => variants??); +// } +// } + +// report_unhandled_fields(summary, task, "FightTask", &[ +// "UseWeeklySchedule", +// "WeeklySchedule", +// "UseOptionalStage", +// "StagePlan", +// "UseMedicine", +// "MedicineCount", +// "UseStone", +// "StoneCount", +// "EnableTimesLimit", +// "TimesLimit", +// "EnableTargetDrop", +// "DropId", +// "DropCount", +// "Series", +// "UseExpiringMedicine", +// "MedicineExpireDays", +// ]); + +// Ok(Some(item)) +// } + +// fn flag(task: &MAAValue, key: &str) -> bool { +// matches!( +// task.get(key), +// Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) +// ) +// } + +// /// Shared Fight params that do not depend on stage / condition branching. +// fn shared_fight_params(task: &MAAValue) -> MAAValue { +// let mut params = MAAValue::default(); + +// // UseMedicine + MedicineCount -> medicine +// if flag(task, "UseMedicine") +// && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("MedicineCount") +// { +// insert!(params, "medicine" => *count); +// } + +// // UseStone + StoneCount -> stone +// if flag(task, "UseStone") +// && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("StoneCount") +// { +// warn!( +// "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" +// ); +// insert!(params, "stone" => *count); +// } + +// // EnableTimesLimit + TimesLimit -> times +// if flag(task, "EnableTimesLimit") +// && let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("TimesLimit") +// { +// insert!(params, "times" => *times); +// } + +// // EnableTargetDrop + DropId/DropCount -> drops = { = } +// if flag(task, "EnableTargetDrop") +// && let ( +// Some(MAAValue::Primitive(MAAPrimitive::String(drop_id))), +// Some(MAAValue::Primitive(MAAPrimitive::Int(drop_count))), +// ) = (task.get("DropId"), task.get("DropCount")) +// { +// let mut drop_map = maa_value::map::StringMap::new(); +// drop_map.insert(drop_id.clone(), (*drop_count).into()); +// insert!(params, "drops" => MAAValue::Object(drop_map)); +// } + +// // Series -> series (only when non-zero) +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(series))) = task.get("Series") +// && *series != 0 +// { +// insert!(params, "series" => *series); +// } + +// // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days +// if flag(task, "UseExpiringMedicine") +// && let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = +// task.get("MedicineExpireDays") +// { +// insert!(params, "medicine_expire_days" => *days); +// } + +// params +// } + +// /// `UseWeeklySchedule` + `WeeklySchedule` -> `Weekday` condition. +// fn weekly_schedule_condition(task: &MAAValue) -> Result { +// let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") else { +// bail!("FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid"); +// }; + +// const DAYS: [(&str, &str); 7] = [ +// ("Sunday", "Sun"), +// ("Monday", "Mon"), +// ("Tuesday", "Tue"), +// ("Wednesday", "Wed"), +// ("Thursday", "Thu"), +// ("Friday", "Fri"), +// ("Saturday", "Sat"), +// ]; + +// let mut weekdays = Vec::new(); +// for (gui_day, cli_day) in DAYS { +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { +// weekdays.push(cli_day); +// } +// } + +// Ok(object!( +// "type" => "Weekday", +// "weekdays" => weekdays?? +// )) +// } + +// /// Parse `StagePlan` as a single stage name. +// /// +// /// GUI usually stores a single stage as a one-element array (`["CE-6"]`). +// /// A multi-element array is rejected when `UseOptionalStage` is false. +// fn stage_plan_single(task: &MAAValue) -> Result<&str> { +// match task.get("StagePlan") { +// Some(MAAValue::Primitive(MAAPrimitive::String(stage))) => Ok(stage.as_str()), +// Some(MAAValue::Array(stages)) if stages.len() == 1 => match &stages[0] { +// MAAValue::Primitive(MAAPrimitive::String(stage)) => Ok(stage.as_str()), +// _ => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), +// }, +// Some(MAAValue::Array(_)) => { +// bail!( +// "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ +// got a string array; enable UseOptionalStage for multiple stages" +// ) +// } +// Some(_) => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), +// None => bail!("FightTask missing StagePlan"), +// } +// } + +// /// Parse `StagePlan` as a non-empty string array (optional-stage mode). +// fn stage_plan_array(task: &MAAValue) -> Result> { +// match task.get("StagePlan") { +// Some(MAAValue::Array(stages)) => { +// let mut stage_list = Vec::with_capacity(stages.len()); +// for stage in stages { +// match stage { +// MAAValue::Primitive(MAAPrimitive::String(stage)) => { +// stage_list.push(stage.as_str()); +// } +// _ => bail!( +// "FightTask StagePlan array elements must be strings when UseOptionalStage is true" +// ), +// } +// } +// if stage_list.is_empty() { +// bail!( +// "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" +// ); +// } +// Ok(stage_list) +// } +// Some(MAAValue::Primitive(MAAPrimitive::String(_))) => { +// bail!( +// "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" +// ) +// } +// Some(_) => { +// bail!("FightTask StagePlan must be a string array when UseOptionalStage is true") +// } +// None => bail!("FightTask missing StagePlan"), +// } +// } + +// /// Build the open-condition for a fight stage name. +// /// +// /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) +// /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` +// /// - Side-story stages listed in `StageActivityV2.json` → `OnSideStory` +// fn stage_condition(stage: &str) -> MAAValue { +// match stage { +// // 资源本 / 芯片本(同开放日合并) +// "CE-6" => weekday_condition(&["Tue", "Thu", "Sat", "Sun"]), +// "AP-5" => weekday_condition(&["Mon", "Thu", "Sat", "Sun"]), +// "CA-5" => weekday_condition(&["Tue", "Wed", "Fri", "Sun"]), +// "SK-5" => weekday_condition(&["Mon", "Wed", "Fri", "Sat"]), +// "PR-A-1" | "PR-A-2" => weekday_condition(&["Mon", "Thu", "Fri", "Sun"]), +// "PR-B-1" | "PR-B-2" => weekday_condition(&["Mon", "Tue", "Fri", "Sat"]), +// "PR-C-1" | "PR-C-2" => weekday_condition(&["Wed", "Thu", "Sat", "Sun"]), +// "PR-D-1" | "PR-D-2" => weekday_condition(&["Tue", "Wed", "Sat", "Sun"]), +// // 永久开启 +// "LS-6" | "Annihilation" | "OF-1" | "OF-F3" => always_condition(), +// _ => { +// let side_story = crate::activity::side_story_stages(ClientType::Official); +// if side_story.iter().any(|code| code == stage) { +// return object!("type" => "OnSideStory"); +// } +// warn!("FightTask stage `{stage}` has no known open schedule; treating as Always"); +// always_condition() +// } +// } +// } + +// fn always_condition() -> MAAValue { +// object!("type" => "Always") +// } + +// fn weekday_condition(weekdays: &[&str]) -> MAAValue { +// object!( +// "type" => "Weekday", +// "weekdays" => weekdays.to_vec()??, +// "timezone" => "Official" +// ) +// } } -mod infrast { - use anyhow::{Result, bail}; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - - pub(super) fn migrate_infrast_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - // Custom infrastructure plans are not migrated yet. - if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") - && mode == "Custom" - { - bail!("InfrastTask custom mode is not supported yet"); - } - if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") - && !filename.is_empty() - { - bail!("InfrastTask custom plan (Filename) is not supported yet"); - } - - let mut item = object!("type" => "Infrast"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut params = MAAValue::default(); - let mut handled = vec![ - "Mode", - "RoomList", - "UsesOfDrones", - "DormThreshold", - "OriginiumShardAutoReplenishment", - "DormFilterNotStationed", - "DormTrustEnabled", - "ReceptionMessageBoard", - "ReceptionClueExchange", - "SendClue", - // Custom plan fields: rejected above when set; keep handled so defaults are quiet. - "Filename", - "PlanSelect", - ]; - // Mode -> mode (Custom is rejected above) - if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { - if let Some(mode) = match mode.as_str() { - "Normal" => Some(0), - "Rotation" => Some(20000), - _ => None, - } { - insert!(params, "mode" => mode); - } else { - handled.retain(|field| *field != "Mode"); - } - } - // RoomList -> facility - if let Some(MAAValue::Array(rooms)) = task.get("RoomList") { - let mut facility = Vec::new(); - for room in rooms { - if let MAAValue::Object(map) = room - && let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") - { - facility.push(room.as_str()); - } - } - if !facility.is_empty() { - insert!(params, "facility" => facility??); - } - } - // UsesOfDrones -> drones - if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") { - insert!(params, "drones" => drones.as_str()); - } - // DormThreshold -> threshold - if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = task.get("DormThreshold") { - insert!(params, "threshold" => *threshold as f32 / 100.0); - } - // OriginiumShardAutoReplenishment -> replenish - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(replenish))) = - task.get("OriginiumShardAutoReplenishment") - { - insert!(params, "replenish" => *replenish); - } - // DormFilterNotStationed -> dorm_notstationed_enabled - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = - task.get("DormFilterNotStationed") - { - insert!(params, "dorm_notstationed_enabled" => *enabled); - } - // DormTrustEnabled -> dorm_trust_enabled - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("DormTrustEnabled") - { - insert!(params, "dorm_trust_enabled" => *enabled); - } - // ReceptionMessageBoard -> reception_message_board - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = - task.get("ReceptionMessageBoard") - { - insert!(params, "reception_message_board" => *enabled); - } - // ReceptionClueExchange -> reception_clue_exchange - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = - task.get("ReceptionClueExchange") - { - insert!(params, "reception_clue_exchange" => *enabled); - } - // SendClue -> reception_send_clue - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("SendClue") { - insert!(params, "reception_send_clue" => *enabled); - } - - insert!(item, "params" => params); - report_unhandled_fields(summary, task, "InfrastTask", &handled); - Ok(Some(item)) - } -} - -mod recruit { - use anyhow::Result; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - - pub(super) fn migrate_recruit_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let mut item = object!("type" => "Recruit"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut params = MAAValue::default(); - // MaxTimes -> times - if let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("MaxTimes") { - insert!(params, "times" => *times); - } - // ExtraTagMode -> extra_tags_mode - if let Some(MAAValue::Primitive(MAAPrimitive::Int(mode))) = task.get("ExtraTagMode") { - insert!(params, "extra_tags_mode" => *mode); - } - // RefreshLevel3 -> refresh - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") { - insert!(params, "refresh" => *refresh); - } - // ForceRefresh -> expedite - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") { - insert!(params, "expedite" => *expedite); - } - // LevelXChoose -> select / confirm - let mut select = Vec::new(); - let mut confirm = Vec::new(); - let mut recruitment_time = MAAValue::default(); - const LEVELS: [(&str, &str, i32); 4] = [ - ("Level6Choose", "Level6Time", 6), - ("Level5Choose", "Level5Time", 5), - ("Level4Choose", "Level4Time", 4), - ("Level3Choose", "Level3Time", 3), - ]; - for (choose_key, time_key, level) in LEVELS { - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get(choose_key) { - select.push(level); - confirm.push(level); - } - if let Some(MAAValue::Primitive(MAAPrimitive::Int(minutes))) = task.get(time_key) { - recruitment_time.insert(level.to_string(), (*minutes).into()); - } - } - if !select.is_empty() { - insert!(params, "select" => select??); - insert!(params, "confirm" => confirm??); - } - if recruitment_time.as_map().is_some_and(|map| !map.is_empty()) { - insert!(params, "recruitment_time" => recruitment_time); - } - // PreferTagEnabled + Level3PreferTags -> first_tags - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreferTagEnabled") - && let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") - { - let mut first_tags = Vec::new(); - for tag in tags { - if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { - first_tags.push(tag.as_str()); - } - } - if !first_tags.is_empty() { - insert!(params, "first_tags" => first_tags??); - } - } - // PreserveTagEnabled + PreserveTagList -> preserve_tags - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreserveTagEnabled") - && let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") - { - let mut preserve_tags = Vec::new(); - for tag in tags { - if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { - preserve_tags.push(tag.as_str()); - } - } - if !preserve_tags.is_empty() { - insert!(params, "preserve_tags" => preserve_tags??); - } - } - - insert!(item, "params" => params); - report_unhandled_fields(summary, task, "RecruitTask", &[ - "MaxTimes", - "ExtraTagMode", - "RefreshLevel3", - "ForceRefresh", - "Level6Choose", - "Level6Time", - "Level5Choose", - "Level5Time", - "Level4Choose", - "Level4Time", - "Level3Choose", - "Level3Time", - "PreferTagEnabled", - "Level3PreferTags", - "PreserveTagEnabled", - "PreserveTagList", - ]); - Ok(Some(item)) - } -} - -mod mall { - use anyhow::Result; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - - pub(super) fn migrate_mall_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let mut item = object!("type" => "Mall"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut params = MAAValue::default(); - // Shopping -> shopping - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(shopping))) = task.get("Shopping") { - insert!(params, "shopping" => *shopping); - } - // CreditFight -> credit_fight - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = task.get("CreditFight") - { - insert!(params, "credit_fight" => *credit_fight); - } - // CreditFightFormation -> formation_index - if let Some(MAAValue::Primitive(MAAPrimitive::Int(formation_index))) = - task.get("CreditFightFormation") - { - insert!(params, "formation_index" => *formation_index); - } - // VisitFriends -> visit_friends - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(visit_friends))) = - task.get("VisitFriends") - { - insert!(params, "visit_friends" => *visit_friends); - } - // FirstList -> buy_first - if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("FirstList") { - let buy_first: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); - if !buy_first.is_empty() { - insert!(params, "buy_first" => buy_first??); - } - } - // BlackList -> blacklist - if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("BlackList") { - let blacklist: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); - if !blacklist.is_empty() { - insert!(params, "blacklist" => blacklist??); - } - } - // ShoppingIgnoreBlackListWhenFull -> force_shopping_if_credit_full - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(force))) = - task.get("ShoppingIgnoreBlackListWhenFull") - { - insert!(params, "force_shopping_if_credit_full" => *force); - } - // OnlyBuyDiscount -> only_buy_discount - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_discount))) = - task.get("OnlyBuyDiscount") - { - insert!(params, "only_buy_discount" => *only_discount); - } - // ReserveMaxCredit -> reserve_max_credit - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = task.get("ReserveMaxCredit") - { - insert!(params, "reserve_max_credit" => *reserve); - } - - insert!(item, "params" => params); - report_unhandled_fields(summary, task, "MallTask", &[ - "Shopping", - "CreditFight", - "CreditFightFormation", - "VisitFriends", - "FirstList", - "BlackList", - "ShoppingIgnoreBlackListWhenFull", - "OnlyBuyDiscount", - "ReserveMaxCredit", - ]); - Ok(Some(item)) - } -} - -mod award { - use anyhow::Result; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - - pub(super) fn migrate_award_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let mut item = object!("type" => "Award"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut params = MAAValue::default(); - // Award -> award - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(award))) = task.get("Award") { - insert!(params, "award" => *award); - } - // Mail -> mail - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mail))) = task.get("Mail") { - insert!(params, "mail" => *mail); - } - // FreeGacha -> recruit - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(recruit))) = task.get("FreeGacha") { - insert!(params, "recruit" => *recruit); - } - // Orundum -> orundum - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(orundum))) = task.get("Orundum") { - insert!(params, "orundum" => *orundum); - } - // Mining -> mining - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mining))) = task.get("Mining") { - insert!(params, "mining" => *mining); - } - // SpecialAccess -> specialaccess - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(specialaccess))) = - task.get("SpecialAccess") - { - insert!(params, "specialaccess" => *specialaccess); - } - - insert!(item, "params" => params); - report_unhandled_fields(summary, task, "AwardTask", &[ - "Award", - "Mail", - "FreeGacha", - "Orundum", - "Mining", - "SpecialAccess", - ]); - Ok(Some(item)) - } -} - -mod roguelike { - use anyhow::Result; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - - pub(super) fn migrate_roguelike_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let mut item = object!("type" => "Roguelike"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut params = MAAValue::default(); - let mut handled = vec![ - "Theme", - "Mode", - "Squad", - "Roles", - "CoreChar", - "StartCount", - "Difficulty", - "Investment", - "InvestCount", - "InvestWithMoreScore", - "StopWhenDepositFull", - "StopAtFinalBoss", - "StopWhenLevelMax", - "UseSupport", - "UseSupportNonFriend", - "RefreshTraderWithDice", - "StartWithEliteTwo", - "StartWithEliteTwoOnly", - ]; - // Theme -> theme - if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { - insert!(params, "theme" => theme.as_str()); - } - // Mode -> mode - if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { - if let Some(mode) = match mode.as_str() { - "Exp" => Some(0), - "Investment" => Some(1), - "Collect" => Some(4), - "CollapsalParadigms" => Some(5), - "MonthlySquad" => Some(6), - "DeepExploration" => Some(7), - _ => None, - } { - insert!(params, "mode" => mode); - } else { - handled.retain(|field| *field != "Mode"); - } - } - // Squad -> squad - if let Some(MAAValue::Primitive(MAAPrimitive::String(squad))) = task.get("Squad") { - insert!(params, "squad" => squad.as_str()); - } - // Roles -> roles - if let Some(MAAValue::Primitive(MAAPrimitive::String(roles))) = task.get("Roles") { - insert!(params, "roles" => roles.as_str()); - } - // CoreChar -> core_char - if let Some(MAAValue::Primitive(MAAPrimitive::String(core_char))) = task.get("CoreChar") { - insert!(params, "core_char" => core_char.as_str()); - } - // StartCount -> starts_count - if let Some(MAAValue::Primitive(MAAPrimitive::Int(starts_count))) = task.get("StartCount") { - insert!(params, "starts_count" => *starts_count); - } - // Difficulty -> difficulty - if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") - && *difficulty != i32::MAX - { - insert!(params, "difficulty" => *difficulty); - } - // Investment -> investment_enabled - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_enabled))) = - task.get("Investment") - { - insert!(params, "investment_enabled" => *investment_enabled); - } - // InvestCount -> investments_count - if let Some(MAAValue::Primitive(MAAPrimitive::Int(investments_count))) = - task.get("InvestCount") - { - insert!(params, "investments_count" => *investments_count); - } - // InvestWithMoreScore -> investment_with_more_score - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_with_more_score))) = - task.get("InvestWithMoreScore") - { - insert!(params, "investment_with_more_score" => *investment_with_more_score); - } - // StopWhenDepositFull -> stop_when_investment_full - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_when_investment_full))) = - task.get("StopWhenDepositFull") - { - insert!(params, "stop_when_investment_full" => *stop_when_investment_full); - } - // StopAtFinalBoss -> stop_at_final_boss - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_final_boss))) = - task.get("StopAtFinalBoss") - { - insert!(params, "stop_at_final_boss" => *stop_at_final_boss); - } - // StopWhenLevelMax -> stop_at_max_level - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_max_level))) = - task.get("StopWhenLevelMax") - { - insert!(params, "stop_at_max_level" => *stop_at_max_level); - } - // UseSupport -> use_support - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = task.get("UseSupport") { - insert!(params, "use_support" => *use_support); - } - // UseSupportNonFriend -> use_nonfriend_support - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_nonfriend_support))) = - task.get("UseSupportNonFriend") - { - insert!(params, "use_nonfriend_support" => *use_nonfriend_support); - } - // RefreshTraderWithDice -> refresh_trader_with_dice - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh_trader_with_dice))) = - task.get("RefreshTraderWithDice") - { - insert!(params, "refresh_trader_with_dice" => *refresh_trader_with_dice); - } - // StartWithEliteTwo -> start_with_elite_two - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_with_elite_two))) = - task.get("StartWithEliteTwo") - { - insert!(params, "start_with_elite_two" => *start_with_elite_two); - } - // StartWithEliteTwoOnly -> only_start_with_elite_two - if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_start_with_elite_two))) = - task.get("StartWithEliteTwoOnly") - { - insert!(params, "only_start_with_elite_two" => *only_start_with_elite_two); - } - - insert!(item, "params" => params); - report_unhandled_fields(summary, task, "RoguelikeTask", &handled); - Ok(Some(item)) - } -} - -mod reclamation { - use anyhow::Result; - use maa_value::prelude::*; - - use super::{MigrationSummary, report_unhandled_fields}; - - pub(super) fn migrate_reclamation_task( - task: &MAAValue, - summary: &mut MigrationSummary, - ) -> Result> { - let mut item = object!("type" => "Reclamation"); - // -> task name - if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { - insert!(item, "name" => name.as_str()); - } - - let mut params = MAAValue::default(); - let mut handled = vec![ - "Theme", - "Mode", - "ToolToCraft", - "IncrementMode", - "MaxCraftCountPerRound", - ]; - // Theme -> theme - if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { - insert!(params, "theme" => theme.as_str()); - } - // Mode -> mode - if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { - if let Some(mode) = match mode.as_str() { - "ProsperityNoSave" => Some(0), - "ProsperityInSave" => Some(1), - _ => None, - } { - insert!(params, "mode" => mode); - } else { - handled.retain(|field| *field != "Mode"); - } - } - // ToolToCraft -> tools_to_craft - if let Some(MAAValue::Primitive(MAAPrimitive::String(tool))) = task.get("ToolToCraft") - && !tool.is_empty() - { - insert!(params, "tools_to_craft" => vec![tool.as_str()]??); - } - // IncrementMode -> increment_mode - if let Some(MAAValue::Primitive(MAAPrimitive::Int(increment_mode))) = - task.get("IncrementMode") - { - insert!(params, "increment_mode" => *increment_mode); - } - // MaxCraftCountPerRound -> num_craft_batches - if let Some(MAAValue::Primitive(MAAPrimitive::Int(num_craft_batches))) = - task.get("MaxCraftCountPerRound") - { - insert!(params, "num_craft_batches" => *num_craft_batches); - } - - insert!(item, "params" => params); - report_unhandled_fields(summary, task, "ReclamationTask", &handled); - Ok(Some(item)) - } -} +// mod infrast { +// use anyhow::{Result, bail}; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; + +// pub(super) fn migrate_infrast_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// // Custom infrastructure plans are not migrated yet. +// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") +// && mode == "Custom" +// { +// bail!("InfrastTask custom mode is not supported yet"); +// } +// if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") +// && !filename.is_empty() +// { +// bail!("InfrastTask custom plan (Filename) is not supported yet"); +// } + +// let mut item = object!("type" => "Infrast"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let mut params = MAAValue::default(); +// let mut handled = vec![ +// "Mode", +// "RoomList", +// "UsesOfDrones", +// "DormThreshold", +// "OriginiumShardAutoReplenishment", +// "DormFilterNotStationed", +// "DormTrustEnabled", +// "ReceptionMessageBoard", +// "ReceptionClueExchange", +// "SendClue", +// // Custom plan fields: rejected above when set; keep handled so defaults are quiet. +// "Filename", +// "PlanSelect", +// ]; +// // Mode -> mode (Custom is rejected above) +// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { +// if let Some(mode) = match mode.as_str() { +// "Normal" => Some(0), +// "Rotation" => Some(20000), +// _ => None, +// } { +// insert!(params, "mode" => mode); +// } else { +// handled.retain(|field| *field != "Mode"); +// } +// } +// // RoomList -> facility +// if let Some(MAAValue::Array(rooms)) = task.get("RoomList") { +// let mut facility = Vec::new(); +// for room in rooms { +// if let MAAValue::Object(map) = room +// && let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") +// { +// facility.push(room.as_str()); +// } +// } +// if !facility.is_empty() { +// insert!(params, "facility" => facility??); +// } +// } +// // UsesOfDrones -> drones +// if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") { +// insert!(params, "drones" => drones.as_str()); +// } +// // DormThreshold -> threshold +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = task.get("DormThreshold") { +// insert!(params, "threshold" => *threshold as f32 / 100.0); +// } +// // OriginiumShardAutoReplenishment -> replenish +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(replenish))) = +// task.get("OriginiumShardAutoReplenishment") +// { +// insert!(params, "replenish" => *replenish); +// } +// // DormFilterNotStationed -> dorm_notstationed_enabled +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = +// task.get("DormFilterNotStationed") +// { +// insert!(params, "dorm_notstationed_enabled" => *enabled); +// } +// // DormTrustEnabled -> dorm_trust_enabled +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("DormTrustEnabled") +// { +// insert!(params, "dorm_trust_enabled" => *enabled); +// } +// // ReceptionMessageBoard -> reception_message_board +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = +// task.get("ReceptionMessageBoard") +// { +// insert!(params, "reception_message_board" => *enabled); +// } +// // ReceptionClueExchange -> reception_clue_exchange +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = +// task.get("ReceptionClueExchange") +// { +// insert!(params, "reception_clue_exchange" => *enabled); +// } +// // SendClue -> reception_send_clue +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("SendClue") { +// insert!(params, "reception_send_clue" => *enabled); +// } + +// insert!(item, "params" => params); +// report_unhandled_fields(summary, task, "InfrastTask", &handled); +// Ok(Some(item)) +// } +// } + +// mod recruit { +// use anyhow::Result; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; + +// pub(super) fn migrate_recruit_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let mut item = object!("type" => "Recruit"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let mut params = MAAValue::default(); +// // MaxTimes -> times +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("MaxTimes") { +// insert!(params, "times" => *times); +// } +// // ExtraTagMode -> extra_tags_mode +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(mode))) = task.get("ExtraTagMode") { +// insert!(params, "extra_tags_mode" => *mode); +// } +// // RefreshLevel3 -> refresh +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") { +// insert!(params, "refresh" => *refresh); +// } +// // ForceRefresh -> expedite +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") { +// insert!(params, "expedite" => *expedite); +// } +// // LevelXChoose -> select / confirm +// let mut select = Vec::new(); +// let mut confirm = Vec::new(); +// let mut recruitment_time = MAAValue::default(); +// const LEVELS: [(&str, &str, i32); 4] = [ +// ("Level6Choose", "Level6Time", 6), +// ("Level5Choose", "Level5Time", 5), +// ("Level4Choose", "Level4Time", 4), +// ("Level3Choose", "Level3Time", 3), +// ]; +// for (choose_key, time_key, level) in LEVELS { +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get(choose_key) { +// select.push(level); +// confirm.push(level); +// } +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(minutes))) = task.get(time_key) { +// recruitment_time.insert(level.to_string(), (*minutes).into()); +// } +// } +// if !select.is_empty() { +// insert!(params, "select" => select??); +// insert!(params, "confirm" => confirm??); +// } +// if recruitment_time.as_map().is_some_and(|map| !map.is_empty()) { +// insert!(params, "recruitment_time" => recruitment_time); +// } +// // PreferTagEnabled + Level3PreferTags -> first_tags +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreferTagEnabled") +// && let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") +// { +// let mut first_tags = Vec::new(); +// for tag in tags { +// if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { +// first_tags.push(tag.as_str()); +// } +// } +// if !first_tags.is_empty() { +// insert!(params, "first_tags" => first_tags??); +// } +// } +// // PreserveTagEnabled + PreserveTagList -> preserve_tags +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreserveTagEnabled") +// && let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") +// { +// let mut preserve_tags = Vec::new(); +// for tag in tags { +// if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { +// preserve_tags.push(tag.as_str()); +// } +// } +// if !preserve_tags.is_empty() { +// insert!(params, "preserve_tags" => preserve_tags??); +// } +// } + +// insert!(item, "params" => params); +// report_unhandled_fields(summary, task, "RecruitTask", &[ +// "MaxTimes", +// "ExtraTagMode", +// "RefreshLevel3", +// "ForceRefresh", +// "Level6Choose", +// "Level6Time", +// "Level5Choose", +// "Level5Time", +// "Level4Choose", +// "Level4Time", +// "Level3Choose", +// "Level3Time", +// "PreferTagEnabled", +// "Level3PreferTags", +// "PreserveTagEnabled", +// "PreserveTagList", +// ]); +// Ok(Some(item)) +// } +// } + +// mod mall { +// use anyhow::Result; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; + +// pub(super) fn migrate_mall_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let mut item = object!("type" => "Mall"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let mut params = MAAValue::default(); +// // Shopping -> shopping +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(shopping))) = task.get("Shopping") { +// insert!(params, "shopping" => *shopping); +// } +// // CreditFight -> credit_fight +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = task.get("CreditFight") +// { +// insert!(params, "credit_fight" => *credit_fight); +// } +// // CreditFightFormation -> formation_index +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(formation_index))) = +// task.get("CreditFightFormation") +// { +// insert!(params, "formation_index" => *formation_index); +// } +// // VisitFriends -> visit_friends +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(visit_friends))) = +// task.get("VisitFriends") +// { +// insert!(params, "visit_friends" => *visit_friends); +// } +// // FirstList -> buy_first +// if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("FirstList") { +// let buy_first: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); +// if !buy_first.is_empty() { +// insert!(params, "buy_first" => buy_first??); +// } +// } +// // BlackList -> blacklist +// if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("BlackList") { +// let blacklist: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); +// if !blacklist.is_empty() { +// insert!(params, "blacklist" => blacklist??); +// } +// } +// // ShoppingIgnoreBlackListWhenFull -> force_shopping_if_credit_full +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(force))) = +// task.get("ShoppingIgnoreBlackListWhenFull") +// { +// insert!(params, "force_shopping_if_credit_full" => *force); +// } +// // OnlyBuyDiscount -> only_buy_discount +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_discount))) = +// task.get("OnlyBuyDiscount") +// { +// insert!(params, "only_buy_discount" => *only_discount); +// } +// // ReserveMaxCredit -> reserve_max_credit +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = task.get("ReserveMaxCredit") +// { +// insert!(params, "reserve_max_credit" => *reserve); +// } + +// insert!(item, "params" => params); +// report_unhandled_fields(summary, task, "MallTask", &[ +// "Shopping", +// "CreditFight", +// "CreditFightFormation", +// "VisitFriends", +// "FirstList", +// "BlackList", +// "ShoppingIgnoreBlackListWhenFull", +// "OnlyBuyDiscount", +// "ReserveMaxCredit", +// ]); +// Ok(Some(item)) +// } +// } + +// mod award { +// use anyhow::Result; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; + +// pub(super) fn migrate_award_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let mut item = object!("type" => "Award"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let mut params = MAAValue::default(); +// // Award -> award +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(award))) = task.get("Award") { +// insert!(params, "award" => *award); +// } +// // Mail -> mail +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mail))) = task.get("Mail") { +// insert!(params, "mail" => *mail); +// } +// // FreeGacha -> recruit +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(recruit))) = task.get("FreeGacha") { +// insert!(params, "recruit" => *recruit); +// } +// // Orundum -> orundum +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(orundum))) = task.get("Orundum") { +// insert!(params, "orundum" => *orundum); +// } +// // Mining -> mining +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mining))) = task.get("Mining") { +// insert!(params, "mining" => *mining); +// } +// // SpecialAccess -> specialaccess +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(specialaccess))) = +// task.get("SpecialAccess") +// { +// insert!(params, "specialaccess" => *specialaccess); +// } + +// insert!(item, "params" => params); +// report_unhandled_fields(summary, task, "AwardTask", &[ +// "Award", +// "Mail", +// "FreeGacha", +// "Orundum", +// "Mining", +// "SpecialAccess", +// ]); +// Ok(Some(item)) +// } +// } + +// mod roguelike { +// use anyhow::Result; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; + +// pub(super) fn migrate_roguelike_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let mut item = object!("type" => "Roguelike"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let mut params = MAAValue::default(); +// let mut handled = vec![ +// "Theme", +// "Mode", +// "Squad", +// "Roles", +// "CoreChar", +// "StartCount", +// "Difficulty", +// "Investment", +// "InvestCount", +// "InvestWithMoreScore", +// "StopWhenDepositFull", +// "StopAtFinalBoss", +// "StopWhenLevelMax", +// "UseSupport", +// "UseSupportNonFriend", +// "RefreshTraderWithDice", +// "StartWithEliteTwo", +// "StartWithEliteTwoOnly", +// ]; +// // Theme -> theme +// if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { +// insert!(params, "theme" => theme.as_str()); +// } +// // Mode -> mode +// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { +// if let Some(mode) = match mode.as_str() { +// "Exp" => Some(0), +// "Investment" => Some(1), +// "Collect" => Some(4), +// "CollapsalParadigms" => Some(5), +// "MonthlySquad" => Some(6), +// "DeepExploration" => Some(7), +// _ => None, +// } { +// insert!(params, "mode" => mode); +// } else { +// handled.retain(|field| *field != "Mode"); +// } +// } +// // Squad -> squad +// if let Some(MAAValue::Primitive(MAAPrimitive::String(squad))) = task.get("Squad") { +// insert!(params, "squad" => squad.as_str()); +// } +// // Roles -> roles +// if let Some(MAAValue::Primitive(MAAPrimitive::String(roles))) = task.get("Roles") { +// insert!(params, "roles" => roles.as_str()); +// } +// // CoreChar -> core_char +// if let Some(MAAValue::Primitive(MAAPrimitive::String(core_char))) = task.get("CoreChar") { +// insert!(params, "core_char" => core_char.as_str()); +// } +// // StartCount -> starts_count +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(starts_count))) = task.get("StartCount") { +// insert!(params, "starts_count" => *starts_count); +// } +// // Difficulty -> difficulty +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") +// && *difficulty != i32::MAX +// { +// insert!(params, "difficulty" => *difficulty); +// } +// // Investment -> investment_enabled +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_enabled))) = +// task.get("Investment") +// { +// insert!(params, "investment_enabled" => *investment_enabled); +// } +// // InvestCount -> investments_count +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(investments_count))) = +// task.get("InvestCount") +// { +// insert!(params, "investments_count" => *investments_count); +// } +// // InvestWithMoreScore -> investment_with_more_score +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_with_more_score))) = +// task.get("InvestWithMoreScore") +// { +// insert!(params, "investment_with_more_score" => *investment_with_more_score); +// } +// // StopWhenDepositFull -> stop_when_investment_full +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_when_investment_full))) = +// task.get("StopWhenDepositFull") +// { +// insert!(params, "stop_when_investment_full" => *stop_when_investment_full); +// } +// // StopAtFinalBoss -> stop_at_final_boss +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_final_boss))) = +// task.get("StopAtFinalBoss") +// { +// insert!(params, "stop_at_final_boss" => *stop_at_final_boss); +// } +// // StopWhenLevelMax -> stop_at_max_level +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_max_level))) = +// task.get("StopWhenLevelMax") +// { +// insert!(params, "stop_at_max_level" => *stop_at_max_level); +// } +// // UseSupport -> use_support +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = task.get("UseSupport") { +// insert!(params, "use_support" => *use_support); +// } +// // UseSupportNonFriend -> use_nonfriend_support +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_nonfriend_support))) = +// task.get("UseSupportNonFriend") +// { +// insert!(params, "use_nonfriend_support" => *use_nonfriend_support); +// } +// // RefreshTraderWithDice -> refresh_trader_with_dice +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh_trader_with_dice))) = +// task.get("RefreshTraderWithDice") +// { +// insert!(params, "refresh_trader_with_dice" => *refresh_trader_with_dice); +// } +// // StartWithEliteTwo -> start_with_elite_two +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_with_elite_two))) = +// task.get("StartWithEliteTwo") +// { +// insert!(params, "start_with_elite_two" => *start_with_elite_two); +// } +// // StartWithEliteTwoOnly -> only_start_with_elite_two +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_start_with_elite_two))) = +// task.get("StartWithEliteTwoOnly") +// { +// insert!(params, "only_start_with_elite_two" => *only_start_with_elite_two); +// } + +// insert!(item, "params" => params); +// report_unhandled_fields(summary, task, "RoguelikeTask", &handled); +// Ok(Some(item)) +// } +// } + +// mod reclamation { +// use anyhow::Result; +// use maa_value::prelude::*; + +// use super::{MigrationSummary, report_unhandled_fields}; + +// pub(super) fn migrate_reclamation_task( +// task: &MAAValue, +// summary: &mut MigrationSummary, +// ) -> Result> { +// let mut item = object!("type" => "Reclamation"); +// // -> task name +// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { +// insert!(item, "name" => name.as_str()); +// } + +// let mut params = MAAValue::default(); +// let mut handled = vec![ +// "Theme", +// "Mode", +// "ToolToCraft", +// "IncrementMode", +// "MaxCraftCountPerRound", +// ]; +// // Theme -> theme +// if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { +// insert!(params, "theme" => theme.as_str()); +// } +// // Mode -> mode +// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { +// if let Some(mode) = match mode.as_str() { +// "ProsperityNoSave" => Some(0), +// "ProsperityInSave" => Some(1), +// _ => None, +// } { +// insert!(params, "mode" => mode); +// } else { +// handled.retain(|field| *field != "Mode"); +// } +// } +// // ToolToCraft -> tools_to_craft +// if let Some(MAAValue::Primitive(MAAPrimitive::String(tool))) = task.get("ToolToCraft") +// && !tool.is_empty() +// { +// insert!(params, "tools_to_craft" => vec![tool.as_str()]??); +// } +// // IncrementMode -> increment_mode +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(increment_mode))) = +// task.get("IncrementMode") +// { +// insert!(params, "increment_mode" => *increment_mode); +// } +// // MaxCraftCountPerRound -> num_craft_batches +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(num_craft_batches))) = +// task.get("MaxCraftCountPerRound") +// { +// insert!(params, "num_craft_batches" => *num_craft_batches); +// } + +// insert!(item, "params" => params); +// report_unhandled_fields(summary, task, "ReclamationTask", &handled); +// Ok(Some(item)) +// } +// } From dd2433d1821ee58d9889bef30eac3bc2e3495583 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 7 Aug 2026 02:01:56 +0800 Subject: [PATCH 14/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20migrate?= =?UTF-8?q?=20WPF=20Fight=20via=20typed=20Serialize=20prototypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build CLI Fight output from FightCliTask/FightParams and From/TryFrom instead of MAAValue insert trees. Serialize Condition/TimeOffset/ClientType so weekly and stage open conditions stay typed. Remove the legacy MAAValue Fight path and temporary parity tests until a unified suite lands. 将 WPF Fight 迁移改为原型结构体序列化,并移除旧路径与临时测试 --- .../maa-cli/config_examples/wpf/gui.new.toml | 22 +- crates/maa-cli/docs/zh-CN/migrate.md | 3 +- crates/maa-cli/src/command.rs | 2 +- crates/maa-cli/src/config/migrate/mod.rs | 11 +- crates/maa-cli/src/config/migrate/wpf.rs | 897 +++++++++--------- crates/maa-cli/src/config/task/client_type.rs | 12 +- crates/maa-cli/src/config/task/condition.rs | 39 +- crates/maa-cli/src/config/task/mod.rs | 3 +- 8 files changed, 500 insertions(+), 489 deletions(-) diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml index 262bc037..91183cc0 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.toml +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -130,15 +130,8 @@ specialaccess = true type = "Roguelike" name = "" -[[tasks.variants]] - -[tasks.variants.condition] -type = "Not" - -[tasks.variants.condition.condition] -type = "Always" - -[tasks.variants.params] +[tasks.params] +enable = false theme = "JieGarden" mode = 1 squad = "指挥分队" @@ -161,15 +154,8 @@ only_start_with_elite_two = false type = "Reclamation" name = "" -[[tasks.variants]] - -[tasks.variants.condition] -type = "Not" - -[tasks.variants.condition.condition] -type = "Always" - -[tasks.variants.params] +[tasks.params] +enable = false theme = "Tales" mode = 1 increment_mode = 0 diff --git a/crates/maa-cli/docs/zh-CN/migrate.md b/crates/maa-cli/docs/zh-CN/migrate.md index 3e46ff5b..8f095c7f 100644 --- a/crates/maa-cli/docs/zh-CN/migrate.md +++ b/crates/maa-cli/docs/zh-CN/migrate.md @@ -27,7 +27,7 @@ maa migrate wpf [output] [-f toml|yaml|json] [--config ] 如果文件中有多套配置,可用 `--config` 或环境变量 `MAA_GUI_CONFIG` 指定名称;未指定时会交互选择,默认使用文件中的当前配置(`Current`)。 -迁移可能不完整:不支持的任务类型会被跳过;GUI 中已关闭的任务会保留在结果里,但默认不会执行;未能对应的参数会出现在迁移摘要中。命令结束后会输出这些汇总,方便你对照检查。 +迁移可能不完整:不支持的任务类型会被跳过;GUI 中已关闭的任务(`IsEnable = false`)会保留,并写入 `params.enable = false`;未能对应的参数会出现在迁移摘要中。命令结束后会输出这些汇总,方便你对照检查。 ### StartUp @@ -49,6 +49,7 @@ TODO:`account_name` 的转换**我没有测试过**(未做实机账号切换 | GUI 字段 | maa-cli 字段 | 规则 | | --- | --- | --- | | `Name` | `name` | 有值时写入 | +| `IsEnable` | `params.enable` | 为 `false` 时写入 `enable = false`(MaaCore 通用开关;默认不写,等同于 `true`) | | `UseMedicine` + `MedicineCount` | `params.medicine` | 仅当 `UseMedicine` 为 `true` 时写入 `MedicineCount` | | `UseStone` + `StoneCount` | `params.stone` | 仅当 `UseStone` 为 `true` 时写入 `StoneCount`;迁移时会警告该设置可能消耗源石 | | `EnableTimesLimit` + `TimesLimit` | `params.times` | 仅当 `EnableTimesLimit` 为 `true` 时写入 `TimesLimit` | diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index bf07a4a9..a7aa8f63 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -262,7 +262,7 @@ pub(crate) enum MigrateCommand { /// /// The input is typically a GUI-exported JSON profile. When multiple /// configurations exist, use `--config` or `MAA_GUI_CONFIG` to select one. - /// Disabled GUI tasks are kept inactive (never-true condition) so they will + /// Disabled GUI tasks are kept with `params.enable = false` so they will /// not run until re-enabled manually. Wpf { /// Path of the input GUI profile diff --git a/crates/maa-cli/src/config/migrate/mod.rs b/crates/maa-cli/src/config/migrate/mod.rs index e163192c..1924da6b 100644 --- a/crates/maa-cli/src/config/migrate/mod.rs +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -13,7 +13,7 @@ pub(crate) use wpf::wpf; pub struct MigrationSummary { /// Unsupported task types that were not written. pub skipped_tasks: Vec, - /// Tasks with `IsEnable = false`, kept inactive in the output. + /// Tasks with `IsEnable = false`, written with `params.enable = false`. pub disabled_tasks: Vec, /// Fields present on a supported task that were not mapped. pub skipped_fields: Vec, @@ -39,21 +39,22 @@ impl MigrationSummary { && self.skipped_fields.is_empty() } - fn skip_task(&mut self, type_tag: impl Into, name: Option) { + pub(super) fn skip_task(&mut self, type_tag: impl Into, name: Option) { self.skipped_tasks.push(SkippedTask { type_tag: type_tag.into(), name, }); } - fn disable_task(&mut self, type_tag: impl Into, name: Option) { + pub(super) fn disable_task(&mut self, type_tag: impl Into, name: Option) { self.disabled_tasks.push(SkippedTask { type_tag: type_tag.into(), name, }); } - fn skip_field( + #[expect(dead_code, reason = "used when reporting unmapped GUI fields")] + pub(super) fn skip_field( &mut self, task_type: impl Into, task_name: Option, @@ -82,7 +83,7 @@ impl MigrationSummary { } if !self.disabled_tasks.is_empty() { - eprintln!(" Disabled tasks (kept inactive with never-true condition; will not run):"); + eprintln!(" Disabled tasks (kept with params.enable=false; will not run):"); for task in &self.disabled_tasks { eprintln!(" - {}", format_task_ref(task)); } diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index ad0a36c0..20604202 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -3,18 +3,15 @@ use std::{io::BufReader, num::NonZero, path::Path}; use anyhow::{Context, Result, bail, ensure}; use log::trace; use maa_value::{ - map::StringMap, prelude::*, userinput::{SelectD, UserInput}, }; +use serde::{Deserialize, Serialize}; use serde_json::Value; use super::MigrationSummary; use crate::config::Filetype; -/// Meta / structural keys that are never reported as skipped fields. -const META_FIELDS: &[&str] = &["$type", "TaskType", "Name", "IsEnable"]; - /// Migrate a MAA WPF GUI profile into a maa-cli task config. /// /// ```text @@ -41,8 +38,8 @@ pub(crate) fn wpf(file: &Path, out: Option<&Path>, config: Option) -> Re use maa_dirs::Ensure; dir.ensure()?; } - ft.write(&out, &value) - .with_context(|| format!("Failed to write migrated file {}", out.display())); + ft.write(out, &value) + .with_context(|| format!("Failed to write migrated file {}", out.display()))?; summary.print(); Ok(()) @@ -55,7 +52,11 @@ pub(crate) fn wpf(file: &Path, out: Option<&Path>, config: Option) -> Re /// When multiple configurations exist, `config_name` can be /// used to select one without an interactive prompt. pub(super) fn select_configuration(mut input: Value, config_name: Option) -> Result { - let current = input.get("Current").and_then(|v| v.as_str()).map(str::to_string).unwrap_or_default(); + let current = input + .get("Current") + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_default(); let Some(configurations) = input.get_mut("Configurations") else { bail!("GUI profile missing Configurations"); }; @@ -69,7 +70,7 @@ pub(super) fn select_configuration(mut input: Value, config_name: Option match object.len() { 0 => bail!("GUI profile has no configuration"), 1 => object.keys().next().unwrap().to_string(), - _ => resolve_configuration_name(object, current)? + _ => resolve_configuration_name(object, current)?, } } }; @@ -97,151 +98,75 @@ fn resolve_configuration_name( .context("Failed to select GUI configuration") } -/// Migrate a GUI profile `MAAValue` into maa-cli task config shape. -pub(super) fn migrate( - input: Value, -) -> Result<(MAAValue, MigrationSummary)> { - let summary = MigrationSummary::default(); - - unimplemented!() +/// Migrate a GUI profile into maa-cli task config shape. +pub(super) fn migrate(input: Value) -> Result<(MAAValue, MigrationSummary)> { + let mut summary = MigrationSummary::default(); + let config: Configuration = + serde_json::from_value(input).context("Failed to deserialize GUI configuration")?; + let value = config + .migrate_tasks(&mut summary)? + .context("GUI configuration produced no CLI config")?; + Ok((value, summary)) +} + +/// Serialize a typed CLI config prototype into [`MAAValue`]. +fn serialize_to_maa_value(value: &T) -> Result { + let json = serde_json::to_value(value).context("Failed to serialize CLI config prototype")?; + serde_json::from_value(json).context("Failed to convert CLI config prototype to MAAValue") +} + +#[derive(Debug, Serialize)] +struct CliConfig { + tasks: Vec, } +#[derive(Debug, Deserialize)] struct Configuration { + #[serde(rename = "TaskQueue")] task_queue: Vec, + #[serde(rename = "Gui")] + #[allow(dead_code)] gui: GuiSettings, } -enum WpfTask { - StartUpTask(start_up::StartUpTask), - // FightTask(FightTask), - // InfrastTask(InfrastTask), - // RecruitTask(RecruitTask), - // MallTask(MallTask), - // AwardTask(AwardTask), - // RoguelikeTask(RoguelikeTask), - // ReclamationTask(ReclamationTask), - Unsupported { - type_tag: String, - raw: serde_json::Value, - }, +impl Configuration { + fn migrate_tasks(&self, summary: &mut MigrationSummary) -> Result> { + let mut tasks = Vec::new(); + for task in &self.task_queue { + if let Some(item) = task.migrate_task(summary)? { + tasks.push(item); + } + } + Ok(Some(serialize_to_maa_value(&CliConfig { tasks })?)) + } } -struct GuiSettings {} - -// fn migrate_task( -// task: &MAAValue, -// config: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let type_tag = task -// .get("$type") -// .and_then(|v| v.as_str()) -// .context("GUI task missing $type")?; -// let name = task_name(task); -// let disabled = matches!( -// task.get("IsEnable"), -// Some(MAAValue::Primitive(MAAPrimitive::Bool(false))) -// ); - -// let mut item = match type_tag { -// "StartUpTask" => start_up::migrate_start_up_task(task, config, summary)?, -// "FightTask" => fight::migrate_fight_task(task, summary)?, -// "InfrastTask" => infrast::migrate_infrast_task(task, summary)?, -// "RecruitTask" => recruit::migrate_recruit_task(task, summary)?, -// "MallTask" => mall::migrate_mall_task(task, summary)?, -// "AwardTask" => award::migrate_award_task(task, summary)?, -// "RoguelikeTask" => roguelike::migrate_roguelike_task(task, summary)?, -// "ReclamationTask" => reclamation::migrate_reclamation_task(task, summary)?, -// type_tag => { -// summary.skip_task(type_tag, name); -// return Ok(None); -// } -// }; - -// if disabled && let Some(item) = item.as_mut() { -// apply_disabled_condition(item); -// summary.disable_task(type_tag, name); -// } - -// Ok(item) -// } - -// fn task_name(task: &MAAValue) -> Option { -// task.get("Name") -// .and_then(|v| v.as_str()) -// .filter(|name| !name.is_empty()) -// .map(str::to_string) -// } - -// /// Keep a disabled GUI task in the output, but ensure it never becomes active. -// fn apply_disabled_condition(item: &mut MAAValue) { -// let never = never_condition(); - -// if let Some(MAAValue::Array(variants)) = item.get_mut("variants") { -// for variant in variants.iter_mut() { -// let new_cond = match variant.get("condition") { -// Some(cond) => object!( -// "type" => "And", -// "conditions" => vec![cond.clone(), never.clone()]?? -// ), -// None => never.clone(), -// }; -// variant.insert("condition", new_cond); -// } -// return; -// } - -// let params = item.get("params").cloned().unwrap_or_default(); -// if let Some(map) = item.as_mut_map() { -// map.shift_remove("params"); -// } -// item.insert( -// "variants", -// MAAValue::Array(vec![object!( -// "condition" => never, -// "params" => params -// )]), -// ); -// } - -// fn never_condition() -> MAAValue { -// object!( -// "type" => "Not", -// "condition" => object!("type" => "Always") -// ) -// } +#[derive(Debug, Deserialize)] +#[serde(tag = "$type")] +enum WpfTask { + FightTask(fight::FightTask), + /// Unknown `$type` values are skipped during migration. + #[serde(other)] + Unsupported, +} -// fn report_unhandled_fields( -// summary: &mut MigrationSummary, -// task: &MAAValue, -// task_type: &str, -// handled: &[&str], -// ) { -// let name = task_name(task); -// let Some(map) = task.as_map() else { -// return; -// }; -// for (key, value) in map { -// if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { -// continue; -// } -// if is_meaningful_unhandled(value) { -// summary.skip_field(task_type, name.clone(), key.clone()); -// } -// } -// } +impl WpfTask { + fn migrate_task(&self, summary: &mut MigrationSummary) -> Result> { + match self { + Self::FightTask(fight) => { + fight.report_disabled(summary); + Ok(Some(MAAValue::try_from(fight)?)) + } + Self::Unsupported => { + summary.skip_task("Unsupported", None); + Ok(None) + } + } + } +} -// /// Whether an unhandled GUI field likely affects runtime behavior if dropped. -// fn is_meaningful_unhandled(value: &MAAValue) -> bool { -// match value { -// MAAValue::Primitive(MAAPrimitive::Bool(v)) => *v, -// MAAValue::Primitive(MAAPrimitive::String(s)) => !s.is_empty(), -// MAAValue::Primitive(MAAPrimitive::Int(i)) => *i != 0 && *i != i32::MAX, -// MAAValue::Primitive(MAAPrimitive::Float(f)) => *f != 0.0, -// MAAValue::Array(items) => !items.is_empty(), -// MAAValue::Object(map) => !map.is_empty(), -// } -// } +#[derive(Debug, Default, Deserialize)] +struct GuiSettings {} mod start_up { // use anyhow::Result; @@ -255,6 +180,7 @@ mod start_up { // "Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR", // ]; + #[allow(dead_code)] pub(super) struct StartUpTask {} // pub(super) fn migrate_start_up_task( @@ -278,8 +204,8 @@ mod start_up { // } // None => { // insert!(params, "client_type" => object!( - // "alternatives" => VALID_CLIENT_TYPES.iter().map(|s| s.to_string()).collect::>()??, - // )); + // "alternatives" => VALID_CLIENT_TYPES.iter().map(|s| + // s.to_string()).collect::>()??, )); // } // } @@ -315,299 +241,376 @@ mod start_up { } mod fight { -// use anyhow::{Result, bail}; -// use log::warn; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; -// use crate::config::task::ClientType; - -// pub(super) fn migrate_fight_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let mut item = object!("type" => "Fight"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let shared_params = shared_fight_params(task); - -// let use_weekly = flag(task, "UseWeeklySchedule"); -// let use_optional = flag(task, "UseOptionalStage"); - -// match (use_weekly, use_optional) { -// // No variants: StagePlan must be a single stage. -// (false, false) => { -// let stage = stage_plan_single(task)?; -// let mut params = shared_params; -// insert!(params, "stage" => stage); -// insert!(item, "params" => params); -// } -// // One variant gated by weekly schedule; StagePlan must be a single stage. -// (true, false) => { -// let stage = stage_plan_single(task)?; -// let weekly = weekly_schedule_condition(task)?; -// let mut params = shared_params; -// insert!(params, "stage" => stage); -// insert!( -// item, -// "variants" => vec![object!( -// "condition" => weekly, -// "params" => params -// )]?? -// ); -// } -// // One variant per optional stage, each with its own open-condition. -// (false, true) => { -// let stages = stage_plan_array(task)?; -// let mut variants = Vec::with_capacity(stages.len()); -// for stage in stages { -// let mut params = shared_params.clone(); -// insert!(params, "stage" => stage); -// variants.push(object!( -// "condition" => stage_condition(stage), -// "params" => params -// )); -// } -// insert!(item, "variants" => variants??); -// } -// // Weekly schedule AND each stage's open-condition. -// (true, true) => { -// let stages = stage_plan_array(task)?; -// let weekly = weekly_schedule_condition(task)?; -// let mut variants = Vec::with_capacity(stages.len()); -// for stage in stages { -// let mut params = shared_params.clone(); -// insert!(params, "stage" => stage); -// variants.push(object!( -// "condition" => object!( -// "type" => "And", -// "conditions" => vec![ -// weekly.clone(), -// stage_condition(stage) -// ]?? -// ), -// "params" => params -// )); -// } -// insert!(item, "variants" => variants??); -// } -// } - -// report_unhandled_fields(summary, task, "FightTask", &[ -// "UseWeeklySchedule", -// "WeeklySchedule", -// "UseOptionalStage", -// "StagePlan", -// "UseMedicine", -// "MedicineCount", -// "UseStone", -// "StoneCount", -// "EnableTimesLimit", -// "TimesLimit", -// "EnableTargetDrop", -// "DropId", -// "DropCount", -// "Series", -// "UseExpiringMedicine", -// "MedicineExpireDays", -// ]); - -// Ok(Some(item)) -// } - -// fn flag(task: &MAAValue, key: &str) -> bool { -// matches!( -// task.get(key), -// Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) -// ) -// } + use anyhow::{Context, Result, bail}; + use chrono::Weekday; + use log::warn; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{MigrationSummary, serialize_to_maa_value}; + use crate::config::task::{ClientType, Condition, TimeOffset}; + + /// maa-cli Fight task shape written by migration. + #[derive(Debug, Serialize)] + struct FightCliTask { + #[serde(rename = "type")] + task_type: TaskType, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + variants: Vec, + } -// /// Shared Fight params that do not depend on stage / condition branching. -// fn shared_fight_params(task: &MAAValue) -> MAAValue { -// let mut params = MAAValue::default(); + #[derive(Debug, Serialize)] + struct FightVariant { + condition: Condition, + params: FightParams, + } -// // UseMedicine + MedicineCount -> medicine -// if flag(task, "UseMedicine") -// && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("MedicineCount") -// { -// insert!(params, "medicine" => *count); -// } + /// Fight params; field order matches historical `object!` / `insert!` output for parity. + #[derive(Clone, Debug, Default, Serialize)] + struct FightParams { + #[serde(skip_serializing_if = "Option::is_none")] + medicine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + times: Option, + #[serde(skip_serializing_if = "Option::is_none")] + drops: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + series: Option, + #[serde(skip_serializing_if = "Option::is_none")] + medicine_expire_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } -// // UseStone + StoneCount -> stone -// if flag(task, "UseStone") -// && let Some(MAAValue::Primitive(MAAPrimitive::Int(count))) = task.get("StoneCount") -// { -// warn!( -// "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" -// ); -// insert!(params, "stone" => *count); -// } + /// WPF GUI `FightTask` (`$type = "FightTask"`). + /// + /// Deserialized from the GUI task queue; converts into [`FightCliTask`] / + /// [`MAAValue`] via [`TryFrom`]. Unmapped keys are collected in `unknown`. + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct FightTask { + name: String, + is_enable: bool, + + use_weekly_schedule: bool, + /// Absent when `UseWeeklySchedule` is false in typical GUI exports. + #[serde(default)] + weekly_schedule: Option, + use_optional_stage: bool, + stage_plan: StagePlan, + + use_medicine: bool, + medicine_count: i32, + use_stone: bool, + stone_count: i32, + enable_times_limit: bool, + times_limit: i32, + enable_target_drop: bool, + drop_id: String, + drop_count: i32, + series: i32, + use_expiring_medicine: bool, + medicine_expire_days: i32, + + #[serde(flatten)] + #[allow(dead_code)] + unknown: Map, + } -// // EnableTimesLimit + TimesLimit -> times -// if flag(task, "EnableTimesLimit") -// && let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("TimesLimit") -// { -// insert!(params, "times" => *times); -// } + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + struct WeeklySchedule { + sunday: bool, + monday: bool, + tuesday: bool, + wednesday: bool, + thursday: bool, + friday: bool, + saturday: bool, + } -// // EnableTargetDrop + DropId/DropCount -> drops = { = } -// if flag(task, "EnableTargetDrop") -// && let ( -// Some(MAAValue::Primitive(MAAPrimitive::String(drop_id))), -// Some(MAAValue::Primitive(MAAPrimitive::Int(drop_count))), -// ) = (task.get("DropId"), task.get("DropCount")) -// { -// let mut drop_map = maa_value::map::StringMap::new(); -// drop_map.insert(drop_id.clone(), (*drop_count).into()); -// insert!(params, "drops" => MAAValue::Object(drop_map)); -// } + #[derive(Debug, Deserialize)] + #[serde(untagged)] + enum StagePlan { + Single(String), + Many(Vec), + } -// // Series -> series (only when non-zero) -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(series))) = task.get("Series") -// && *series != 0 -// { -// insert!(params, "series" => *series); -// } + impl FightTask { + pub(super) fn report_disabled(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("FightTask", Some(self.name.clone())); + } + } -// // UseExpiringMedicine + MedicineExpireDays -> medicine_expire_days -// if flag(task, "UseExpiringMedicine") -// && let Some(MAAValue::Primitive(MAAPrimitive::Int(days))) = -// task.get("MedicineExpireDays") -// { -// insert!(params, "medicine_expire_days" => *days); -// } + /// Build the open-condition for a fight stage name. + /// + /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) + /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` + /// - Side-story stages listed in `StageActivityV2.json` → `OnSideStory` + fn stage_condition(stage: &str) -> Condition { + use Weekday::*; + match stage { + // 资源本 / 芯片本(同开放日合并) + "CE-6" => Condition::Weekday { + weekdays: vec![Tue, Thu, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "AP-5" => Condition::Weekday { + weekdays: vec![Mon, Thu, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "CA-5" => Condition::Weekday { + weekdays: vec![Tue, Wed, Fri, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "SK-5" => Condition::Weekday { + weekdays: vec![Mon, Wed, Fri, Sat], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-A-1" | "PR-A-2" => Condition::Weekday { + weekdays: vec![Mon, Thu, Fri, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-B-1" | "PR-B-2" => Condition::Weekday { + weekdays: vec![Mon, Tue, Fri, Sat], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-C-1" | "PR-C-2" => Condition::Weekday { + weekdays: vec![Wed, Thu, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-D-1" | "PR-D-2" => Condition::Weekday { + weekdays: vec![Tue, Wed, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + // 永久开启 + "LS-6" | "Annihilation" | "OF-1" | "OF-F3" => Condition::Always, + _ => { + let side_story = crate::activity::side_story_stages(ClientType::Official); + if side_story.iter().any(|code| code == stage) { + return Condition::OnSideStory { + client: ClientType::Official, + }; + } + warn!( + "FightTask stage `{stage}` has no known open schedule; treating as Always" + ); + Condition::Always + } + } + } + } -// params -// } + impl From<&FightTask> for FightParams { + fn from(task: &FightTask) -> Self { + FightParams { + medicine: task.use_medicine.then_some(task.medicine_count), + stone: task.use_stone.then(|| { + let count = task.stone_count; + warn!( + "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" + ); + count + }), + times: task.enable_times_limit.then_some(task.times_limit), + drops: task.enable_target_drop.then(|| { + let mut drops = maa_value::map::StringMap::new(); + drops.insert(task.drop_id.clone(), task.drop_count); + drops + }), + series: (task.series != 0).then_some(task.series), + medicine_expire_days: task + .use_expiring_medicine + .then_some(task.medicine_expire_days), + stage: None, + enable: None, + } + } + } -// /// `UseWeeklySchedule` + `WeeklySchedule` -> `Weekday` condition. -// fn weekly_schedule_condition(task: &MAAValue) -> Result { -// let Some(MAAValue::Object(map)) = task.get("WeeklySchedule") else { -// bail!("FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid"); -// }; - -// const DAYS: [(&str, &str); 7] = [ -// ("Sunday", "Sun"), -// ("Monday", "Mon"), -// ("Tuesday", "Tue"), -// ("Wednesday", "Wed"), -// ("Thursday", "Thu"), -// ("Friday", "Fri"), -// ("Saturday", "Sat"), -// ]; + impl From<&WeeklySchedule> for Condition { + fn from(schedule: &WeeklySchedule) -> Self { + let days = [ + (schedule.sunday, Weekday::Sun), + (schedule.monday, Weekday::Mon), + (schedule.tuesday, Weekday::Tue), + (schedule.wednesday, Weekday::Wed), + (schedule.thursday, Weekday::Thu), + (schedule.friday, Weekday::Fri), + (schedule.saturday, Weekday::Sat), + ]; + + let weekdays: Vec = days + .into_iter() + .filter_map(|(enabled, day)| enabled.then_some(day)) + .collect(); + + Condition::Weekday { + weekdays, + timezone: TimeOffset::Local, + } + } + } -// let mut weekdays = Vec::new(); -// for (gui_day, cli_day) in DAYS { -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = map.get(gui_day) { -// weekdays.push(cli_day); -// } -// } + impl<'a> TryFrom<&'a StagePlan> for &'a str { + type Error = anyhow::Error; + + fn try_from(plan: &'a StagePlan) -> Result<&'a str> { + match plan { + StagePlan::Single(stage) => Ok(stage.as_str()), + StagePlan::Many(stages) if stages.len() == 1 => Ok(stages[0].as_str()), + StagePlan::Many(_) => { + bail!( + "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ + got a string array; enable UseOptionalStage for multiple stages" + ) + } + } + } + } -// Ok(object!( -// "type" => "Weekday", -// "weekdays" => weekdays?? -// )) -// } + impl<'a> TryFrom<&'a StagePlan> for Vec<&'a str> { + type Error = anyhow::Error; + + fn try_from(plan: &'a StagePlan) -> Result> { + match plan { + StagePlan::Many(stages) => { + if stages.is_empty() { + bail!( + "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" + ); + } + Ok(stages.iter().map(String::as_str).collect()) + } + StagePlan::Single(_) => { + bail!( + "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" + ) + } + } + } + } -// /// Parse `StagePlan` as a single stage name. -// /// -// /// GUI usually stores a single stage as a one-element array (`["CE-6"]`). -// /// A multi-element array is rejected when `UseOptionalStage` is false. -// fn stage_plan_single(task: &MAAValue) -> Result<&str> { -// match task.get("StagePlan") { -// Some(MAAValue::Primitive(MAAPrimitive::String(stage))) => Ok(stage.as_str()), -// Some(MAAValue::Array(stages)) if stages.len() == 1 => match &stages[0] { -// MAAValue::Primitive(MAAPrimitive::String(stage)) => Ok(stage.as_str()), -// _ => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), -// }, -// Some(MAAValue::Array(_)) => { -// bail!( -// "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ -// got a string array; enable UseOptionalStage for multiple stages" -// ) -// } -// Some(_) => bail!("FightTask StagePlan must be a string when UseOptionalStage is false"), -// None => bail!("FightTask missing StagePlan"), -// } -// } + impl TryFrom<&FightTask> for FightCliTask { + type Error = anyhow::Error; + + fn try_from(task: &FightTask) -> Result { + let shared_params = FightParams::from(task); + + let mut item = match (task.use_weekly_schedule, task.use_optional_stage) { + // No variants: StagePlan must be a single stage. + (false, false) => { + let stage: &str = (&task.stage_plan).try_into()?; + let mut params = shared_params; + params.stage = Some(stage.to_string()); + FightCliTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: Some(params), + variants: Vec::new(), + } + } + // One variant gated by weekly schedule; StagePlan must be a single stage. + (true, false) => { + let stage: &str = (&task.stage_plan).try_into()?; + let weekly = Condition::from( + task.weekly_schedule.as_ref().context( + "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", + )?, + ); + let mut params = shared_params; + params.stage = Some(stage.to_string()); + FightCliTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: None, + variants: vec![FightVariant { + condition: weekly, + params, + }], + } + } + // One variant per optional stage, each with its own open-condition. + (false, true) => { + let stages: Vec<&str> = (&task.stage_plan).try_into()?; + let mut variants = Vec::with_capacity(stages.len()); + for stage in stages { + let mut params = shared_params.clone(); + params.stage = Some(stage.to_string()); + variants.push(FightVariant { + condition: FightTask::stage_condition(stage), + params, + }); + } + FightCliTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: None, + variants, + } + } + // Weekly schedule AND each stage's open-condition. + (true, true) => { + let stages: Vec<&str> = (&task.stage_plan).try_into()?; + let weekly = Condition::from( + task.weekly_schedule.as_ref().context( + "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", + )?, + ); + let mut variants = Vec::with_capacity(stages.len()); + for stage in stages { + let mut params = shared_params.clone(); + params.stage = Some(stage.to_string()); + variants.push(FightVariant { + condition: Condition::And { + conditions: vec![weekly.clone(), FightTask::stage_condition(stage)], + }, + params, + }); + } + FightCliTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: None, + variants, + } + } + }; -// /// Parse `StagePlan` as a non-empty string array (optional-stage mode). -// fn stage_plan_array(task: &MAAValue) -> Result> { -// match task.get("StagePlan") { -// Some(MAAValue::Array(stages)) => { -// let mut stage_list = Vec::with_capacity(stages.len()); -// for stage in stages { -// match stage { -// MAAValue::Primitive(MAAPrimitive::String(stage)) => { -// stage_list.push(stage.as_str()); -// } -// _ => bail!( -// "FightTask StagePlan array elements must be strings when UseOptionalStage is true" -// ), -// } -// } -// if stage_list.is_empty() { -// bail!( -// "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" -// ); -// } -// Ok(stage_list) -// } -// Some(MAAValue::Primitive(MAAPrimitive::String(_))) => { -// bail!( -// "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" -// ) -// } -// Some(_) => { -// bail!("FightTask StagePlan must be a string array when UseOptionalStage is true") -// } -// None => bail!("FightTask missing StagePlan"), -// } -// } + // MaaCore common switch: params.enable=false disables the task. + if !task.is_enable { + match &mut item.params { + Some(params) => params.enable = Some(false), + None => { + item.params = Some(FightParams { + enable: Some(false), + ..FightParams::default() + }); + } + } + } -// /// Build the open-condition for a fight stage name. -// /// -// /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) -// /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` -// /// - Side-story stages listed in `StageActivityV2.json` → `OnSideStory` -// fn stage_condition(stage: &str) -> MAAValue { -// match stage { -// // 资源本 / 芯片本(同开放日合并) -// "CE-6" => weekday_condition(&["Tue", "Thu", "Sat", "Sun"]), -// "AP-5" => weekday_condition(&["Mon", "Thu", "Sat", "Sun"]), -// "CA-5" => weekday_condition(&["Tue", "Wed", "Fri", "Sun"]), -// "SK-5" => weekday_condition(&["Mon", "Wed", "Fri", "Sat"]), -// "PR-A-1" | "PR-A-2" => weekday_condition(&["Mon", "Thu", "Fri", "Sun"]), -// "PR-B-1" | "PR-B-2" => weekday_condition(&["Mon", "Tue", "Fri", "Sat"]), -// "PR-C-1" | "PR-C-2" => weekday_condition(&["Wed", "Thu", "Sat", "Sun"]), -// "PR-D-1" | "PR-D-2" => weekday_condition(&["Tue", "Wed", "Sat", "Sun"]), -// // 永久开启 -// "LS-6" | "Annihilation" | "OF-1" | "OF-F3" => always_condition(), -// _ => { -// let side_story = crate::activity::side_story_stages(ClientType::Official); -// if side_story.iter().any(|code| code == stage) { -// return object!("type" => "OnSideStory"); -// } -// warn!("FightTask stage `{stage}` has no known open schedule; treating as Always"); -// always_condition() -// } -// } -// } + Ok(item) + } + } -// fn always_condition() -> MAAValue { -// object!("type" => "Always") -// } + impl TryFrom<&FightTask> for MAAValue { + type Error = anyhow::Error; -// fn weekday_condition(weekdays: &[&str]) -> MAAValue { -// object!( -// "type" => "Weekday", -// "weekdays" => weekdays.to_vec()??, -// "timezone" => "Official" -// ) -// } + fn try_from(task: &FightTask) -> Result { + serialize_to_maa_value(&FightCliTask::try_from(task)?) + } + } } // mod infrast { @@ -671,8 +674,8 @@ mod fight { // let mut facility = Vec::new(); // for room in rooms { // if let MAAValue::Object(map) = room -// && let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = map.get("Room") -// { +// && let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = +// map.get("Room") { // facility.push(room.as_str()); // } // } @@ -681,13 +684,13 @@ mod fight { // } // } // // UsesOfDrones -> drones -// if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") { -// insert!(params, "drones" => drones.as_str()); +// if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") +// { insert!(params, "drones" => drones.as_str()); // } // // DormThreshold -> threshold -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = task.get("DormThreshold") { -// insert!(params, "threshold" => *threshold as f32 / 100.0); -// } +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = +// task.get("DormThreshold") { insert!(params, "threshold" => *threshold as f32 / +// 100.0); } // // OriginiumShardAutoReplenishment -> replenish // if let Some(MAAValue::Primitive(MAAPrimitive::Bool(replenish))) = // task.get("OriginiumShardAutoReplenishment") @@ -701,8 +704,8 @@ mod fight { // insert!(params, "dorm_notstationed_enabled" => *enabled); // } // // DormTrustEnabled -> dorm_trust_enabled -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("DormTrustEnabled") -// { +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = +// task.get("DormTrustEnabled") { // insert!(params, "dorm_trust_enabled" => *enabled); // } // // ReceptionMessageBoard -> reception_message_board @@ -754,12 +757,12 @@ mod fight { // insert!(params, "extra_tags_mode" => *mode); // } // // RefreshLevel3 -> refresh -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") { -// insert!(params, "refresh" => *refresh); +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") +// { insert!(params, "refresh" => *refresh); // } // // ForceRefresh -> expedite -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") { -// insert!(params, "expedite" => *expedite); +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") +// { insert!(params, "expedite" => *expedite); // } // // LevelXChoose -> select / confirm // let mut select = Vec::new(); @@ -802,9 +805,9 @@ mod fight { // } // } // // PreserveTagEnabled + PreserveTagList -> preserve_tags -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreserveTagEnabled") -// && let Some(MAAValue::Array(tags)) = task.get("PreserveTagList") -// { +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = +// task.get("PreserveTagEnabled") && let Some(MAAValue::Array(tags)) = +// task.get("PreserveTagList") { // let mut preserve_tags = Vec::new(); // for tag in tags { // if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { @@ -861,8 +864,8 @@ mod fight { // insert!(params, "shopping" => *shopping); // } // // CreditFight -> credit_fight -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = task.get("CreditFight") -// { +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = +// task.get("CreditFight") { // insert!(params, "credit_fight" => *credit_fight); // } // // CreditFightFormation -> formation_index @@ -904,8 +907,8 @@ mod fight { // insert!(params, "only_buy_discount" => *only_discount); // } // // ReserveMaxCredit -> reserve_max_credit -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = task.get("ReserveMaxCredit") -// { +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = +// task.get("ReserveMaxCredit") { // insert!(params, "reserve_max_credit" => *reserve); // } @@ -1048,12 +1051,12 @@ mod fight { // insert!(params, "roles" => roles.as_str()); // } // // CoreChar -> core_char -// if let Some(MAAValue::Primitive(MAAPrimitive::String(core_char))) = task.get("CoreChar") { -// insert!(params, "core_char" => core_char.as_str()); +// if let Some(MAAValue::Primitive(MAAPrimitive::String(core_char))) = task.get("CoreChar") +// { insert!(params, "core_char" => core_char.as_str()); // } // // StartCount -> starts_count -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(starts_count))) = task.get("StartCount") { -// insert!(params, "starts_count" => *starts_count); +// if let Some(MAAValue::Primitive(MAAPrimitive::Int(starts_count))) = +// task.get("StartCount") { insert!(params, "starts_count" => *starts_count); // } // // Difficulty -> difficulty // if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") @@ -1098,8 +1101,8 @@ mod fight { // insert!(params, "stop_at_max_level" => *stop_at_max_level); // } // // UseSupport -> use_support -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = task.get("UseSupport") { -// insert!(params, "use_support" => *use_support); +// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = +// task.get("UseSupport") { insert!(params, "use_support" => *use_support); // } // // UseSupportNonFriend -> use_nonfriend_support // if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_nonfriend_support))) = diff --git a/crates/maa-cli/src/config/task/client_type.rs b/crates/maa-cli/src/config/task/client_type.rs index 99799fa1..cd3b5329 100644 --- a/crates/maa-cli/src/config/task/client_type.rs +++ b/crates/maa-cli/src/config/task/client_type.rs @@ -1,5 +1,5 @@ use clap::ValueEnum; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[repr(u8)] #[cfg_attr(test, derive(Debug))] @@ -38,6 +38,10 @@ impl ClientType { variants }; + pub(crate) const fn is_official(&self) -> bool { + matches!(self, Official) + } + pub const fn to_str(self) -> &'static str { match self { Official => "Official", @@ -124,6 +128,12 @@ impl<'de> Deserialize<'de> for ClientType { } } +impl Serialize for ClientType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.to_str()) + } +} + impl std::fmt::Display for ClientType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.to_str()) diff --git a/crates/maa-cli/src/config/task/condition.rs b/crates/maa-cli/src/config/task/condition.rs index eea050f4..cfaa16ad 100644 --- a/crates/maa-cli/src/config/task/condition.rs +++ b/crates/maa-cli/src/config/task/condition.rs @@ -1,13 +1,12 @@ use chrono::{DateTime, Datelike, NaiveDateTime, NaiveTime, TimeZone, Utc, Weekday}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::client_type::ClientType; use crate::activity::has_side_story_open; #[cfg_attr(test, derive(PartialEq, Debug))] -#[derive(Deserialize)] +#[derive(Clone, Default, Deserialize, Serialize)] #[serde(tag = "type")] -#[derive(Default)] pub enum Condition { /// The task is always active #[default] @@ -20,7 +19,11 @@ pub enum Condition { /// 03:59:59. Weekday { weekdays: Vec, - #[serde(default, alias = "client")] + #[serde( + default, + alias = "client", + skip_serializing_if = "TimeOffset::is_local" + )] timezone: TimeOffset, }, /// Day modula @@ -30,9 +33,9 @@ pub enum Condition { /// If `remainder` is not specified, it is 0. DayMod { divisor: u32, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_zero_u32")] remainder: u32, - #[serde(default)] + #[serde(default, skip_serializing_if = "TimeOffset::is_local")] timezone: TimeOffset, }, /// The task is active on the specified time range @@ -40,11 +43,11 @@ pub enum Condition { /// If `start` is `None`, the task is active before `end`. /// If `end` is `None`, the task is active after `start`. Time { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] start: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] end: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "TimeOffset::is_local")] timezone: TimeOffset, }, /// The task is active on the specified datetime range @@ -52,15 +55,15 @@ pub enum Condition { /// If `start` is `None`, the task is active before `end`. /// If `end` is `None`, the task is active after `start`. DateTime { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] start: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] end: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "TimeOffset::is_local")] timezone: TimeOffset, }, OnSideStory { - #[serde(default)] + #[serde(default, skip_serializing_if = "ClientType::is_official")] client: ClientType, }, /// The task is active if all the sub-conditions are met @@ -72,8 +75,12 @@ pub enum Condition { Not { condition: Box }, } +fn is_zero_u32(value: &u32) -> bool { + *value == 0 +} + #[cfg_attr(test, derive(PartialEq, Debug))] -#[derive(Clone, Copy, Default, Deserialize)] +#[derive(Clone, Copy, Default, Deserialize, Serialize)] #[serde(untagged)] pub enum TimeOffset { Client(ClientType), @@ -83,6 +90,10 @@ pub enum TimeOffset { } impl TimeOffset { + const fn is_local(&self) -> bool { + matches!(self, Self::Local) + } + /// Get the current date time in given time zone fn naive_now(self) -> NaiveDateTime { self.date_time(Utc::now()) diff --git a/crates/maa-cli/src/config/task/mod.rs b/crates/maa-cli/src/config/task/mod.rs index e5af62bb..31801b93 100644 --- a/crates/maa-cli/src/config/task/mod.rs +++ b/crates/maa-cli/src/config/task/mod.rs @@ -5,8 +5,7 @@ mod condition; use std::path::PathBuf; use anyhow::{Context, Result}; -use condition::Condition; -pub use condition::{TimeOffset, remainder_of_day_mod}; +pub use condition::{Condition, TimeOffset, remainder_of_day_mod}; use maa_types::TaskType; use maa_value::prelude::*; use serde::Deserialize; From 7742a4411e98db676768bc8283ba88853be0402d Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sun, 9 Aug 2026 01:51:40 +0800 Subject: [PATCH 15/20] =?UTF-8?q?=E2=9C=A8=20feat:=20complete=20typed=20WP?= =?UTF-8?q?F=20GUI=20task=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate remaining Infrast/Recruit/Mall/Award/Roguelike/Reclamation via Wpf*/Cli* structs and From/TryFrom. Reuse Fight/StartUp patterns with Gui.RuntimeSettings for StartUp and shared unknown-field reporting. Drop legacy MAAValue insert paths; keep Unsupported types skipped in the migration summary. 完成 WPF GUI 任务迁移的结构化实现,并覆盖剩余任务类型 --- crates/maa-cli/src/config/migrate/mod.rs | 1 - crates/maa-cli/src/config/migrate/wpf.rs | 1784 ++++++++++------- crates/maa-cli/src/config/task/client_type.rs | 3 +- crates/maa-cli/src/config/task/condition.rs | 8 +- 4 files changed, 1084 insertions(+), 712 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/mod.rs b/crates/maa-cli/src/config/migrate/mod.rs index 1924da6b..183c1ab7 100644 --- a/crates/maa-cli/src/config/migrate/mod.rs +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -53,7 +53,6 @@ impl MigrationSummary { }); } - #[expect(dead_code, reason = "used when reporting unmapped GUI fields")] pub(super) fn skip_field( &mut self, task_type: impl Into, diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index 20604202..f77c8a3e 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -101,7 +101,7 @@ fn resolve_configuration_name( /// Migrate a GUI profile into maa-cli task config shape. pub(super) fn migrate(input: Value) -> Result<(MAAValue, MigrationSummary)> { let mut summary = MigrationSummary::default(); - let config: Configuration = + let config: WpfConfiguration = serde_json::from_value(input).context("Failed to deserialize GUI configuration")?; let value = config .migrate_tasks(&mut summary)? @@ -115,25 +115,70 @@ fn serialize_to_maa_value(value: &T) -> Result { serde_json::from_value(json).context("Failed to convert CLI config prototype to MAAValue") } +/// Meta / structural keys that are never reported as skipped fields. +const META_FIELDS: &[&str] = &["$type", "TaskType", "Name", "IsEnable"]; + +fn report_unknown_fields( + summary: &mut MigrationSummary, + type_tag: &str, + name: Option, + unknown: &serde_json::Map, + handled: &[&str], +) { + for (key, value) in unknown { + if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { + continue; + } + if is_meaningful_json(value) { + summary.skip_field(type_tag, name.clone(), key.clone()); + } + } +} + +fn is_meaningful_json(value: &Value) -> bool { + match value { + Value::Bool(v) => *v, + Value::String(s) => !s.is_empty(), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + i != 0 && i != i64::from(i32::MAX) + } else if let Some(f) = n.as_f64() { + f != 0.0 + } else { + true + } + } + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + Value::Null => false, + } +} + +fn split_semi_list(list: &str) -> Vec { + list.split(';') + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + #[derive(Debug, Serialize)] struct CliConfig { tasks: Vec, } #[derive(Debug, Deserialize)] -struct Configuration { +struct WpfConfiguration { #[serde(rename = "TaskQueue")] task_queue: Vec, #[serde(rename = "Gui")] - #[allow(dead_code)] - gui: GuiSettings, + gui: WpfGuiSettings, } -impl Configuration { +impl WpfConfiguration { fn migrate_tasks(&self, summary: &mut MigrationSummary) -> Result> { let mut tasks = Vec::new(); for task in &self.task_queue { - if let Some(item) = task.migrate_task(summary)? { + if let Some(item) = task.migrate_task(&self.gui, summary)? { tasks.push(item); } } @@ -144,19 +189,58 @@ impl Configuration { #[derive(Debug, Deserialize)] #[serde(tag = "$type")] enum WpfTask { - FightTask(fight::FightTask), + StartUpTask(start_up::WpfStartUpTask), + FightTask(fight::WpfFightTask), + InfrastTask(infrast::WpfInfrastTask), + RecruitTask(recruit::WpfRecruitTask), + MallTask(mall::WpfMallTask), + AwardTask(award::WpfAwardTask), + RoguelikeTask(roguelike::WpfRoguelikeTask), + ReclamationTask(reclamation::WpfReclamationTask), /// Unknown `$type` values are skipped during migration. #[serde(other)] Unsupported, } impl WpfTask { - fn migrate_task(&self, summary: &mut MigrationSummary) -> Result> { + fn migrate_task( + &self, + gui: &WpfGuiSettings, + summary: &mut MigrationSummary, + ) -> Result> { match self { + Self::StartUpTask(start_up) => { + start_up.report_to(summary); + Ok(Some(start_up.to_maa_value(gui)?)) + } Self::FightTask(fight) => { fight.report_disabled(summary); Ok(Some(MAAValue::try_from(fight)?)) } + Self::InfrastTask(task) => { + task.report_to(summary); + Ok(Some(task.to_maa_value()?)) + } + Self::RecruitTask(task) => { + task.report_to(summary); + Ok(Some(task.to_maa_value()?)) + } + Self::MallTask(task) => { + task.report_to(summary); + Ok(Some(task.to_maa_value()?)) + } + Self::AwardTask(task) => { + task.report_to(summary); + Ok(Some(task.to_maa_value()?)) + } + Self::RoguelikeTask(task) => { + task.report_to(summary); + Ok(Some(task.to_maa_value()?)) + } + Self::ReclamationTask(task) => { + task.report_to(summary); + Ok(Some(task.to_maa_value()?)) + } Self::Unsupported => { summary.skip_task("Unsupported", None); Ok(None) @@ -165,79 +249,142 @@ impl WpfTask { } } +/// WPF GUI `Gui` object for the selected configuration. #[derive(Debug, Default, Deserialize)] -struct GuiSettings {} +#[serde(rename_all = "PascalCase")] +struct WpfGuiSettings { + #[serde(default)] + runtime_settings: Option, + #[serde(flatten)] + #[allow(dead_code)] + unknown: serde_json::Map, +} + +/// `Gui.RuntimeSettings` fields used by StartUp migration. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WpfRuntimeSettings { + #[serde(default)] + client_type: Option, + #[serde(default)] + start_game: Option, + #[serde(flatten)] + #[allow(dead_code)] + unknown: serde_json::Map, +} mod start_up { - // use anyhow::Result; - // use log::warn; - // use maa_value::prelude::*; + use anyhow::Result; + use log::warn; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; - // use super::MigrationSummary; + use super::{WpfGuiSettings, MigrationSummary, serialize_to_maa_value}; + use crate::config::task::ClientType; - // /// Valid GUI / maa-cli client type strings. - // const VALID_CLIENT_TYPES: &[&str] = &[ - // "Official", "Bilibili", "txwy", "YoStarEN", "YoStarJP", "YoStarKR", - // ]; + /// maa-cli StartUp task shape written by migration. + #[derive(Debug, Serialize)] + struct CliStartUpTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliStartUpParams, + } - #[allow(dead_code)] - pub(super) struct StartUpTask {} - - // pub(super) fn migrate_start_up_task( - // task: &MAAValue, - // config: &MAAValue, - // summary: &mut MigrationSummary, - // ) -> Result> { - // let runtime = config.get("Gui").and_then(|gui| gui.get("RuntimeSettings")); - - // let mut params = MAAValue::default(); - - // // Gui.RuntimeSettings.ClientType -> client_type - // // Missing / invalid values fall back to an interactive prompt. - // match runtime - // .and_then(|settings| settings.get("ClientType")) - // .and_then(|value| value.as_str()) - // .filter(|value| VALID_CLIENT_TYPES.contains(value)) - // { - // Some(client_type) => { - // insert!(params, "client_type" => client_type); - // } - // None => { - // insert!(params, "client_type" => object!( - // "alternatives" => VALID_CLIENT_TYPES.iter().map(|s| - // s.to_string()).collect::>()??, )); - // } - // } - - // // Gui.RuntimeSettings.StartGame -> start_game_enabled - // if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_game))) = - // runtime.and_then(|settings| settings.get("StartGame")) - // { - // insert!(params, "start_game_enabled" => *start_game); - // } - - // // AccountSwitchEnabled + AccountName -> account_name - // // Only emit account_name when account switching is enabled. - // if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = - // task.get("AccountSwitchEnabled") - // { - // if let Some(MAAValue::Primitive(MAAPrimitive::String(account_name))) = - // task.get("AccountName") - // { - // insert!(params, "account_name" => account_name.as_str()); - // } else { - // summary.skip_field("StartUpTask", None, "AccountName"); - // warn!("AccountName is missing, but GUI enable account switching"); - // } - // } - - // let item = object!( - // "type" => "StartUp", - // "params" => params - // ); - - // Ok(Some(item)) - // } + #[derive(Debug, Serialize)] + struct CliStartUpParams { + client_type: CliClientTypeParam, + #[serde(skip_serializing_if = "Option::is_none")] + start_game_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + account_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + /// Fixed client type string, or an interactive select prompt. + #[derive(Debug, Serialize)] + #[serde(untagged)] + enum CliClientTypeParam { + Fixed(ClientType), + Prompt { + alternatives: &'static [&'static str], + }, + } + + /// WPF GUI `StartUpTask` (`$type = "StartUpTask"`). + /// + /// Task-queue fields combine with [`WpfGuiSettings::runtime_settings`] when + /// converting to [`CliStartUpTask`] / [`MAAValue`]. + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfStartUpTask { + name: String, + is_enable: bool, + account_switch_enabled: bool, + /// Absent when the GUI export omits the key. + #[serde(default)] + account_name: Option, + #[serde(flatten)] + #[allow(dead_code)] + unknown: Map, + } + + impl WpfStartUpTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("StartUpTask", Some(self.name.clone())); + } + if self.account_switch_enabled && self.account_name.is_none() { + summary.skip_field("StartUpTask", Some(self.name.clone()), "AccountName"); + warn!("AccountName is missing, but GUI enable account switching"); + } + } + + pub(super) fn to_maa_value(&self, gui: &WpfGuiSettings) -> Result { + serialize_to_maa_value(&CliStartUpTask::try_from((self, gui))?) + } + } + + impl TryFrom<(&WpfStartUpTask, &WpfGuiSettings)> for CliStartUpTask { + type Error = anyhow::Error; + + fn try_from((task, gui): (&WpfStartUpTask, &WpfGuiSettings)) -> Result { + let runtime = gui.runtime_settings.as_ref(); + + let client_type = match runtime.and_then(|r| r.client_type.as_deref()) { + Some(s) if !s.is_empty() => match s.parse::() { + Ok(client) => CliClientTypeParam::Fixed(client), + Err(_) => CliClientTypeParam::Prompt { + alternatives: &ClientType::NAMES, + }, + }, + _ => CliClientTypeParam::Prompt { + alternatives: &ClientType::NAMES, + }, + }; + + let account_name = if task.account_switch_enabled { + task.account_name.clone() + } else { + None + }; + + Ok(CliStartUpTask { + task_type: TaskType::StartUp, + name: task.name.clone(), + params: CliStartUpParams { + client_type, + start_game_enabled: runtime.and_then(|r| r.start_game), + account_name, + enable: (!task.is_enable).then_some(false), + }, + }) + } + } } mod fight { @@ -254,25 +401,25 @@ mod fight { /// maa-cli Fight task shape written by migration. #[derive(Debug, Serialize)] - struct FightCliTask { + struct CliFightTask { #[serde(rename = "type")] task_type: TaskType, name: String, #[serde(skip_serializing_if = "Option::is_none")] - params: Option, + params: Option, #[serde(skip_serializing_if = "Vec::is_empty")] - variants: Vec, + variants: Vec, } #[derive(Debug, Serialize)] - struct FightVariant { + struct CliFightVariant { condition: Condition, - params: FightParams, + params: CliFightParams, } /// Fight params; field order matches historical `object!` / `insert!` output for parity. #[derive(Clone, Debug, Default, Serialize)] - struct FightParams { + struct CliFightParams { #[serde(skip_serializing_if = "Option::is_none")] medicine: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -293,20 +440,20 @@ mod fight { /// WPF GUI `FightTask` (`$type = "FightTask"`). /// - /// Deserialized from the GUI task queue; converts into [`FightCliTask`] / + /// Deserialized from the GUI task queue; converts into [`CliFightTask`] / /// [`MAAValue`] via [`TryFrom`]. Unmapped keys are collected in `unknown`. #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] - pub(super) struct FightTask { + pub(super) struct WpfFightTask { name: String, is_enable: bool, use_weekly_schedule: bool, /// Absent when `UseWeeklySchedule` is false in typical GUI exports. #[serde(default)] - weekly_schedule: Option, + weekly_schedule: Option, use_optional_stage: bool, - stage_plan: StagePlan, + stage_plan: WpfStagePlan, use_medicine: bool, medicine_count: i32, @@ -328,7 +475,7 @@ mod fight { #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] - struct WeeklySchedule { + struct WpfWeeklySchedule { sunday: bool, monday: bool, tuesday: bool, @@ -340,12 +487,12 @@ mod fight { #[derive(Debug, Deserialize)] #[serde(untagged)] - enum StagePlan { + enum WpfStagePlan { Single(String), Many(Vec), } - impl FightTask { + impl WpfFightTask { pub(super) fn report_disabled(&self, summary: &mut MigrationSummary) { if !self.is_enable { summary.disable_task("FightTask", Some(self.name.clone())); @@ -411,9 +558,9 @@ mod fight { } } - impl From<&FightTask> for FightParams { - fn from(task: &FightTask) -> Self { - FightParams { + impl From<&WpfFightTask> for CliFightParams { + fn from(task: &WpfFightTask) -> Self { + CliFightParams { medicine: task.use_medicine.then_some(task.medicine_count), stone: task.use_stone.then(|| { let count = task.stone_count; @@ -438,8 +585,8 @@ mod fight { } } - impl From<&WeeklySchedule> for Condition { - fn from(schedule: &WeeklySchedule) -> Self { + impl From<&WpfWeeklySchedule> for Condition { + fn from(schedule: &WpfWeeklySchedule) -> Self { let days = [ (schedule.sunday, Weekday::Sun), (schedule.monday, Weekday::Mon), @@ -462,14 +609,14 @@ mod fight { } } - impl<'a> TryFrom<&'a StagePlan> for &'a str { + impl<'a> TryFrom<&'a WpfStagePlan> for &'a str { type Error = anyhow::Error; - fn try_from(plan: &'a StagePlan) -> Result<&'a str> { + fn try_from(plan: &'a WpfStagePlan) -> Result<&'a str> { match plan { - StagePlan::Single(stage) => Ok(stage.as_str()), - StagePlan::Many(stages) if stages.len() == 1 => Ok(stages[0].as_str()), - StagePlan::Many(_) => { + WpfStagePlan::Single(stage) => Ok(stage.as_str()), + WpfStagePlan::Many(stages) if stages.len() == 1 => Ok(stages[0].as_str()), + WpfStagePlan::Many(_) => { bail!( "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ got a string array; enable UseOptionalStage for multiple stages" @@ -479,12 +626,12 @@ mod fight { } } - impl<'a> TryFrom<&'a StagePlan> for Vec<&'a str> { + impl<'a> TryFrom<&'a WpfStagePlan> for Vec<&'a str> { type Error = anyhow::Error; - fn try_from(plan: &'a StagePlan) -> Result> { + fn try_from(plan: &'a WpfStagePlan) -> Result> { match plan { - StagePlan::Many(stages) => { + WpfStagePlan::Many(stages) => { if stages.is_empty() { bail!( "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" @@ -492,7 +639,7 @@ mod fight { } Ok(stages.iter().map(String::as_str).collect()) } - StagePlan::Single(_) => { + WpfStagePlan::Single(_) => { bail!( "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" ) @@ -501,11 +648,11 @@ mod fight { } } - impl TryFrom<&FightTask> for FightCliTask { + impl TryFrom<&WpfFightTask> for CliFightTask { type Error = anyhow::Error; - fn try_from(task: &FightTask) -> Result { - let shared_params = FightParams::from(task); + fn try_from(task: &WpfFightTask) -> Result { + let shared_params = CliFightParams::from(task); let mut item = match (task.use_weekly_schedule, task.use_optional_stage) { // No variants: StagePlan must be a single stage. @@ -513,7 +660,7 @@ mod fight { let stage: &str = (&task.stage_plan).try_into()?; let mut params = shared_params; params.stage = Some(stage.to_string()); - FightCliTask { + CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), params: Some(params), @@ -530,11 +677,11 @@ mod fight { ); let mut params = shared_params; params.stage = Some(stage.to_string()); - FightCliTask { + CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), params: None, - variants: vec![FightVariant { + variants: vec![CliFightVariant { condition: weekly, params, }], @@ -547,12 +694,12 @@ mod fight { for stage in stages { let mut params = shared_params.clone(); params.stage = Some(stage.to_string()); - variants.push(FightVariant { - condition: FightTask::stage_condition(stage), + variants.push(CliFightVariant { + condition: WpfFightTask::stage_condition(stage), params, }); } - FightCliTask { + CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), params: None, @@ -571,14 +718,14 @@ mod fight { for stage in stages { let mut params = shared_params.clone(); params.stage = Some(stage.to_string()); - variants.push(FightVariant { + variants.push(CliFightVariant { condition: Condition::And { - conditions: vec![weekly.clone(), FightTask::stage_condition(stage)], + conditions: vec![weekly.clone(), WpfFightTask::stage_condition(stage)], }, params, }); } - FightCliTask { + CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), params: None, @@ -592,9 +739,9 @@ mod fight { match &mut item.params { Some(params) => params.enable = Some(false), None => { - item.params = Some(FightParams { + item.params = Some(CliFightParams { enable: Some(false), - ..FightParams::default() + ..CliFightParams::default() }); } } @@ -604,598 +751,825 @@ mod fight { } } - impl TryFrom<&FightTask> for MAAValue { + impl TryFrom<&WpfFightTask> for MAAValue { + type Error = anyhow::Error; + + fn try_from(task: &WpfFightTask) -> Result { + serialize_to_maa_value(&CliFightTask::try_from(task)?) + } + } +} + + +mod infrast { + use anyhow::{Result, bail}; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; + + const HANDLED: &[&str] = &[ + "Mode", + "RoomList", + "UsesOfDrones", + "DormThreshold", + "OriginiumShardAutoReplenishment", + "DormFilterNotStationed", + "DormTrustEnabled", + "ReceptionMessageBoard", + "ReceptionClueExchange", + "SendClue", + "Filename", + "PlanSelect", + ]; + + #[derive(Debug, Serialize)] + struct CliInfrastTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliInfrastParams, + } + + #[derive(Debug, Serialize)] + struct CliInfrastParams { + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + facility: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + drones: Option, + #[serde(skip_serializing_if = "Option::is_none")] + threshold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + replenish: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dorm_notstationed_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dorm_trust_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reception_message_board: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reception_clue_exchange: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reception_send_clue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + struct WpfInfrastRoom { + room: String, + } + + /// WPF GUI `InfrastTask` (`$type = "InfrastTask"`). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfInfrastTask { + name: String, + is_enable: bool, + mode: String, + #[serde(default)] + filename: String, + room_list: Vec, + uses_of_drones: String, + dorm_threshold: i32, + originium_shard_auto_replenishment: bool, + dorm_filter_not_stationed: bool, + dorm_trust_enabled: bool, + reception_message_board: bool, + reception_clue_exchange: bool, + send_clue: bool, + #[serde(flatten)] + unknown: Map, + } + + impl WpfInfrastTask { + fn mapped_mode(&self) -> Option { + match self.mode.as_str() { + "Normal" => Some(0), + "Rotation" => Some(20000), + _ => None, + } + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("InfrastTask", Some(self.name.clone())); + } + if self.mode != "Custom" && self.mapped_mode().is_none() { + summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); + } + report_unknown_fields( + summary, + "InfrastTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_maa_value(&self) -> Result { + serialize_to_maa_value(&CliInfrastTask::try_from(self)?) + } + } + + impl TryFrom<&WpfInfrastTask> for CliInfrastTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfInfrastTask) -> Result { + if task.mode == "Custom" { + bail!("InfrastTask custom mode is not supported yet"); + } + if !task.filename.is_empty() { + bail!("InfrastTask custom plan (Filename) is not supported yet"); + } + + Ok(CliInfrastTask { + task_type: TaskType::Infrast, + name: task.name.clone(), + params: CliInfrastParams { + mode: task.mapped_mode(), + facility: task.room_list.iter().map(|r| r.room.clone()).collect(), + drones: Some(task.uses_of_drones.clone()), + threshold: Some(task.dorm_threshold as f32 / 100.0), + replenish: Some(task.originium_shard_auto_replenishment), + dorm_notstationed_enabled: Some(task.dorm_filter_not_stationed), + dorm_trust_enabled: Some(task.dorm_trust_enabled), + reception_message_board: Some(task.reception_message_board), + reception_clue_exchange: Some(task.reception_clue_exchange), + reception_send_clue: Some(task.send_clue), + enable: (!task.is_enable).then_some(false), + }, + }) + } + } +} + +mod recruit { + use anyhow::Result; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; + + const HANDLED: &[&str] = &[ + "MaxTimes", + "ExtraTagMode", + "RefreshLevel3", + "ForceRefresh", + "Level6Choose", + "Level6Time", + "Level5Choose", + "Level5Time", + "Level4Choose", + "Level4Time", + "Level3Choose", + "Level3Time", + "PreferTagEnabled", + "Level3PreferTags", + "PreserveTagEnabled", + "PreserveTagList", + ]; + + #[derive(Debug, Serialize)] + struct CliRecruitTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliRecruitParams, + } + + #[derive(Debug, Serialize)] + struct CliRecruitParams { + #[serde(skip_serializing_if = "Option::is_none")] + times: Option, + #[serde(skip_serializing_if = "Option::is_none")] + extra_tags_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expedite: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + select: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + confirm: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + recruitment_time: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + first_tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + preserve_tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + /// WPF GUI `RecruitTask` (`$type = "RecruitTask"`). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfRecruitTask { + name: String, + is_enable: bool, + max_times: i32, + extra_tag_mode: i32, + refresh_level3: bool, + force_refresh: bool, + level3_choose: bool, + #[serde(default)] + level3_time: Option, + level4_choose: bool, + #[serde(default)] + level4_time: Option, + level5_choose: bool, + #[serde(default)] + level5_time: Option, + level6_choose: bool, + #[serde(default)] + level6_time: Option, + prefer_tag_enabled: bool, + #[serde(default)] + level3_prefer_tags: Vec, + preserve_tag_enabled: bool, + #[serde(default)] + preserve_tag_list: Vec, + #[serde(flatten)] + unknown: Map, + } + + impl WpfRecruitTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("RecruitTask", Some(self.name.clone())); + } + report_unknown_fields( + summary, + "RecruitTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_maa_value(&self) -> Result { + serialize_to_maa_value(&CliRecruitTask::try_from(self)?) + } + } + + impl TryFrom<&WpfRecruitTask> for CliRecruitTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfRecruitTask) -> Result { + let levels = [ + (task.level6_choose, task.level6_time, 6), + (task.level5_choose, task.level5_time, 5), + (task.level4_choose, task.level4_time, 4), + (task.level3_choose, task.level3_time, 3), + ]; + + let mut select = Vec::new(); + let mut confirm = Vec::new(); + let mut recruitment_time = maa_value::map::StringMap::new(); + for (choose, minutes, level) in levels { + if choose { + select.push(level); + confirm.push(level); + } + if let Some(minutes) = minutes { + recruitment_time.insert(level.to_string(), minutes); + } + } + + let first_tags = (task.prefer_tag_enabled && !task.level3_prefer_tags.is_empty()) + .then(|| task.level3_prefer_tags.clone()); + let preserve_tags = (task.preserve_tag_enabled && !task.preserve_tag_list.is_empty()) + .then(|| task.preserve_tag_list.clone()); + + Ok(CliRecruitTask { + task_type: TaskType::Recruit, + name: task.name.clone(), + params: CliRecruitParams { + times: Some(task.max_times), + extra_tags_mode: Some(task.extra_tag_mode), + refresh: Some(task.refresh_level3), + expedite: Some(task.force_refresh), + select, + confirm, + recruitment_time: (!recruitment_time.is_empty()).then_some(recruitment_time), + first_tags, + preserve_tags, + enable: (!task.is_enable).then_some(false), + }, + }) + } + } +} + +mod mall { + use anyhow::Result; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{ + MigrationSummary, report_unknown_fields, serialize_to_maa_value, split_semi_list, + }; + + const HANDLED: &[&str] = &[ + "Shopping", + "CreditFight", + "CreditFightFormation", + "VisitFriends", + "FirstList", + "BlackList", + "ShoppingIgnoreBlackListWhenFull", + "OnlyBuyDiscount", + "ReserveMaxCredit", + ]; + + #[derive(Debug, Serialize)] + struct CliMallTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliMallParams, + } + + #[derive(Debug, Serialize)] + struct CliMallParams { + #[serde(skip_serializing_if = "Option::is_none")] + shopping: Option, + #[serde(skip_serializing_if = "Option::is_none")] + credit_fight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + formation_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + visit_friends: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + buy_first: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + blacklist: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + force_shopping_if_credit_full: Option, + #[serde(skip_serializing_if = "Option::is_none")] + only_buy_discount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reserve_max_credit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + /// WPF GUI `MallTask` (`$type = "MallTask"`). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfMallTask { + name: String, + is_enable: bool, + shopping: bool, + credit_fight: bool, + credit_fight_formation: i32, + visit_friends: bool, + first_list: String, + black_list: String, + shopping_ignore_black_list_when_full: bool, + only_buy_discount: bool, + reserve_max_credit: bool, + #[serde(flatten)] + unknown: Map, + } + + impl WpfMallTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("MallTask", Some(self.name.clone())); + } + report_unknown_fields( + summary, + "MallTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_maa_value(&self) -> Result { + serialize_to_maa_value(&CliMallTask::try_from(self)?) + } + } + + impl TryFrom<&WpfMallTask> for CliMallTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfMallTask) -> Result { + Ok(CliMallTask { + task_type: TaskType::Mall, + name: task.name.clone(), + params: CliMallParams { + shopping: Some(task.shopping), + credit_fight: Some(task.credit_fight), + formation_index: Some(task.credit_fight_formation), + visit_friends: Some(task.visit_friends), + buy_first: split_semi_list(&task.first_list), + blacklist: split_semi_list(&task.black_list), + force_shopping_if_credit_full: Some(task.shopping_ignore_black_list_when_full), + only_buy_discount: Some(task.only_buy_discount), + reserve_max_credit: Some(task.reserve_max_credit), + enable: (!task.is_enable).then_some(false), + }, + }) + } + } +} + +mod award { + use anyhow::Result; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; + + const HANDLED: &[&str] = &[ + "Award", + "Mail", + "FreeGacha", + "Orundum", + "Mining", + "SpecialAccess", + ]; + + #[derive(Debug, Serialize)] + struct CliAwardTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliAwardParams, + } + + #[derive(Debug, Serialize)] + struct CliAwardParams { + #[serde(skip_serializing_if = "Option::is_none")] + award: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + recruit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + orundum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mining: Option, + #[serde(skip_serializing_if = "Option::is_none")] + specialaccess: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + /// WPF GUI `AwardTask` (`$type = "AwardTask"`). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfAwardTask { + name: String, + is_enable: bool, + award: bool, + mail: bool, + free_gacha: bool, + orundum: bool, + mining: bool, + special_access: bool, + #[serde(flatten)] + unknown: Map, + } + + impl WpfAwardTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("AwardTask", Some(self.name.clone())); + } + report_unknown_fields( + summary, + "AwardTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_maa_value(&self) -> Result { + serialize_to_maa_value(&CliAwardTask::try_from(self)?) + } + } + + impl TryFrom<&WpfAwardTask> for CliAwardTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfAwardTask) -> Result { + Ok(CliAwardTask { + task_type: TaskType::Award, + name: task.name.clone(), + params: CliAwardParams { + award: Some(task.award), + mail: Some(task.mail), + recruit: Some(task.free_gacha), + orundum: Some(task.orundum), + mining: Some(task.mining), + specialaccess: Some(task.special_access), + enable: (!task.is_enable).then_some(false), + }, + }) + } + } +} + +mod roguelike { + use anyhow::Result; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; + + const HANDLED: &[&str] = &[ + "Theme", + "Mode", + "Squad", + "Roles", + "CoreChar", + "StartCount", + "Difficulty", + "Investment", + "InvestCount", + "InvestWithMoreScore", + "StopWhenDepositFull", + "StopAtFinalBoss", + "StopWhenLevelMax", + "UseSupport", + "UseSupportNonFriend", + "RefreshTraderWithDice", + "StartWithEliteTwo", + "StartWithEliteTwoOnly", + ]; + + #[derive(Debug, Serialize)] + struct CliRoguelikeTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliRoguelikeParams, + } + + #[derive(Debug, Serialize)] + struct CliRoguelikeParams { + #[serde(skip_serializing_if = "Option::is_none")] + theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + squad: Option, + #[serde(skip_serializing_if = "Option::is_none")] + roles: Option, + #[serde(skip_serializing_if = "Option::is_none")] + core_char: Option, + #[serde(skip_serializing_if = "Option::is_none")] + starts_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + difficulty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + investment_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + investments_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + investment_with_more_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_when_investment_full: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_at_final_boss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_at_max_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + use_support: Option, + #[serde(skip_serializing_if = "Option::is_none")] + use_nonfriend_support: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_trader_with_dice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + start_with_elite_two: Option, + #[serde(skip_serializing_if = "Option::is_none")] + only_start_with_elite_two: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + /// WPF GUI `RoguelikeTask` (`$type = "RoguelikeTask"`). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfRoguelikeTask { + name: String, + is_enable: bool, + theme: String, + mode: String, + squad: String, + roles: String, + core_char: String, + start_count: i32, + difficulty: i32, + investment: bool, + invest_count: i32, + invest_with_more_score: bool, + stop_when_deposit_full: bool, + stop_at_final_boss: bool, + stop_when_level_max: bool, + use_support: bool, + use_support_non_friend: bool, + refresh_trader_with_dice: bool, + start_with_elite_two: bool, + start_with_elite_two_only: bool, + #[serde(flatten)] + unknown: Map, + } + + impl WpfRoguelikeTask { + fn mapped_mode(&self) -> Option { + match self.mode.as_str() { + "Exp" => Some(0), + "Investment" => Some(1), + "Collect" => Some(4), + "CollapsalParadigms" => Some(5), + "MonthlySquad" => Some(6), + "DeepExploration" => Some(7), + _ => None, + } + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("RoguelikeTask", Some(self.name.clone())); + } + if self.mapped_mode().is_none() { + summary.skip_field("RoguelikeTask", Some(self.name.clone()), "Mode"); + } + report_unknown_fields( + summary, + "RoguelikeTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_maa_value(&self) -> Result { + serialize_to_maa_value(&CliRoguelikeTask::try_from(self)?) + } + } + + impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { type Error = anyhow::Error; - fn try_from(task: &FightTask) -> Result { - serialize_to_maa_value(&FightCliTask::try_from(task)?) + fn try_from(task: &WpfRoguelikeTask) -> Result { + Ok(CliRoguelikeTask { + task_type: TaskType::Roguelike, + name: task.name.clone(), + params: CliRoguelikeParams { + theme: Some(task.theme.clone()), + mode: task.mapped_mode(), + squad: Some(task.squad.clone()), + roles: Some(task.roles.clone()), + core_char: Some(task.core_char.clone()), + starts_count: Some(task.start_count), + difficulty: (task.difficulty != i32::MAX).then_some(task.difficulty), + investment_enabled: Some(task.investment), + investments_count: Some(task.invest_count), + investment_with_more_score: Some(task.invest_with_more_score), + stop_when_investment_full: Some(task.stop_when_deposit_full), + stop_at_final_boss: Some(task.stop_at_final_boss), + stop_at_max_level: Some(task.stop_when_level_max), + use_support: Some(task.use_support), + use_nonfriend_support: Some(task.use_support_non_friend), + refresh_trader_with_dice: Some(task.refresh_trader_with_dice), + start_with_elite_two: Some(task.start_with_elite_two), + only_start_with_elite_two: Some(task.start_with_elite_two_only), + enable: (!task.is_enable).then_some(false), + }, + }) } } } -// mod infrast { -// use anyhow::{Result, bail}; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; - -// pub(super) fn migrate_infrast_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// // Custom infrastructure plans are not migrated yet. -// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") -// && mode == "Custom" -// { -// bail!("InfrastTask custom mode is not supported yet"); -// } -// if let Some(MAAValue::Primitive(MAAPrimitive::String(filename))) = task.get("Filename") -// && !filename.is_empty() -// { -// bail!("InfrastTask custom plan (Filename) is not supported yet"); -// } - -// let mut item = object!("type" => "Infrast"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let mut params = MAAValue::default(); -// let mut handled = vec![ -// "Mode", -// "RoomList", -// "UsesOfDrones", -// "DormThreshold", -// "OriginiumShardAutoReplenishment", -// "DormFilterNotStationed", -// "DormTrustEnabled", -// "ReceptionMessageBoard", -// "ReceptionClueExchange", -// "SendClue", -// // Custom plan fields: rejected above when set; keep handled so defaults are quiet. -// "Filename", -// "PlanSelect", -// ]; -// // Mode -> mode (Custom is rejected above) -// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { -// if let Some(mode) = match mode.as_str() { -// "Normal" => Some(0), -// "Rotation" => Some(20000), -// _ => None, -// } { -// insert!(params, "mode" => mode); -// } else { -// handled.retain(|field| *field != "Mode"); -// } -// } -// // RoomList -> facility -// if let Some(MAAValue::Array(rooms)) = task.get("RoomList") { -// let mut facility = Vec::new(); -// for room in rooms { -// if let MAAValue::Object(map) = room -// && let Some(MAAValue::Primitive(MAAPrimitive::String(room))) = -// map.get("Room") { -// facility.push(room.as_str()); -// } -// } -// if !facility.is_empty() { -// insert!(params, "facility" => facility??); -// } -// } -// // UsesOfDrones -> drones -// if let Some(MAAValue::Primitive(MAAPrimitive::String(drones))) = task.get("UsesOfDrones") -// { insert!(params, "drones" => drones.as_str()); -// } -// // DormThreshold -> threshold -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(threshold))) = -// task.get("DormThreshold") { insert!(params, "threshold" => *threshold as f32 / -// 100.0); } -// // OriginiumShardAutoReplenishment -> replenish -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(replenish))) = -// task.get("OriginiumShardAutoReplenishment") -// { -// insert!(params, "replenish" => *replenish); -// } -// // DormFilterNotStationed -> dorm_notstationed_enabled -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = -// task.get("DormFilterNotStationed") -// { -// insert!(params, "dorm_notstationed_enabled" => *enabled); -// } -// // DormTrustEnabled -> dorm_trust_enabled -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = -// task.get("DormTrustEnabled") { -// insert!(params, "dorm_trust_enabled" => *enabled); -// } -// // ReceptionMessageBoard -> reception_message_board -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = -// task.get("ReceptionMessageBoard") -// { -// insert!(params, "reception_message_board" => *enabled); -// } -// // ReceptionClueExchange -> reception_clue_exchange -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = -// task.get("ReceptionClueExchange") -// { -// insert!(params, "reception_clue_exchange" => *enabled); -// } -// // SendClue -> reception_send_clue -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(enabled))) = task.get("SendClue") { -// insert!(params, "reception_send_clue" => *enabled); -// } - -// insert!(item, "params" => params); -// report_unhandled_fields(summary, task, "InfrastTask", &handled); -// Ok(Some(item)) -// } -// } - -// mod recruit { -// use anyhow::Result; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; - -// pub(super) fn migrate_recruit_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let mut item = object!("type" => "Recruit"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let mut params = MAAValue::default(); -// // MaxTimes -> times -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(times))) = task.get("MaxTimes") { -// insert!(params, "times" => *times); -// } -// // ExtraTagMode -> extra_tags_mode -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(mode))) = task.get("ExtraTagMode") { -// insert!(params, "extra_tags_mode" => *mode); -// } -// // RefreshLevel3 -> refresh -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh))) = task.get("RefreshLevel3") -// { insert!(params, "refresh" => *refresh); -// } -// // ForceRefresh -> expedite -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(expedite))) = task.get("ForceRefresh") -// { insert!(params, "expedite" => *expedite); -// } -// // LevelXChoose -> select / confirm -// let mut select = Vec::new(); -// let mut confirm = Vec::new(); -// let mut recruitment_time = MAAValue::default(); -// const LEVELS: [(&str, &str, i32); 4] = [ -// ("Level6Choose", "Level6Time", 6), -// ("Level5Choose", "Level5Time", 5), -// ("Level4Choose", "Level4Time", 4), -// ("Level3Choose", "Level3Time", 3), -// ]; -// for (choose_key, time_key, level) in LEVELS { -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get(choose_key) { -// select.push(level); -// confirm.push(level); -// } -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(minutes))) = task.get(time_key) { -// recruitment_time.insert(level.to_string(), (*minutes).into()); -// } -// } -// if !select.is_empty() { -// insert!(params, "select" => select??); -// insert!(params, "confirm" => confirm??); -// } -// if recruitment_time.as_map().is_some_and(|map| !map.is_empty()) { -// insert!(params, "recruitment_time" => recruitment_time); -// } -// // PreferTagEnabled + Level3PreferTags -> first_tags -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = task.get("PreferTagEnabled") -// && let Some(MAAValue::Array(tags)) = task.get("Level3PreferTags") -// { -// let mut first_tags = Vec::new(); -// for tag in tags { -// if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { -// first_tags.push(tag.as_str()); -// } -// } -// if !first_tags.is_empty() { -// insert!(params, "first_tags" => first_tags??); -// } -// } -// // PreserveTagEnabled + PreserveTagList -> preserve_tags -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(true))) = -// task.get("PreserveTagEnabled") && let Some(MAAValue::Array(tags)) = -// task.get("PreserveTagList") { -// let mut preserve_tags = Vec::new(); -// for tag in tags { -// if let MAAValue::Primitive(MAAPrimitive::String(tag)) = tag { -// preserve_tags.push(tag.as_str()); -// } -// } -// if !preserve_tags.is_empty() { -// insert!(params, "preserve_tags" => preserve_tags??); -// } -// } - -// insert!(item, "params" => params); -// report_unhandled_fields(summary, task, "RecruitTask", &[ -// "MaxTimes", -// "ExtraTagMode", -// "RefreshLevel3", -// "ForceRefresh", -// "Level6Choose", -// "Level6Time", -// "Level5Choose", -// "Level5Time", -// "Level4Choose", -// "Level4Time", -// "Level3Choose", -// "Level3Time", -// "PreferTagEnabled", -// "Level3PreferTags", -// "PreserveTagEnabled", -// "PreserveTagList", -// ]); -// Ok(Some(item)) -// } -// } - -// mod mall { -// use anyhow::Result; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; - -// pub(super) fn migrate_mall_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let mut item = object!("type" => "Mall"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let mut params = MAAValue::default(); -// // Shopping -> shopping -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(shopping))) = task.get("Shopping") { -// insert!(params, "shopping" => *shopping); -// } -// // CreditFight -> credit_fight -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(credit_fight))) = -// task.get("CreditFight") { -// insert!(params, "credit_fight" => *credit_fight); -// } -// // CreditFightFormation -> formation_index -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(formation_index))) = -// task.get("CreditFightFormation") -// { -// insert!(params, "formation_index" => *formation_index); -// } -// // VisitFriends -> visit_friends -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(visit_friends))) = -// task.get("VisitFriends") -// { -// insert!(params, "visit_friends" => *visit_friends); -// } -// // FirstList -> buy_first -// if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("FirstList") { -// let buy_first: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); -// if !buy_first.is_empty() { -// insert!(params, "buy_first" => buy_first??); -// } -// } -// // BlackList -> blacklist -// if let Some(MAAValue::Primitive(MAAPrimitive::String(list))) = task.get("BlackList") { -// let blacklist: Vec<_> = list.split(';').filter(|item| !item.is_empty()).collect(); -// if !blacklist.is_empty() { -// insert!(params, "blacklist" => blacklist??); -// } -// } -// // ShoppingIgnoreBlackListWhenFull -> force_shopping_if_credit_full -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(force))) = -// task.get("ShoppingIgnoreBlackListWhenFull") -// { -// insert!(params, "force_shopping_if_credit_full" => *force); -// } -// // OnlyBuyDiscount -> only_buy_discount -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_discount))) = -// task.get("OnlyBuyDiscount") -// { -// insert!(params, "only_buy_discount" => *only_discount); -// } -// // ReserveMaxCredit -> reserve_max_credit -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(reserve))) = -// task.get("ReserveMaxCredit") { -// insert!(params, "reserve_max_credit" => *reserve); -// } - -// insert!(item, "params" => params); -// report_unhandled_fields(summary, task, "MallTask", &[ -// "Shopping", -// "CreditFight", -// "CreditFightFormation", -// "VisitFriends", -// "FirstList", -// "BlackList", -// "ShoppingIgnoreBlackListWhenFull", -// "OnlyBuyDiscount", -// "ReserveMaxCredit", -// ]); -// Ok(Some(item)) -// } -// } - -// mod award { -// use anyhow::Result; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; - -// pub(super) fn migrate_award_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let mut item = object!("type" => "Award"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let mut params = MAAValue::default(); -// // Award -> award -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(award))) = task.get("Award") { -// insert!(params, "award" => *award); -// } -// // Mail -> mail -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mail))) = task.get("Mail") { -// insert!(params, "mail" => *mail); -// } -// // FreeGacha -> recruit -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(recruit))) = task.get("FreeGacha") { -// insert!(params, "recruit" => *recruit); -// } -// // Orundum -> orundum -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(orundum))) = task.get("Orundum") { -// insert!(params, "orundum" => *orundum); -// } -// // Mining -> mining -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(mining))) = task.get("Mining") { -// insert!(params, "mining" => *mining); -// } -// // SpecialAccess -> specialaccess -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(specialaccess))) = -// task.get("SpecialAccess") -// { -// insert!(params, "specialaccess" => *specialaccess); -// } - -// insert!(item, "params" => params); -// report_unhandled_fields(summary, task, "AwardTask", &[ -// "Award", -// "Mail", -// "FreeGacha", -// "Orundum", -// "Mining", -// "SpecialAccess", -// ]); -// Ok(Some(item)) -// } -// } - -// mod roguelike { -// use anyhow::Result; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; - -// pub(super) fn migrate_roguelike_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let mut item = object!("type" => "Roguelike"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let mut params = MAAValue::default(); -// let mut handled = vec![ -// "Theme", -// "Mode", -// "Squad", -// "Roles", -// "CoreChar", -// "StartCount", -// "Difficulty", -// "Investment", -// "InvestCount", -// "InvestWithMoreScore", -// "StopWhenDepositFull", -// "StopAtFinalBoss", -// "StopWhenLevelMax", -// "UseSupport", -// "UseSupportNonFriend", -// "RefreshTraderWithDice", -// "StartWithEliteTwo", -// "StartWithEliteTwoOnly", -// ]; -// // Theme -> theme -// if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { -// insert!(params, "theme" => theme.as_str()); -// } -// // Mode -> mode -// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { -// if let Some(mode) = match mode.as_str() { -// "Exp" => Some(0), -// "Investment" => Some(1), -// "Collect" => Some(4), -// "CollapsalParadigms" => Some(5), -// "MonthlySquad" => Some(6), -// "DeepExploration" => Some(7), -// _ => None, -// } { -// insert!(params, "mode" => mode); -// } else { -// handled.retain(|field| *field != "Mode"); -// } -// } -// // Squad -> squad -// if let Some(MAAValue::Primitive(MAAPrimitive::String(squad))) = task.get("Squad") { -// insert!(params, "squad" => squad.as_str()); -// } -// // Roles -> roles -// if let Some(MAAValue::Primitive(MAAPrimitive::String(roles))) = task.get("Roles") { -// insert!(params, "roles" => roles.as_str()); -// } -// // CoreChar -> core_char -// if let Some(MAAValue::Primitive(MAAPrimitive::String(core_char))) = task.get("CoreChar") -// { insert!(params, "core_char" => core_char.as_str()); -// } -// // StartCount -> starts_count -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(starts_count))) = -// task.get("StartCount") { insert!(params, "starts_count" => *starts_count); -// } -// // Difficulty -> difficulty -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(difficulty))) = task.get("Difficulty") -// && *difficulty != i32::MAX -// { -// insert!(params, "difficulty" => *difficulty); -// } -// // Investment -> investment_enabled -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_enabled))) = -// task.get("Investment") -// { -// insert!(params, "investment_enabled" => *investment_enabled); -// } -// // InvestCount -> investments_count -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(investments_count))) = -// task.get("InvestCount") -// { -// insert!(params, "investments_count" => *investments_count); -// } -// // InvestWithMoreScore -> investment_with_more_score -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(investment_with_more_score))) = -// task.get("InvestWithMoreScore") -// { -// insert!(params, "investment_with_more_score" => *investment_with_more_score); -// } -// // StopWhenDepositFull -> stop_when_investment_full -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_when_investment_full))) = -// task.get("StopWhenDepositFull") -// { -// insert!(params, "stop_when_investment_full" => *stop_when_investment_full); -// } -// // StopAtFinalBoss -> stop_at_final_boss -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_final_boss))) = -// task.get("StopAtFinalBoss") -// { -// insert!(params, "stop_at_final_boss" => *stop_at_final_boss); -// } -// // StopWhenLevelMax -> stop_at_max_level -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(stop_at_max_level))) = -// task.get("StopWhenLevelMax") -// { -// insert!(params, "stop_at_max_level" => *stop_at_max_level); -// } -// // UseSupport -> use_support -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_support))) = -// task.get("UseSupport") { insert!(params, "use_support" => *use_support); -// } -// // UseSupportNonFriend -> use_nonfriend_support -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(use_nonfriend_support))) = -// task.get("UseSupportNonFriend") -// { -// insert!(params, "use_nonfriend_support" => *use_nonfriend_support); -// } -// // RefreshTraderWithDice -> refresh_trader_with_dice -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(refresh_trader_with_dice))) = -// task.get("RefreshTraderWithDice") -// { -// insert!(params, "refresh_trader_with_dice" => *refresh_trader_with_dice); -// } -// // StartWithEliteTwo -> start_with_elite_two -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(start_with_elite_two))) = -// task.get("StartWithEliteTwo") -// { -// insert!(params, "start_with_elite_two" => *start_with_elite_two); -// } -// // StartWithEliteTwoOnly -> only_start_with_elite_two -// if let Some(MAAValue::Primitive(MAAPrimitive::Bool(only_start_with_elite_two))) = -// task.get("StartWithEliteTwoOnly") -// { -// insert!(params, "only_start_with_elite_two" => *only_start_with_elite_two); -// } - -// insert!(item, "params" => params); -// report_unhandled_fields(summary, task, "RoguelikeTask", &handled); -// Ok(Some(item)) -// } -// } - -// mod reclamation { -// use anyhow::Result; -// use maa_value::prelude::*; - -// use super::{MigrationSummary, report_unhandled_fields}; - -// pub(super) fn migrate_reclamation_task( -// task: &MAAValue, -// summary: &mut MigrationSummary, -// ) -> Result> { -// let mut item = object!("type" => "Reclamation"); -// // -> task name -// if let Some(MAAValue::Primitive(MAAPrimitive::String(name))) = task.get("Name") { -// insert!(item, "name" => name.as_str()); -// } - -// let mut params = MAAValue::default(); -// let mut handled = vec![ -// "Theme", -// "Mode", -// "ToolToCraft", -// "IncrementMode", -// "MaxCraftCountPerRound", -// ]; -// // Theme -> theme -// if let Some(MAAValue::Primitive(MAAPrimitive::String(theme))) = task.get("Theme") { -// insert!(params, "theme" => theme.as_str()); -// } -// // Mode -> mode -// if let Some(MAAValue::Primitive(MAAPrimitive::String(mode))) = task.get("Mode") { -// if let Some(mode) = match mode.as_str() { -// "ProsperityNoSave" => Some(0), -// "ProsperityInSave" => Some(1), -// _ => None, -// } { -// insert!(params, "mode" => mode); -// } else { -// handled.retain(|field| *field != "Mode"); -// } -// } -// // ToolToCraft -> tools_to_craft -// if let Some(MAAValue::Primitive(MAAPrimitive::String(tool))) = task.get("ToolToCraft") -// && !tool.is_empty() -// { -// insert!(params, "tools_to_craft" => vec![tool.as_str()]??); -// } -// // IncrementMode -> increment_mode -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(increment_mode))) = -// task.get("IncrementMode") -// { -// insert!(params, "increment_mode" => *increment_mode); -// } -// // MaxCraftCountPerRound -> num_craft_batches -// if let Some(MAAValue::Primitive(MAAPrimitive::Int(num_craft_batches))) = -// task.get("MaxCraftCountPerRound") -// { -// insert!(params, "num_craft_batches" => *num_craft_batches); -// } - -// insert!(item, "params" => params); -// report_unhandled_fields(summary, task, "ReclamationTask", &handled); -// Ok(Some(item)) -// } -// } +mod reclamation { + use anyhow::Result; + use maa_types::TaskType; + use maa_value::prelude::*; + use serde::{Deserialize, Serialize}; + use serde_json::{Map, Value}; + + use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; + + const HANDLED: &[&str] = &[ + "Theme", + "Mode", + "ToolToCraft", + "IncrementMode", + "MaxCraftCountPerRound", + ]; + + #[derive(Debug, Serialize)] + struct CliReclamationTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliReclamationParams, + } + + #[derive(Debug, Serialize)] + struct CliReclamationParams { + #[serde(skip_serializing_if = "Option::is_none")] + theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools_to_craft: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + increment_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + num_craft_batches: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, + } + + /// WPF GUI `ReclamationTask` (`$type = "ReclamationTask"`). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub(super) struct WpfReclamationTask { + name: String, + is_enable: bool, + theme: String, + mode: String, + tool_to_craft: String, + increment_mode: i32, + max_craft_count_per_round: i32, + #[serde(flatten)] + unknown: Map, + } + + impl WpfReclamationTask { + fn mapped_mode(&self) -> Option { + match self.mode.as_str() { + "ProsperityNoSave" => Some(0), + "ProsperityInSave" => Some(1), + _ => None, + } + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("ReclamationTask", Some(self.name.clone())); + } + if self.mapped_mode().is_none() { + summary.skip_field("ReclamationTask", Some(self.name.clone()), "Mode"); + } + report_unknown_fields( + summary, + "ReclamationTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_maa_value(&self) -> Result { + serialize_to_maa_value(&CliReclamationTask::try_from(self)?) + } + } + + impl TryFrom<&WpfReclamationTask> for CliReclamationTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfReclamationTask) -> Result { + Ok(CliReclamationTask { + task_type: TaskType::Reclamation, + name: task.name.clone(), + params: CliReclamationParams { + theme: Some(task.theme.clone()), + mode: task.mapped_mode(), + tools_to_craft: (!task.tool_to_craft.is_empty()) + .then(|| vec![task.tool_to_craft.clone()]), + increment_mode: Some(task.increment_mode), + num_craft_batches: Some(task.max_craft_count_per_round), + enable: (!task.is_enable).then_some(false), + }, + }) + } + } +} diff --git a/crates/maa-cli/src/config/task/client_type.rs b/crates/maa-cli/src/config/task/client_type.rs index cd3b5329..bbcdfea5 100644 --- a/crates/maa-cli/src/config/task/client_type.rs +++ b/crates/maa-cli/src/config/task/client_type.rs @@ -2,8 +2,7 @@ use clap::ValueEnum; use serde::{Deserialize, Serialize}; #[repr(u8)] -#[cfg_attr(test, derive(Debug))] -#[derive(Clone, Copy, Default, ValueEnum, PartialEq)] +#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq)] #[clap(rename_all = "verbatim")] pub enum ClientType { #[default] diff --git a/crates/maa-cli/src/config/task/condition.rs b/crates/maa-cli/src/config/task/condition.rs index cfaa16ad..1fd1a202 100644 --- a/crates/maa-cli/src/config/task/condition.rs +++ b/crates/maa-cli/src/config/task/condition.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; use super::client_type::ClientType; use crate::activity::has_side_story_open; -#[cfg_attr(test, derive(PartialEq, Debug))] -#[derive(Clone, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq))] #[serde(tag = "type")] pub enum Condition { /// The task is always active @@ -79,8 +79,8 @@ fn is_zero_u32(value: &u32) -> bool { *value == 0 } -#[cfg_attr(test, derive(PartialEq, Debug))] -#[derive(Clone, Copy, Default, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq))] #[serde(untagged)] pub enum TimeOffset { Client(ClientType), From 63c823f833e3d150aca999158eafd72dff2423b8 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sun, 9 Aug 2026 02:38:51 +0800 Subject: [PATCH 16/20] =?UTF-8?q?=F0=9F=93=9D=20fix:=20correct=20WPF=20fig?= =?UTF-8?q?ht/recruit=20migration=20and=20document=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put shared Fight options on task params so variants only select stages. Drop ForceRefresh→expedite mapping and warn on unsupported/dangerous recruit settings. Add zh-CN WPF migrate docs and refresh the example output. 修正 WPF 理智作战/公招迁移并补充中文文档 --- .../maa-cli/config_examples/wpf/gui.new.json | 943 ++---------------- .../maa-cli/config_examples/wpf/gui.new.toml | 126 +-- crates/maa-cli/docs/zh-CN/intro.md | 1 + crates/maa-cli/docs/zh-CN/migrate.md | 246 ----- crates/maa-cli/docs/zh-CN/wpf.md | 126 +++ crates/maa-cli/src/config/migrate/wpf.rs | 88 +- 6 files changed, 310 insertions(+), 1220 deletions(-) delete mode 100644 crates/maa-cli/docs/zh-CN/migrate.md create mode 100644 crates/maa-cli/docs/zh-CN/wpf.md diff --git a/crates/maa-cli/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json index 32d22a30..2bdeccfd 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.json +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -23,6 +23,39 @@ "EnableTimesLimit": false, "TimesLimit": 2147483647, "Series": 0, + "StagePlan": [ + "Annihilation" + ], + "IsDrGrandet": false, + "UseExpiringMedicine": false, + "MedicineExpireDays": 2, + "UseExpireMedicineForActivity": false, + "UseCustomAnnihilation": true, + "AnnihilationStage": "LungmenDowntown@Annihilation", + "HideUnavailableStage": false, + "IsStageManually": false, + "UseOptionalStage": false, + "UseStoneAllowSave": false, + "HideSeries": false, + "StageResetMode": "Ignore", + "UseWeeklySchedule": false, + "Name": "剿灭", + "IsEnable": true, + "TaskType": "Fight" + }, + { + "$type": "FightTask", + "UseMedicine": false, + "MedicineCount": 999, + "UseStone": false, + "StoneCount": 1, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 9, + "IsInventoryTarget": false, + "EnableTimesLimit": false, + "TimesLimit": 2147483647, + "Series": 0, "StagePlan": [ "TO-5", "CE-6", @@ -45,6 +78,28 @@ "IsEnable": true, "TaskType": "Fight" }, + { + "$type": "RecruitTask", + "MaxTimes": 4, + "ExtraTagMode": 0, + "Level3PreferTags": [], + "PreferTagEnabled": true, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "Name": "", + "IsEnable": true, + "TaskType": "Recruit" + }, { "$type": "InfrastTask", "Mode": "Normal", @@ -93,28 +148,6 @@ "IsEnable": true, "TaskType": "Infrast" }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" - ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, { "$type": "MallTask", "Shopping": true, @@ -131,7 +164,7 @@ "OnlyBuyDiscount": false, "ReserveMaxCredit": false, "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": false, + "IsVisitFriendsAvailable": true, "Name": "", "IsEnable": true, "TaskType": "Mall" @@ -300,7 +333,7 @@ "EmulatorWaitSeconds": 60 }, "WindowTitlePrefix": "", - "PostActions": "ExitEmulator, ExitSelf", + "PostActions": "ExitArknights, ExitSelf", "AchievementPopupDisabled": false, "AchievementPopupAutoClose": false }, @@ -406,857 +439,7 @@ "SelectFormation": 1, "LoopTimes": 1 }, - "TaskSelectedIndex": 1 - }, - "Dev": { - "TaskQueue": [ - { - "$type": "StartUpTask", - "AccountName": "", - "AccountSwitchEnabled": false, - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "LS-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "CE-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "InfrastTask", - "Mode": "Normal", - "UsesOfDrones": "Money", - "DormThreshold": 30, - "DormTrustEnabled": true, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - "ContinueTraining": true, - "CustomFileType": "user_defined", - "Filename": "", - "PlanSelect": -1, - "RoomList": [ - { - "Room": "Mfg" - }, - { - "Room": "Trade" - }, - { - "Room": "Control" - }, - { - "Room": "Power" - }, - { - "Room": "Reception" - }, - { - "Room": "Office" - }, - { - "Room": "Dorm" - }, - { - "Room": "Processing" - }, - { - "Room": "Training" - } - ], - "Name": "", - "IsEnable": true, - "TaskType": "Infrast" - }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" - ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, - { - "$type": "MallTask", - "Shopping": true, - "CreditFight": false, - "CreditFightFormation": 0, - "CreditFightLastTime": "2026/06/08 00:00:00", - "CreditFightOnceADay": true, - "VisitFriends": true, - "VisitFriendsOnceADay": true, - "VisitFriendsLastTime": "2026/07/05 00:00:00", - "FirstList": "加急许可;招聘许可", - "BlackList": "碳;家具;", - "ShoppingIgnoreBlackListWhenFull": true, - "OnlyBuyDiscount": false, - "ReserveMaxCredit": false, - "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": true, - "Name": "", - "IsEnable": true, - "TaskType": "Mall" - }, - { - "$type": "AwardTask", - "Award": true, - "Mail": true, - "FreeGacha": false, - "Orundum": true, - "Mining": true, - "SpecialAccess": true, - "Name": "", - "IsEnable": true, - "TaskType": "Award" - }, - { - "$type": "RoguelikeTask", - "Theme": "JieGarden", - "Difficulty": 2147483647, - "Mode": "Investment", - "Squad": "指挥分队", - "SquadCollectible": "指挥分队", - "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", - "Investment": true, - "InvestCount": 999, - "InvestWithMoreScore": false, - "CollectibleStartAwards": "HotWater", - "CollectibleShopping": false, - "StartWithEliteTwo": false, - "StartWithEliteTwoOnly": false, - "ExpectedCollapsalParadigms": "", - "MonthlySquadAutoIterate": true, - "MonthlySquadCheckComms": true, - "DeepExplorationAutoIterate": true, - "FindPlaytimeTarget": "Ling", - "UseSupport": false, - "UseSupportNonFriend": false, - "RefreshTraderWithDice": false, - "SquadIsFoldartal": false, - "SamiFirstFloorFoldartal": false, - "SamiFirstFloorFoldartals": "", - "SamiNewSquad2StartingFoldartal": false, - "SamiNewSquad2StartingFoldartals": "", - "StopWhenDepositFull": true, - "StopAtFinalBoss": false, - "StopWhenLevelMax": false, - "StartWithSeed": false, - "Seed": "", - "Name": "", - "IsEnable": false, - "TaskType": "Roguelike" - }, - { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" - }, - { - "$type": "UserDataUpdateTask", - "UpdateOperBox": true, - "UpdateDepot": true, - "TriggerInterval": "EveryTime", - "IsTriggered": true, - "Name": "", - "IsEnable": true, - "TaskType": "UserDataUpdate" - } - ], - "Gui": { - "ConnectSettings": { - "AutoDetect": false, - "AlwaysAutoDetect": false, - "Config": "MuMuEmulator12", - "AdbPath": "C:\\Program Files\\Netease\\MuMuPlayer\\nx_main\\adb.exe", - "AdbReplaced": false, - "Address": "127.0.0.1:16384", - "AddressHistory": [ - "127.0.0.1:16384" - ], - "Extras": { - "LDPlayer": { - "EmulatorPath": "", - "ManualSetIndex": false, - "InstanceIndex": 0, - "IsEnabled": false - }, - "MuMuEmulator12": { - "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", - "EnableBridgeConnection": false, - "EnableTouch": false, - "InstanceIndex": 0, - "IsEnabled": true - }, - "Win32Extra": { - "ScreencapMethod": "FramePool", - "MouseMethod": "SendMessageWithCursorPos", - "KeyboardMethod": "SendMessage" - }, - "BluestacksExtra": { - "ConfigKeyword": "", - "ConfigPath": "" - } - }, - "AllowAdbRestart": true, - "AllowAdbHardRestart": true, - "TouchMode": "MiniTouch", - "EnableAdbLite": false, - "KillAdbOnExit": false - }, - "Performance": { - "UseGpu": true, - "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", - "GpuInstancePath": "", - "AllowDeprecatedGpu": false - }, - "ExternalNotification": { - "Configs": [], - "SendWhenComplete": true, - "ShowWhenCompleteWithDetails": false, - "SendWhenError": true, - "SendWhenStalled": false - }, - "RemoteControl": { - "RemoteControlGetTaskEndpointUri": "", - "RemoteControlReportStatusUri": "", - "RemoteControlUserIdentity": "", - "RemoteControlDeviceIdentity": "", - "RemoteControlPollIntervalMs": 1000 - }, - "RuntimeSettings": { - "ClientType": "Official", - "StartGame": true, - "DeployWithPause": false, - "AutoRestartOnDrop": true, - "RoguelikeDelayAbortUntilCombatComplete": false, - "PreRunScript": "", - "PostRunScript": "", - "ExecuteScriptOnCopilot": false, - "ExecuteScriptOnManualStop": false, - "BlockSleep": false, - "BlockSleepWithScreenOn": true, - "ReportToPenguin": true, - "PenguinId": "482853894", - "ReportToYituliu": true, - "EnableStallTimeout": true, - "StallTimeoutReminderIntervalMinutes": 30, - "StallTimeoutMinutes": 25 - }, - "StartUpSettings": { - "RunDirectly": false, - "SkipStartupAutoRunAfterUpdate": true, - "StartEmulator": false, - "RestartEmulatorWhenAdbFailed": false, - "EmulatorPath": "", - "EmulatorAddCommand": "", - "EmulatorWaitSeconds": 60 - }, - "WindowTitlePrefix": "", - "PostActions": "ExitEmulator, ExitSelf, Shutdown", - "AchievementPopupDisabled": false, - "AchievementPopupAutoClose": false - }, - "Toolbox": { - "OperBoxExportFormat": "Clipboard", - "GachaShowDisclaimerNoMore": false, - "PeepTargetFps": 20, - "ChooseLevel3": true, - "ChooseLevel3Time": 540, - "ChooseLevel4": true, - "ChooseLevel4Time": 540, - "ChooseLevel5": true, - "ChooseLevel6": true, - "AutoSetTime": true, - "ShowPotential": true - }, - "Copilot": { - "TaskList": [ - { - "Name": "CF-EX-2", - "FilePath": "config\\copilot\\CF-EX-2.json", - "CopilotId": 95705, - "IsRaid": false, - "IsChecked": true, - "Index": 0 - }, - { - "Name": "CF-EX-2", - "FilePath": "config\\copilot\\CF-EX-2.json", - "CopilotId": 95705, - "IsRaid": true, - "IsChecked": true, - "Index": 1 - } - ], - "EnableUserAdditional": false, - "UserAdditional": [], - "SupportMode": "Random", - "SelectFormation": 1, - "LoopTimes": 1 - }, - "TaskSelectedIndex": 1 - }, - "26/07/10 18:36:14": { - "TaskQueue": [ - { - "$type": "StartUpTask", - "AccountName": "", - "AccountSwitchEnabled": false, - "Name": "", - "IsEnable": true, - "TaskType": "StartUp" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "LS-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": true, - "Tuesday": false, - "Wednesday": true, - "Thursday": false, - "Friday": true, - "Saturday": false - }, - "Name": "日常经验本", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "FightTask", - "UseMedicine": false, - "MedicineCount": 0, - "UseStone": false, - "StoneCount": 0, - "EnableTargetDrop": false, - "DropId": "", - "DropCount": 0, - "IsInventoryTarget": false, - "EnableTimesLimit": false, - "TimesLimit": 2147483647, - "Series": 0, - "StagePlan": [ - "CE-6" - ], - "IsDrGrandet": false, - "UseExpiringMedicine": true, - "MedicineExpireDays": 2, - "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": false, - "AnnihilationStage": "Annihilation", - "HideUnavailableStage": false, - "IsStageManually": false, - "UseOptionalStage": false, - "UseStoneAllowSave": false, - "HideSeries": false, - "StageResetMode": "Ignore", - "UseWeeklySchedule": true, - "WeeklySchedule": { - "Sunday": false, - "Monday": false, - "Tuesday": true, - "Wednesday": false, - "Thursday": true, - "Friday": false, - "Saturday": true - }, - "Name": "日常龙门币", - "IsEnable": true, - "TaskType": "Fight" - }, - { - "$type": "InfrastTask", - "Mode": "Normal", - "UsesOfDrones": "Money", - "DormThreshold": 30, - "DormTrustEnabled": true, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - "ContinueTraining": true, - "CustomFileType": "user_defined", - "Filename": "", - "PlanSelect": -1, - "RoomList": [ - { - "Room": "Mfg" - }, - { - "Room": "Trade" - }, - { - "Room": "Control" - }, - { - "Room": "Power" - }, - { - "Room": "Reception" - }, - { - "Room": "Office" - }, - { - "Room": "Dorm" - }, - { - "Room": "Processing" - }, - { - "Room": "Training" - } - ], - "Name": "", - "IsEnable": true, - "TaskType": "Infrast" - }, - { - "$type": "RecruitTask", - "MaxTimes": 4, - "ExtraTagMode": 0, - "Level3PreferTags": [], - "PreferTagEnabled": true, - "PreserveTagList": [ - "支援机械" - ], - "PreserveTagEnabled": false, - "RefreshLevel3": true, - "ForceRefresh": true, - "Level3Choose": true, - "Level4Choose": true, - "Level5Choose": true, - "Level6Choose": false, - "Level3Time": 540, - "Level4Time": 540, - "Name": "", - "IsEnable": true, - "TaskType": "Recruit" - }, - { - "$type": "MallTask", - "Shopping": true, - "CreditFight": false, - "CreditFightFormation": 0, - "CreditFightLastTime": "2026/06/08 00:00:00", - "CreditFightOnceADay": true, - "VisitFriends": true, - "VisitFriendsOnceADay": true, - "VisitFriendsLastTime": "2026/07/10 00:00:00", - "FirstList": "加急许可;招聘许可", - "BlackList": "碳;家具;", - "ShoppingIgnoreBlackListWhenFull": true, - "OnlyBuyDiscount": false, - "ReserveMaxCredit": false, - "IsCreditFightAvailable": false, - "IsVisitFriendsAvailable": true, - "Name": "", - "IsEnable": true, - "TaskType": "Mall" - }, - { - "$type": "AwardTask", - "Award": true, - "Mail": true, - "FreeGacha": false, - "Orundum": true, - "Mining": true, - "SpecialAccess": true, - "Name": "", - "IsEnable": true, - "TaskType": "Award" - }, - { - "$type": "RoguelikeTask", - "Theme": "JieGarden", - "Difficulty": 2147483647, - "Mode": "Investment", - "Squad": "指挥分队", - "SquadCollectible": "指挥分队", - "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", - "Investment": true, - "InvestCount": 999, - "InvestWithMoreScore": false, - "CollectibleStartAwards": "HotWater", - "CollectibleShopping": false, - "StartWithEliteTwo": false, - "StartWithEliteTwoOnly": false, - "ExpectedCollapsalParadigms": "", - "MonthlySquadAutoIterate": true, - "MonthlySquadCheckComms": true, - "DeepExplorationAutoIterate": true, - "FindPlaytimeTarget": "Ling", - "UseSupport": false, - "UseSupportNonFriend": false, - "RefreshTraderWithDice": false, - "SquadIsFoldartal": false, - "SamiFirstFloorFoldartal": false, - "SamiFirstFloorFoldartals": "", - "SamiNewSquad2StartingFoldartal": false, - "SamiNewSquad2StartingFoldartals": "", - "StopWhenDepositFull": true, - "StopAtFinalBoss": false, - "StopWhenLevelMax": false, - "StartWithSeed": false, - "Seed": "", - "Name": "", - "IsEnable": false, - "TaskType": "Roguelike" - }, - { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" - }, - { - "$type": "UserDataUpdateTask", - "UpdateOperBox": true, - "UpdateDepot": true, - "TriggerInterval": "EveryTime", - "IsTriggered": true, - "Name": "", - "IsEnable": true, - "TaskType": "UserDataUpdate" - } - ], - "Gui": { - "ConnectSettings": { - "AutoDetect": false, - "AlwaysAutoDetect": false, - "Config": "MuMuEmulator12", - "AdbPath": "C:\\Program Files\\Netease\\MuMuPlayer\\nx_main\\adb.exe", - "AdbReplaced": false, - "Address": "127.0.0.1:16384", - "AddressHistory": [ - "127.0.0.1:16384" - ], - "Extras": { - "LDPlayer": { - "EmulatorPath": "", - "ManualSetIndex": false, - "InstanceIndex": 0, - "IsEnabled": false - }, - "MuMuEmulator12": { - "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", - "EnableBridgeConnection": false, - "EnableTouch": false, - "InstanceIndex": 0, - "IsEnabled": true - }, - "Win32Extra": { - "ScreencapMethod": "FramePool", - "MouseMethod": "SendMessageWithCursorPos", - "KeyboardMethod": "SendMessage" - }, - "BluestacksExtra": { - "ConfigKeyword": "", - "ConfigPath": "" - } - }, - "AllowAdbRestart": true, - "AllowAdbHardRestart": true, - "TouchMode": "MiniTouch", - "EnableAdbLite": false, - "KillAdbOnExit": false - }, - "Performance": { - "UseGpu": true, - "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", - "GpuInstancePath": "", - "AllowDeprecatedGpu": false - }, - "ExternalNotification": { - "Configs": [], - "SendWhenComplete": true, - "ShowWhenCompleteWithDetails": false, - "SendWhenError": true, - "SendWhenStalled": false - }, - "RemoteControl": { - "RemoteControlGetTaskEndpointUri": "", - "RemoteControlReportStatusUri": "", - "RemoteControlUserIdentity": "", - "RemoteControlDeviceIdentity": "", - "RemoteControlPollIntervalMs": 1000 - }, - "RuntimeSettings": { - "ClientType": "Official", - "StartGame": true, - "DeployWithPause": false, - "AutoRestartOnDrop": true, - "RoguelikeDelayAbortUntilCombatComplete": false, - "PreRunScript": "", - "PostRunScript": "", - "ExecuteScriptOnCopilot": false, - "ExecuteScriptOnManualStop": false, - "BlockSleep": false, - "BlockSleepWithScreenOn": true, - "ReportToPenguin": true, - "PenguinId": "482853894", - "ReportToYituliu": true, - "EnableStallTimeout": true, - "StallTimeoutReminderIntervalMinutes": 30, - "StallTimeoutMinutes": 25 - }, - "StartUpSettings": { - "RunDirectly": false, - "SkipStartupAutoRunAfterUpdate": true, - "StartEmulator": false, - "RestartEmulatorWhenAdbFailed": false, - "EmulatorPath": "", - "EmulatorAddCommand": "", - "EmulatorWaitSeconds": 60 - }, - "WindowTitlePrefix": "", - "PostActions": "ExitEmulator, ExitSelf", - "AchievementPopupDisabled": false, - "AchievementPopupAutoClose": false - }, - "Toolbox": { - "OperBoxExportFormat": "Clipboard", - "GachaShowDisclaimerNoMore": false, - "PeepTargetFps": 20, - "ChooseLevel3": true, - "ChooseLevel3Time": 540, - "ChooseLevel4": true, - "ChooseLevel4Time": 540, - "ChooseLevel5": true, - "ChooseLevel6": true, - "AutoSetTime": true, - "ShowPotential": true - }, - "Copilot": { - "TaskList": [ - { - "Name": "BD-4", - "FilePath": "config\\copilot\\BD-4.json", - "CopilotId": 97204, - "IsRaid": true, - "IsChecked": false, - "Index": 0 - }, - { - "Name": "BD-5", - "FilePath": "config\\copilot\\BD-5.json", - "CopilotId": 97216, - "IsRaid": false, - "IsChecked": false, - "Index": 1 - }, - { - "Name": "BD-5", - "FilePath": "config\\copilot\\BD-5.json", - "CopilotId": 97216, - "IsRaid": true, - "IsChecked": false, - "Index": 2 - }, - { - "Name": "BD-6", - "FilePath": "config\\copilot\\BD-6.json", - "CopilotId": 97218, - "IsRaid": false, - "IsChecked": false, - "Index": 3 - }, - { - "Name": "BD-6", - "FilePath": "config\\copilot\\BD-6.json", - "CopilotId": 97218, - "IsRaid": true, - "IsChecked": false, - "Index": 4 - }, - { - "Name": "BD-7", - "FilePath": "config\\copilot\\BD-7.json", - "CopilotId": 97219, - "IsRaid": false, - "IsChecked": false, - "Index": 5 - }, - { - "Name": "BD-7", - "FilePath": "config\\copilot\\BD-7.json", - "CopilotId": 97219, - "IsRaid": true, - "IsChecked": false, - "Index": 6 - }, - { - "Name": "BD-8", - "FilePath": "config\\copilot\\BD-8.json", - "CopilotId": 97224, - "IsRaid": false, - "IsChecked": false, - "Index": 7 - }, - { - "Name": "BD-8", - "FilePath": "config\\copilot\\BD-8.json", - "CopilotId": 97224, - "IsRaid": true, - "IsChecked": false, - "Index": 8 - }, - { - "Name": "BD-S-2", - "FilePath": "config\\copilot\\BD-S-2.json", - "CopilotId": 97238, - "IsRaid": false, - "IsChecked": false, - "Index": 9 - } - ], - "EnableUserAdditional": false, - "UserAdditional": [], - "SupportMode": "WhenNeeded", - "SelectFormation": 1, - "LoopTimes": 1 - }, - "TaskSelectedIndex": 3 + "TaskSelectedIndex": 2 } }, "Timers": { @@ -1325,7 +508,7 @@ "ConfigVersion": 1, "Current": "Default", "Update": { - "Name": "v6.16.3", + "Name": "v6.16.5", "UpdatePackage": "", "IsFirstBoot": false, "DoNotShowUpdate": false, @@ -1375,10 +558,10 @@ "Y": -1 }, "NormalPosition": { - "Left": 123, - "Top": 220, - "Right": 1563, - "Bottom": 1320 + "Left": 442, + "Top": 229, + "Right": 1882, + "Bottom": 1329 } }, "LoadWindowPlacement": true, diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml index 91183cc0..2f7e68f5 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.toml +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -6,8 +6,18 @@ client_type = "Official" start_game_enabled = true [[tasks]] +name = "剿灭" type = "Fight" + +[tasks.params] +stage = "Annihilation" + +[[tasks]] name = "理智作战" +type = "Fight" + +[tasks.params] +medicine_expire_days = 2 [[tasks.variants]] @@ -15,12 +25,12 @@ name = "理智作战" type = "OnSideStory" [tasks.variants.params] -medicine_expire_days = 2 stage = "TO-5" [[tasks.variants]] [tasks.variants.condition] +timezone = "Official" type = "Weekday" weekdays = [ "Tue", @@ -28,10 +38,8 @@ weekdays = [ "Sat", "Sun", ] -timezone = "Official" [tasks.variants.params] -medicine_expire_days = 2 stage = "CE-6" [[tasks.variants]] @@ -40,15 +48,37 @@ stage = "CE-6" type = "Always" [tasks.variants.params] -medicine_expire_days = 2 stage = "LS-6" +[[tasks]] +type = "Recruit" + +[tasks.params] +confirm = [ + 5, + 4, + 3, +] +extra_tags_mode = 0 +refresh = true +select = [ + 5, + 4, + 3, +] +times = 4 + +[tasks.params.recruitment_time] +3 = 540 +4 = 540 + [[tasks]] type = "Infrast" -name = "" [tasks.params] -mode = 0 +dorm_notstationed_enabled = true +dorm_trust_enabled = true +drones = "Money" facility = [ "Mfg", "Trade", @@ -60,103 +90,73 @@ facility = [ "Processing", "Training", ] -drones = "Money" -threshold = 0.3 -replenish = true -dorm_notstationed_enabled = true -dorm_trust_enabled = true -reception_message_board = true +mode = 0 reception_clue_exchange = true +reception_message_board = true reception_send_clue = true - -[[tasks]] -type = "Recruit" -name = "" - -[tasks.params] -times = 4 -extra_tags_mode = 0 -refresh = true -expedite = true -select = [ - 5, - 4, - 3, -] -confirm = [ - 5, - 4, - 3, -] - -[tasks.params.recruitment_time] -4 = 540 -3 = 540 +replenish = true +threshold = 0.3 [[tasks]] type = "Mall" -name = "" [tasks.params] -shopping = true -credit_fight = false -formation_index = 0 -visit_friends = true -buy_first = [ - "加急许可", - "招聘许可", -] blacklist = [ "碳", "家具", ] +buy_first = [ + "加急许可", + "招聘许可", +] +credit_fight = false force_shopping_if_credit_full = true +formation_index = 0 only_buy_discount = false reserve_max_credit = false +shopping = true +visit_friends = true [[tasks]] type = "Award" -name = "" [tasks.params] award = true mail = true -recruit = false -orundum = true mining = true +orundum = true +recruit = false specialaccess = true [[tasks]] type = "Roguelike" -name = "" [tasks.params] +core_char = "维什戴尔" enable = false -theme = "JieGarden" +investment_enabled = true +investment_with_more_score = false +investments_count = 999 mode = 1 -squad = "指挥分队" +only_start_with_elite_two = false +refresh_trader_with_dice = false roles = "稳扎稳打" -core_char = "维什戴尔" +squad = "指挥分队" +start_with_elite_two = false starts_count = 999999 -investment_enabled = true -investments_count = 999 -investment_with_more_score = false -stop_when_investment_full = true stop_at_final_boss = false stop_at_max_level = false -use_support = false +stop_when_investment_full = true +theme = "JieGarden" use_nonfriend_support = false -refresh_trader_with_dice = false -start_with_elite_two = false -only_start_with_elite_two = false +use_support = false [[tasks]] type = "Reclamation" -name = "" [tasks.params] enable = false -theme = "Tales" -mode = 1 increment_mode = 0 +mode = 1 num_craft_batches = 16 +theme = "Tales" diff --git a/crates/maa-cli/docs/zh-CN/intro.md b/crates/maa-cli/docs/zh-CN/intro.md index 2e4ec498..b3595a29 100644 --- a/crates/maa-cli/docs/zh-CN/intro.md +++ b/crates/maa-cli/docs/zh-CN/intro.md @@ -13,6 +13,7 @@ - [安装及编译](install.md) - [使用](usage.md) - [配置](config.md) +- [从 WPF GUI 迁移配置](wpf.md) - [常见问题](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights/ diff --git a/crates/maa-cli/docs/zh-CN/migrate.md b/crates/maa-cli/docs/zh-CN/migrate.md deleted file mode 100644 index 8f095c7f..00000000 --- a/crates/maa-cli/docs/zh-CN/migrate.md +++ /dev/null @@ -1,246 +0,0 @@ -# 配置迁移 - -`maa migrate` 用于把已有配置迁移到当前 maa-cli 所使用的配置形式。 - -不同来源对应不同的子命令。本文按迁移来源分节说明;目前仅完成 WPF GUI,其他来源会在后续补充。 - -## 目录 - -- [WPF GUI](#wpf-gui) -- [config-v1 到 config-v2](#config-v1-到-config-v2)(TODO) - -## WPF GUI - -把 MAA WPF GUI 的配置迁移为 maa-cli 任务配置: - -```bash -maa migrate wpf [output] [-f toml|yaml|json] [--config ] -``` - -`` 应指向 GUI 导出的配置文件: - -| 平台 | 输入文件 | -| --- | --- | -| Windows | `config/gui.new.json`(相对 MAA 安装或用户配置目录;以你本机实际路径为准) | -| Linux | TODO:待补充路径与测试 | -| macOS | TODO:待补充路径与测试 | - -如果文件中有多套配置,可用 `--config` 或环境变量 `MAA_GUI_CONFIG` 指定名称;未指定时会交互选择,默认使用文件中的当前配置(`Current`)。 - -迁移可能不完整:不支持的任务类型会被跳过;GUI 中已关闭的任务(`IsEnable = false`)会保留,并写入 `params.enable = false`;未能对应的参数会出现在迁移摘要中。命令结束后会输出这些汇总,方便你对照检查。 - -### StartUp - -对应 GUI 任务队列中的启动任务,迁移为 maa-cli 的 `StartUp` 任务。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Configurations.<配置名>.Gui.RuntimeSettings.ClientType` | `tasks[].params.client_type` | 合法值(`Official` / `Bilibili` / `txwy` / `YoStarEN` / `YoStarJP` / `YoStarKR`)直接写入;缺失或不合法时将写入备选值,备选值将在 maa-cli 运行时交互确认最终值 | -| `Configurations.<配置名>.Gui.RuntimeSettings.StartGame` | `tasks[].params.start_game_enabled` | 有值时写入 | -| `Configurations.<配置名>.TaskQueue[] \| {AccountSwitchEnabled, AccountName}` | `tasks[].params.account_name` | 仅当 GUI 配置 `AccountSwitchEnabled` 为 `true` 时读取 `AccountName` 并尝试写入迁移配置 `account_name` | - -TODO:`account_name` 的转换**我没有测试过**(未做实机账号切换验证)。 - -### Fight - -对应 GUI 任务队列中的战斗任务,迁移为 maa-cli 的 `Fight` 任务。 -`Name` 会写入任务的 `name`;其余公共参数写入 `params`(若存在 `variants`,则写入每个变体的 `params`)。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `IsEnable` | `params.enable` | 为 `false` 时写入 `enable = false`(MaaCore 通用开关;默认不写,等同于 `true`) | -| `UseMedicine` + `MedicineCount` | `params.medicine` | 仅当 `UseMedicine` 为 `true` 时写入 `MedicineCount` | -| `UseStone` + `StoneCount` | `params.stone` | 仅当 `UseStone` 为 `true` 时写入 `StoneCount`;迁移时会警告该设置可能消耗源石 | -| `EnableTimesLimit` + `TimesLimit` | `params.times` | 仅当 `EnableTimesLimit` 为 `true` 时写入 `TimesLimit` | -| `EnableTargetDrop` + `DropId` + `DropCount` | `params.drops` | 仅当 `EnableTargetDrop` 为 `true` 时写入,格式为 `{ = }` | -| `Series` | `params.series` | 仅当值不为 `0` 时写入 | -| `UseExpiringMedicine` + `MedicineExpireDays` | `params.medicine_expire_days` | 仅当 `UseExpiringMedicine` 为 `true` 时写入 `MedicineExpireDays` | -| `StagePlan` | `params.stage` | 见下方「关卡与变体」 | - -#### 关卡与变体 - -关卡与条件变体由 `UseWeeklySchedule` 与 `UseOptionalStage` 共同决定。 - -| `UseWeeklySchedule` | `UseOptionalStage` | `StagePlan` 要求 | 输出结构 | -| --- | --- | --- | --- | -| `false` | `false` | 单个关卡(字符串,或仅含一个字符串的数组) | 无 `variants`,关卡写入顶层 `params.stage` | -| `true` | `false` | 同上 | 一个 `variants`;`condition` 为周计划对应的 `Weekday`;关卡写入该变体的 `params.stage` | -| `false` | `true` | 非空字符串数组 | 每个关卡一个 `variants`;`condition` 由关卡开放条件决定 | -| `true` | `true` | 非空字符串数组 | 每个关卡一个 `variants`;`condition` 为 `And`,依次合并周计划与关卡开放条件 | - -周计划来自 `WeeklySchedule`:GUI 中为 `true` 的星期会写入 `condition = { type = "Weekday", weekdays = [...] }`(如 `Sun` / `Mon` / … / `Sat`)。 -若 `UseWeeklySchedule` 为 `true` 但缺少有效的 `WeeklySchedule`,迁移会报错退出。 -若 `UseOptionalStage` 为 `false` 但 `StagePlan` 含多个关卡,迁移会报错退出。 - -启用 `UseOptionalStage` 时,每个关卡的开放条件大致如下: - -| 关卡 | 条件 | -| --- | --- | -| `CE-6` | `Weekday`:`Tue` / `Thu` / `Sat` / `Sun`(`timezone = "Official"`) | -| `AP-5` | `Weekday`:`Mon` / `Thu` / `Sat` / `Sun`(`timezone = "Official"`) | -| `CA-5` | `Weekday`:`Tue` / `Wed` / `Fri` / `Sun`(`timezone = "Official"`) | -| `SK-5` | `Weekday`:`Mon` / `Wed` / `Fri` / `Sat`(`timezone = "Official"`) | -| `PR-A-1` / `PR-A-2` | `Weekday`:`Mon` / `Thu` / `Fri` / `Sun`(`timezone = "Official"`) | -| `PR-B-1` / `PR-B-2` | `Weekday`:`Mon` / `Tue` / `Fri` / `Sat`(`timezone = "Official"`) | -| `PR-C-1` / `PR-C-2` | `Weekday`:`Wed` / `Thu` / `Sat` / `Sun`(`timezone = "Official"`) | -| `PR-D-1` / `PR-D-2` | `Weekday`:`Tue` / `Wed` / `Sat` / `Sun`(`timezone = "Official"`) | -| `LS-6` / `Annihilation` / `OF-1` / `OF-F3` | `Always` | -| 热更新 `StageActivityV2.json` 中的侧边故事关卡 | `OnSideStory`(TODO: 暂时只按官服活动表判断) | -| 其他未识别关卡(如主线) | `Always`,并输出警告 | - -示例:仅启用可选关卡、刷 `CE-6` 与 `LS-6` 时,迁移结果类似: - -```toml -[[tasks]] -type = "Fight" -name = "理智作战" - -[[tasks.variants]] -condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } -params = { medicine_expire_days = 2, stage = "CE-6" } - -[[tasks.variants]] -condition = { type = "Always" } -params = { medicine_expire_days = 2, stage = "LS-6" } -``` - -### Infrast - -对应 GUI 任务队列中的基建换班任务,迁移为 maa-cli 的 `Infrast` 任务。 -`Name` 会写入任务的 `name`;其余字段写入 `params`。 - -自定义基建**暂不支持**:若 `Mode` 为 `Custom`,或 `Filename` 非空,迁移会报错退出。 -`PlanSelect` 不会写入迁移结果(默认值会被忽略;自定义计划本身亦不可用)。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `Mode` | `params.mode` | `Normal` → `0`;`Rotation` → `20000`;`Custom` → 报错;其他未知值记入迁移摘要 | -| `RoomList[].Room` | `params.facility` | 按顺序收集房间名数组(如 `Mfg` / `Trade` / `Dorm`) | -| `UsesOfDrones` | `params.drones` | 有值时写入(如 `Money`) | -| `DormThreshold` | `params.threshold` | GUI 为 0–100 的整数百分比,写入时除以 `100` 转为浮点(如 `30` → `0.3`) | -| `OriginiumShardAutoReplenishment` | `params.replenish` | 有值时写入 | -| `DormFilterNotStationed` | `params.dorm_notstationed_enabled` | 有值时写入 | -| `DormTrustEnabled` | `params.dorm_trust_enabled` | 有值时写入 | -| `ReceptionMessageBoard` | `params.reception_message_board` | 有值时写入 | -| `ReceptionClueExchange` | `params.reception_clue_exchange` | 有值时写入 | -| `SendClue` | `params.reception_send_clue` | 有值时写入 | - -示例: - -```toml -[[tasks]] -type = "Infrast" -name = "" - -[tasks.params] -mode = 0 -facility = ["Mfg", "Trade", "Control", "Power", "Reception", "Office", "Dorm", "Processing", "Training"] -drones = "Money" -threshold = 0.3 -replenish = true -dorm_notstationed_enabled = true -dorm_trust_enabled = true -reception_message_board = true -reception_clue_exchange = true -reception_send_clue = true -``` - -### Recruit - -对应 GUI 任务队列中的公开招募任务,迁移为 maa-cli 的 `Recruit` 任务。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `MaxTimes` | `params.times` | 有值时写入 | -| `ExtraTagMode` | `params.extra_tags_mode` | 有值时写入 | -| `RefreshLevel3` | `params.refresh` | 有值时写入 | -| `ForceRefresh` | `params.expedite` | 有值时写入 | -| `Level3Choose` / `Level4Choose` / `Level5Choose` / `Level6Choose` | `params.select` / `params.confirm` | 为 `true` 的星级会同时写入 `select` 与 `confirm`(如 `3` / `4` / `5`) | -| `Level3Time` / `Level4Time` / `Level5Time` / `Level6Time` | `params.recruitment_time` | 有值时写入对应星级的招募时长(分钟),如 `{ "3" = 540, "4" = 540 }` | -| `PreferTagEnabled` + `Level3PreferTags` | `params.first_tags` | 仅当 `PreferTagEnabled` 为 `true` 且标签列表非空时写入 | -| `PreserveTagEnabled` + `PreserveTagList` | `params.preserve_tags` | 仅当 `PreserveTagEnabled` 为 `true` 且标签列表非空时写入 | - -### Mall - -对应 GUI 任务队列中的信用商店任务,迁移为 maa-cli 的 `Mall` 任务。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `Shopping` | `params.shopping` | 有值时写入 | -| `CreditFight` | `params.credit_fight` | 有值时写入 | -| `CreditFightFormation` | `params.formation_index` | 有值时写入 | -| `VisitFriends` | `params.visit_friends` | 有值时写入 | -| `FirstList` | `params.buy_first` | 以 `;` 分隔的字符串拆成数组(空段丢弃) | -| `BlackList` | `params.blacklist` | 同上 | -| `ShoppingIgnoreBlackListWhenFull` | `params.force_shopping_if_credit_full` | 有值时写入 | -| `OnlyBuyDiscount` | `params.only_buy_discount` | 有值时写入 | -| `ReserveMaxCredit` | `params.reserve_max_credit` | 有值时写入 | - -`CreditFightOnceADay` / `VisitFriendsOnceADay` 及对应的上次执行时间等字段目前不会迁移,有实际取值时会出现在迁移摘要中。 - -### Award - -对应 GUI 任务队列中的领取奖励任务,迁移为 maa-cli 的 `Award` 任务。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `Award` | `params.award` | 日常任务奖励 | -| `Mail` | `params.mail` | 邮件 | -| `FreeGacha` | `params.recruit` | 免费十连 | -| `Orundum` | `params.orundum` | 合成玉 | -| `Mining` | `params.mining` | 限时开采 | -| `SpecialAccess` | `params.specialaccess` | 月卡等特殊访问 | - -上述布尔字段有值时原样写入。 - -### Roguelike - -对应 GUI 任务队列中的集成战略任务,迁移为 maa-cli 的 `Roguelike` 任务。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `Theme` | `params.theme` | 有值时写入(如 `JieGarden`) | -| `Mode` | `params.mode` | `Exp` → `0`;`Investment` → `1`;`Collect` → `4`;`CollapsalParadigms` → `5`;`MonthlySquad` → `6`;`DeepExploration` → `7`;未知值记入迁移摘要 | -| `Squad` | `params.squad` | 有值时写入 | -| `Roles` | `params.roles` | 有值时写入 | -| `CoreChar` | `params.core_char` | 有值时写入 | -| `StartCount` | `params.starts_count` | 有值时写入 | -| `Difficulty` | `params.difficulty` | 有值且不为 `2147483647`(`i32::MAX`,表示未指定)时写入 | -| `Investment` | `params.investment_enabled` | 有值时写入 | -| `InvestCount` | `params.investments_count` | 有值时写入 | -| `InvestWithMoreScore` | `params.investment_with_more_score` | 有值时写入 | -| `StopWhenDepositFull` | `params.stop_when_investment_full` | 有值时写入 | -| `StopAtFinalBoss` | `params.stop_at_final_boss` | 有值时写入 | -| `StopWhenLevelMax` | `params.stop_at_max_level` | 有值时写入 | -| `UseSupport` | `params.use_support` | 有值时写入 | -| `UseSupportNonFriend` | `params.use_nonfriend_support` | 有值时写入 | -| `RefreshTraderWithDice` | `params.refresh_trader_with_dice` | 有值时写入 | -| `StartWithEliteTwo` | `params.start_with_elite_two` | 有值时写入 | -| `StartWithEliteTwoOnly` | `params.only_start_with_elite_two` | 有值时写入 | - -主题专属字段(如萨米密文板、月度分队自动迭代、种子开局等)目前不会迁移,有实际取值时会出现在迁移摘要中。 - -### Reclamation - -对应 GUI 任务队列中的生息演算任务,迁移为 maa-cli 的 `Reclamation` 任务。 - -| GUI 字段 | maa-cli 字段 | 规则 | -| --- | --- | --- | -| `Name` | `name` | 有值时写入 | -| `Theme` | `params.theme` | 有值时写入(如 `Tales`) | -| `Mode` | `params.mode` | `ProsperityNoSave` → `0`;`ProsperityInSave` → `1`;未知值记入迁移摘要 | -| `ToolToCraft` | `params.tools_to_craft` | 非空时写入单元素数组 | -| `IncrementMode` | `params.increment_mode` | 有值时写入 | -| `MaxCraftCountPerRound` | `params.num_craft_batches` | 有值时写入 | - -其余未映射字段(如 `ClearStore`)有实际取值时会出现在迁移摘要中。 - -## config-v1 到 config-v2 - -TODO diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md new file mode 100644 index 00000000..7f733443 --- /dev/null +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -0,0 +1,126 @@ +# 从 WPF GUI 迁移配置 + +`maa migrate wpf` 可以把 MAA Windows WPF GUI 导出的配置文件,转换为 maa-cli 的自定义任务配置。 +迁移是有损的:不支持的任务类型与字段会被跳过,并在结束后输出摘要;转换完成后,你通常还需要对照 [配置文档](config.md) 做一次检查与微调。 + +## 使用方法 + +输入一般是 GUI 导出的 JSON 配置文件(包含 `Configurations` / `TaskQueue`)。 + +```bash +# 输出默认写到与输入同名的 .toml 文件 +maa migrate wpf gui.new.json + +# 指定输出路径与格式(由扩展名决定,支持 toml / yaml / json) +maa migrate wpf gui.new.json tasks/daily.toml + +# 多配置导出时,用 --config 选择配置名 +maa migrate wpf gui.new.json --config Default +``` + +如果导出文件里只有一个配置,会自动选择该配置;有多个配置且未指定 `--config` 时,会交互式提示你选择。 + +迁移完成后,把生成的任务文件放到 `$MAA_CONFIG_DIR/tasks` 中,即可通过 `maa run ` 运行。配置目录可用 `maa dir config` 查看。 + +## 迁移结果 + +输出是一份 maa-cli 自定义任务文件,结构与 [自定义任务](config.md#自定义任务) 相同:顶层是 `[[tasks]]` 列表,每个任务包含 `type`、`name`、`params`,必要时还有 `variants`。 + +GUI 里 `IsEnable = false` 的任务仍会写出,但会带上 `params.enable = false`,因此默认不会执行;需要时再手动改回 `true` 或删掉该字段。 + +示例输入与输出可以参考仓库中的 [`config_examples/wpf`](../../config_examples/wpf)。 + +## 任务映射说明 + +### 开始唤醒(StartUp) + +`client_type` 与 `start_game_enabled` 来自 GUI 的 `Gui.RuntimeSettings`,而不是 `StartUpTask` 本体。 +如果 GUI 未填写可识别的客户端类型,迁移结果会把 `client_type` 写成交互式选项,运行任务时再选择。 +若开启了账号切换但未填写账号名,该字段会被跳过并给出警告。 + +### 理智作战(Fight) + +药品、源石、次数、掉落、连战、过期理智药等共享参数写在任务级 `params` 中;关卡相关内容写在变体里。 + +当 GUI 开启 `UseOptionalStage` 时,`StagePlan` 中的每个关卡会生成一个变体,并按关卡开放规则附上条件: + +- 资源本 / 芯片本:`Weekday`(官服服务器时区) +- 永久关卡(如主线、剿灭、`LS-6`):`Always` +- 活动关卡:`OnSideStory` + +例如 GUI 中同时启用过期理智药与多关卡备选时,迁移结果类似: + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[tasks.params] +medicine_expire_days = 2 + +[[tasks.variants]] +condition = { type = "OnSideStory" } +params = { stage = "TO-5" } + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { stage = "LS-6" } +``` + +这里的 `medicine_expire_days` 对应 GUI 的 `MedicineExpireDays`(在 `UseExpiringMedicine = true` 时写入),表示使用多少天内将过期的理智药。 +若同时启用 `UseWeeklySchedule`,周计划会与关卡开放条件用 `And` 组合。 + +**注意**:启用源石(`UseStone`)时会发出警告,因为这可能消耗源石。 + +### 公开招募(Recruit) + +常见字段会映射为 maa-cli / MaaCore 参数,例如: + +- `MaxTimes` → `times` +- `RefreshLevel3` → `refresh` +- `ExtraTagMode` → `extra_tags_mode` +- `LevelNChoose` / `LevelNTime` → `select` / `confirm` / `recruitment_time` +- 偏好标签、保留标签 → `first_tags` / `preserve_tags` + +以下行为需要特别注意: + +- GUI 的 `ForceRefresh` **不是** MaaCore 的 `expedite`,maa-cli 也不支持该功能。迁移时不会写出对应参数;若 GUI 中为开启状态,会记入跳过字段并警告,该设置无效。 +- 若 `Level6Choose = true`,会警告即将自动确认 6 星干员,这是危险行为。 + +### 基建换班(Infrast) + +普通模式(`Normal` / `Rotation`)会迁移设施顺序、无人机用途、宿舍阈值等常见参数。 +自定义基建计划(`Mode = Custom` 或填写了 `Filename`)目前不支持,迁移会直接报错。 + +### 其他任务 + +- `Mall`:信用商店、信用作战、访问好友、黑白名单等会映射到对应参数。 +- `Award`:日常奖励、邮件、源石锭等;GUI 的 `FreeGacha` 对应 `recruit`。 +- `Roguelike` / `Reclamation`:主题、模式与常见选项会迁移;无法识别的模式字段会记入跳过摘要。 +- 未识别的 `$type` 会整项跳过,并出现在迁移摘要的「跳过任务」列表中。 + +各任务类型的完整参数含义请参考 [MAA 集成文档][task-types]。迁移后若要继续用条件变体精细控制执行逻辑,请参阅 [任务条件](config.md#任务条件)。 + +## 迁移摘要 + +迁移结束时,若存在有损处理,会在 stderr 打印摘要,例如: + +- 跳过的任务(不支持的 `$type`) +- 已禁用但仍写出的任务(`params.enable = false`) +- 跳过的字段(无法映射或 maa-cli 不支持) + +请根据摘要检查输出文件,确认行为符合预期后再用于日常任务。 + +## 参考 + +- [配置](config.md) +- [使用](usage.md) +- [示例:WPF 迁移][wpf-example] +- [MAA 集成文档:任务类型][task-types] + +[wpf-example]: ../../config_examples/wpf +[task-types]: https://maa.plus/docs/zh-cn/protocol/integration.html#任务类型一览 diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index f77c8a3e..a8ed92d6 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -281,7 +281,7 @@ mod start_up { use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; - use super::{WpfGuiSettings, MigrationSummary, serialize_to_maa_value}; + use super::{MigrationSummary, WpfGuiSettings, serialize_to_maa_value}; use crate::config::task::ClientType; /// maa-cli StartUp task shape written by migration. @@ -438,6 +438,26 @@ mod fight { enable: Option, } + impl CliFightParams { + fn is_empty(&self) -> bool { + self.medicine.is_none() + && self.stone.is_none() + && self.times.is_none() + && self.drops.is_none() + && self.series.is_none() + && self.medicine_expire_days.is_none() + && self.stage.is_none() + && self.enable.is_none() + } + + fn stage_only(stage: &str) -> Self { + Self { + stage: Some(stage.to_string()), + ..Self::default() + } + } + } + /// WPF GUI `FightTask` (`$type = "FightTask"`). /// /// Deserialized from the GUI task queue; converts into [`CliFightTask`] / @@ -652,6 +672,8 @@ mod fight { type Error = anyhow::Error; fn try_from(task: &WpfFightTask) -> Result { + // Shared fight options (medicine, stone, expire days, ...) belong on the task. + // Variants only carry stage selection / open-condition params. let shared_params = CliFightParams::from(task); let mut item = match (task.use_weekly_schedule, task.use_optional_stage) { @@ -675,34 +697,30 @@ mod fight { "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", )?, ); - let mut params = shared_params; - params.stage = Some(stage.to_string()); CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), - params: None, + params: (!shared_params.is_empty()).then_some(shared_params), variants: vec![CliFightVariant { condition: weekly, - params, + params: CliFightParams::stage_only(stage), }], } } // One variant per optional stage, each with its own open-condition. (false, true) => { let stages: Vec<&str> = (&task.stage_plan).try_into()?; - let mut variants = Vec::with_capacity(stages.len()); - for stage in stages { - let mut params = shared_params.clone(); - params.stage = Some(stage.to_string()); - variants.push(CliFightVariant { + let variants = stages + .into_iter() + .map(|stage| CliFightVariant { condition: WpfFightTask::stage_condition(stage), - params, - }); - } + params: CliFightParams::stage_only(stage), + }) + .collect(); CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), - params: None, + params: (!shared_params.is_empty()).then_some(shared_params), variants, } } @@ -714,21 +732,22 @@ mod fight { "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", )?, ); - let mut variants = Vec::with_capacity(stages.len()); - for stage in stages { - let mut params = shared_params.clone(); - params.stage = Some(stage.to_string()); - variants.push(CliFightVariant { + let variants = stages + .into_iter() + .map(|stage| CliFightVariant { condition: Condition::And { - conditions: vec![weekly.clone(), WpfFightTask::stage_condition(stage)], + conditions: vec![ + weekly.clone(), + WpfFightTask::stage_condition(stage), + ], }, - params, - }); - } + params: CliFightParams::stage_only(stage), + }) + .collect(); CliFightTask { task_type: TaskType::Fight, name: task.name.clone(), - params: None, + params: (!shared_params.is_empty()).then_some(shared_params), variants, } } @@ -760,7 +779,6 @@ mod fight { } } - mod infrast { use anyhow::{Result, bail}; use maa_types::TaskType; @@ -912,6 +930,7 @@ mod infrast { mod recruit { use anyhow::Result; + use log::warn; use maa_types::TaskType; use maa_value::prelude::*; use serde::{Deserialize, Serialize}; @@ -955,8 +974,6 @@ mod recruit { extra_tags_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] refresh: Option, - #[serde(skip_serializing_if = "Option::is_none")] - expedite: Option, #[serde(skip_serializing_if = "Vec::is_empty")] select: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] @@ -1008,6 +1025,18 @@ mod recruit { if !self.is_enable { summary.disable_task("RecruitTask", Some(self.name.clone())); } + // GUI ForceRefresh is not maa-cli/MaaCore `expedite`; maa-cli does not support it. + if self.force_refresh { + summary.skip_field("RecruitTask", Some(self.name.clone()), "ForceRefresh"); + warn!( + "RecruitTask ForceRefresh is not supported by maa-cli; this setting has no effect" + ); + } + if self.level6_choose { + warn!( + "RecruitTask Level6Choose is enabled; recruitment will auto-confirm 6★ operators (dangerous)" + ); + } report_unknown_fields( summary, "RecruitTask", @@ -1058,7 +1087,6 @@ mod recruit { times: Some(task.max_times), extra_tags_mode: Some(task.extra_tag_mode), refresh: Some(task.refresh_level3), - expedite: Some(task.force_refresh), select, confirm, recruitment_time: (!recruitment_time.is_empty()).then_some(recruitment_time), @@ -1078,9 +1106,7 @@ mod mall { use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; - use super::{ - MigrationSummary, report_unknown_fields, serialize_to_maa_value, split_semi_list, - }; + use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value, split_semi_list}; const HANDLED: &[&str] = &[ "Shopping", From f7c0f6027fa6d5d86eca6d293fd31a98591e2538 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Tue, 11 Aug 2026 14:55:06 +0800 Subject: [PATCH 17/20] =?UTF-8?q?=F0=9F=94=A7=20fix:=20update=20WPF=20figh?= =?UTF-8?q?t=20migration=20to=20use=20non-optional=20series=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the series field in the fight configuration from an optional type to a required i32 --- crates/maa-cli/src/config/migrate/wpf.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index a8ed92d6..e1372728 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -428,8 +428,7 @@ mod fight { times: Option, #[serde(skip_serializing_if = "Option::is_none")] drops: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - series: Option, + series: i32, #[serde(skip_serializing_if = "Option::is_none")] medicine_expire_days: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -444,7 +443,7 @@ mod fight { && self.stone.is_none() && self.times.is_none() && self.drops.is_none() - && self.series.is_none() + && self.series == i32::default() && self.medicine_expire_days.is_none() && self.stage.is_none() && self.enable.is_none() @@ -595,7 +594,7 @@ mod fight { drops.insert(task.drop_id.clone(), task.drop_count); drops }), - series: (task.series != 0).then_some(task.series), + series: task.series, medicine_expire_days: task .use_expiring_medicine .then_some(task.medicine_expire_days), From 4603823bf49d9a1475f093643abd162107df13d1 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Wed, 19 Aug 2026 03:23:33 +0800 Subject: [PATCH 18/20] =?UTF-8?q?=E2=9C=A8=20feat:=20migrate=20WPF=20custo?= =?UTF-8?q?m=20infrast=20schedules=20Read=20the=20schedule=20JSON=20during?= =?UTF-8?q?=20`maa=20migrate=20wpf`=20and=20emit=20plan=5Findex=20variants?= =?UTF-8?q?.=20Add=20`--custom-schedule`=20to=20override=20Filename=20when?= =?UTF-8?q?=20the=20profile=20is=20already=20custom.=20Expand=20`~`=20in?= =?UTF-8?q?=20schedule=20paths;=20fail=20if=20the=20override=20is=20set=20?= =?UTF-8?q?on=20a=20non-custom=20profile.=20=E4=BB=8E=20WPF=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=87=AA=E5=AE=9A=E4=B9=89=E5=9F=BA=E5=BB=BA=E6=8E=92?= =?UTF-8?q?=E7=8F=AD=EF=BC=8C=E6=94=AF=E6=8C=81=20--custom-schedule=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=8E=92=E7=8F=AD=E6=96=87=E4=BB=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maa-cli/config_examples/wpf/gui.new.json | 4 +- .../maa-cli/config_examples/wpf/gui.new.toml | 30 +- crates/maa-cli/docs/zh-CN/wpf.md | 13 +- crates/maa-cli/src/command.rs | 24 + crates/maa-cli/src/config/migrate/wpf.rs | 747 +++++++++++++++++- crates/maa-cli/src/main.rs | 8 +- 6 files changed, 787 insertions(+), 39 deletions(-) diff --git a/crates/maa-cli/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json index 2bdeccfd..bb9f7d6b 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.json +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -102,7 +102,7 @@ }, { "$type": "InfrastTask", - "Mode": "Normal", + "Mode": "Custom", "UsesOfDrones": "Money", "DormThreshold": 30, "DormTrustEnabled": true, @@ -113,7 +113,7 @@ "SendClue": true, "ContinueTraining": true, "CustomFileType": "user_defined", - "Filename": "", + "Filename": "~/一图流-153-一天两换-MAA.json", "PlanSelect": -1, "RoomList": [ { diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml index 2f7e68f5..3b5a731a 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.toml +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -10,6 +10,7 @@ name = "剿灭" type = "Fight" [tasks.params] +series = 0 stage = "Annihilation" [[tasks]] @@ -18,6 +19,7 @@ type = "Fight" [tasks.params] medicine_expire_days = 2 +series = 0 [[tasks.variants]] @@ -25,6 +27,7 @@ medicine_expire_days = 2 type = "OnSideStory" [tasks.variants.params] +series = 0 stage = "TO-5" [[tasks.variants]] @@ -40,6 +43,7 @@ weekdays = [ ] [tasks.variants.params] +series = 0 stage = "CE-6" [[tasks.variants]] @@ -48,6 +52,7 @@ stage = "CE-6" type = "Always" [tasks.variants.params] +series = 0 stage = "LS-6" [[tasks]] @@ -76,9 +81,9 @@ times = 4 type = "Infrast" [tasks.params] +continue_training = true dorm_notstationed_enabled = true dorm_trust_enabled = true -drones = "Money" facility = [ "Mfg", "Trade", @@ -90,13 +95,34 @@ facility = [ "Processing", "Training", ] -mode = 0 +filename = "/Users/nan/一图流-153-一天两换-MAA.json" +mode = 10000 reception_clue_exchange = true reception_message_board = true reception_send_clue = true replenish = true threshold = 0.3 +[[tasks.variants]] + +[tasks.variants.condition] +end = "21:00:00" +start = "09:00:00" +type = "Time" + +[tasks.variants.params] +plan_index = 0 + +[[tasks.variants]] + +[tasks.variants.condition] +end = "09:00:00" +start = "21:00:00" +type = "Time" + +[tasks.variants.params] +plan_index = 1 + [[tasks]] type = "Mall" diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md index 7f733443..c6dee386 100644 --- a/crates/maa-cli/docs/zh-CN/wpf.md +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -16,10 +16,15 @@ maa migrate wpf gui.new.json tasks/daily.toml # 多配置导出时,用 --config 选择配置名 maa migrate wpf gui.new.json --config Default + +# GUI 是自定义排班时,用本机排班 JSON 覆盖其中的 Filename +maa migrate wpf gui.new.json --custom-schedule ~/一图流-153-一天两换-MAA.json ``` 如果导出文件里只有一个配置,会自动选择该配置;有多个配置且未指定 `--config` 时,会交互式提示你选择。 +`--custom-schedule` 只在 GUI 基建任务已经是自定义排班(`Mode = Custom` 或填写了 `Filename`)时生效,用来覆盖排班文件路径。若 GUI 不是自定义排班,传入该选项会报错退出。 + 迁移完成后,把生成的任务文件放到 `$MAA_CONFIG_DIR/tasks` 中,即可通过 `maa run ` 运行。配置目录可用 `maa dir config` 查看。 ## 迁移结果 @@ -94,7 +99,13 @@ params = { stage = "LS-6" } ### 基建换班(Infrast) 普通模式(`Normal` / `Rotation`)会迁移设施顺序、无人机用途、宿舍阈值等常见参数。 -自定义基建计划(`Mode = Custom` 或填写了 `Filename`)目前不支持,迁移会直接报错。 +自定义基建计划(`Mode = Custom` 或填写了 `Filename`)会尝试读取排班 JSON: + +- 读到 `plans` 且各班有 `period`、GUI 为时间轮换(`PlanSelect = -1`)时,写出 `mode = 10000`、`filename`,以及按时段(跨天时加上 `DayMod`)选择 `plan_index` 的变体。 +- 读到排班但没有可用时段(或 GUI 选了固定班次)时,写出自定义模式并带上 `plan_index`。 +- `Filename` 里的 `~` 会展开为用户主目录;写出的 `filename` 是展开后的绝对路径。 +- 可用 `--custom-schedule ` 覆盖 GUI 中的排班文件路径;仅当 GUI 已是自定义排班时有效,否则报错退出。覆盖后若文件读失败,也会报错退出,而不会回退到默认基建。 +- 未指定 `--custom-schedule` 时,文件不存在、无法解析或没有 `plans` 会跳过 `Mode` / `Filename`,按默认基建模式(`mode = 0`)写出其余参数,并在迁移摘要中说明。 ### 其他任务 diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index a7aa8f63..d4ae69b7 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -272,6 +272,12 @@ pub(crate) enum MigrateCommand { /// Select a named configuration when migrating a multi-profile GUI export #[arg(long)] config: Option, + /// Override the custom infrastructure schedule JSON path in the WPF profile + /// + /// The WPF InfrastTask must already use custom scheduling (`Mode = Custom` + /// or a non-empty `Filename`). Otherwise the command exits with an error. + #[arg(long, value_name = "JSON")] + custom_schedule: Option, }, } @@ -618,6 +624,7 @@ mod test { input, output: None, config: None, + custom_schedule: None, }) if input == Path::new("profile.json") ); @@ -636,10 +643,27 @@ mod test { input, output: Some(output), config: Some(config), + custom_schedule: None, }) if input == Path::new("profile.json") && output == Path::new("tasks.toml") && config == "Default" ); + + assert_matches!( + parse_from([ + "maa", + "migrate", + "wpf", + "profile.json", + "--custom-schedule", + "plan.json" + ]) + .command, + Command::Migrate(MigrateCommand::Wpf { + custom_schedule: Some(schedule), + .. + }) if schedule == Path::new("plan.json") + ); } #[test] diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs index e1372728..a66877bc 100644 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ b/crates/maa-cli/src/config/migrate/wpf.rs @@ -17,7 +17,12 @@ use crate::config::Filetype; /// ```text /// maa migrate wpf [output] /// ``` -pub(crate) fn wpf(file: &Path, out: Option<&Path>, config: Option) -> Result<()> { +pub(crate) fn wpf( + file: &Path, + out: Option<&Path>, + config: Option, + custom_schedule: Option<&Path>, +) -> Result<()> { ensure!( file.extension() == Some("json".as_ref()), "`maa migrate wpf` expected a MAA GUI profile (typically .json); input {file:?} is not a JSON file" @@ -26,7 +31,7 @@ pub(crate) fn wpf(file: &Path, out: Option<&Path>, config: Option) -> Re let value = BufReader::new(std::fs::File::open(file).context("Trying to open wpf profile")?); let value = serde_json::from_reader(value)?; let value = select_configuration(value, config)?; - let (value, summary) = migrate(value)?; + let (value, summary) = migrate(value, custom_schedule)?; let mut temp_path = Default::default(); let out = out.unwrap_or_else(|| { @@ -99,12 +104,15 @@ fn resolve_configuration_name( } /// Migrate a GUI profile into maa-cli task config shape. -pub(super) fn migrate(input: Value) -> Result<(MAAValue, MigrationSummary)> { +pub(super) fn migrate( + input: Value, + custom_schedule: Option<&Path>, +) -> Result<(MAAValue, MigrationSummary)> { let mut summary = MigrationSummary::default(); let config: WpfConfiguration = serde_json::from_value(input).context("Failed to deserialize GUI configuration")?; let value = config - .migrate_tasks(&mut summary)? + .migrate_tasks(&mut summary, custom_schedule)? .context("GUI configuration produced no CLI config")?; Ok((value, summary)) } @@ -175,13 +183,25 @@ struct WpfConfiguration { } impl WpfConfiguration { - fn migrate_tasks(&self, summary: &mut MigrationSummary) -> Result> { + fn migrate_tasks( + &self, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + ) -> Result> { let mut tasks = Vec::new(); + let mut schedule_applied = false; for task in &self.task_queue { - if let Some(item) = task.migrate_task(&self.gui, summary)? { + if let Some(item) = + task.migrate_task(&self.gui, summary, custom_schedule, &mut schedule_applied)? + { tasks.push(item); } } + if custom_schedule.is_some() && !schedule_applied { + bail!( + "`--custom-schedule` was provided, but the WPF profile is not using custom infrastructure scheduling" + ); + } Ok(Some(serialize_to_maa_value(&CliConfig { tasks })?)) } } @@ -207,6 +227,8 @@ impl WpfTask { &self, gui: &WpfGuiSettings, summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + schedule_applied: &mut bool, ) -> Result> { match self { Self::StartUpTask(start_up) => { @@ -219,7 +241,11 @@ impl WpfTask { } Self::InfrastTask(task) => { task.report_to(summary); - Ok(Some(task.to_maa_value()?)) + Ok(Some(task.to_maa_value( + summary, + custom_schedule, + schedule_applied, + )?)) } Self::RecruitTask(task) => { task.report_to(summary); @@ -779,13 +805,22 @@ mod fight { } mod infrast { - use anyhow::{Result, bail}; + use std::{ + fs, + path::{Path, PathBuf}, + }; + + use anyhow::{Context, Result, bail, ensure}; + use chrono::{NaiveTime, Timelike}; + use log::warn; + use maa_dirs::expand_tilde; use maa_types::TaskType; use maa_value::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; + use crate::config::task::{Condition, TimeOffset}; const HANDLED: &[&str] = &[ "Mode", @@ -798,10 +833,15 @@ mod infrast { "ReceptionMessageBoard", "ReceptionClueExchange", "SendClue", + "ContinueTraining", + "CustomFileType", "Filename", "PlanSelect", ]; + const MINUTES_PER_DAY: u32 = 24 * 60; + const CUSTOM_MODE: i32 = 10000; + #[derive(Debug, Serialize)] struct CliInfrastTask { #[serde(rename = "type")] @@ -809,6 +849,19 @@ mod infrast { #[serde(skip_serializing_if = "str::is_empty")] name: String, params: CliInfrastParams, + #[serde(skip_serializing_if = "Vec::is_empty")] + variants: Vec, + } + + #[derive(Debug, Serialize)] + struct CliInfrastVariant { + condition: Condition, + params: CliInfrastVariantParams, + } + + #[derive(Debug, Serialize)] + struct CliInfrastVariantParams { + plan_index: i32, } #[derive(Debug, Serialize)] @@ -834,6 +887,12 @@ mod infrast { #[serde(skip_serializing_if = "Option::is_none")] reception_send_clue: Option, #[serde(skip_serializing_if = "Option::is_none")] + continue_training: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + plan_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] enable: Option, } @@ -852,6 +911,10 @@ mod infrast { mode: String, #[serde(default)] filename: String, + #[serde(default = "default_plan_select")] + plan_select: i32, + #[serde(default)] + continue_training: bool, room_list: Vec, uses_of_drones: String, dorm_threshold: i32, @@ -865,6 +928,29 @@ mod infrast { unknown: Map, } + fn default_plan_select() -> i32 { + -1 + } + + #[derive(Debug, Deserialize)] + struct CustomInfrastFile { + plans: Vec, + } + + #[derive(Debug, Deserialize)] + struct CustomInfrastPlan { + #[serde(default)] + period: Vec>, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum MergedSpan { + /// `[start, end)` on a single day; `end == MINUTES_PER_DAY` means until midnight. + NonWrap { start: u32, end: u32 }, + /// `[start, 24:00)` plus `[00:00, end)`. + Wrap { start: u32, end: u32 }, + } + impl WpfInfrastTask { fn mapped_mode(&self) -> Option { match self.mode.as_str() { @@ -874,11 +960,15 @@ mod infrast { } } + fn wants_custom(&self) -> bool { + self.mode == "Custom" || (!self.filename.is_empty() && self.mode != "Rotation") + } + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { if !self.is_enable { summary.disable_task("InfrastTask", Some(self.name.clone())); } - if self.mode != "Custom" && self.mapped_mode().is_none() { + if !self.wants_custom() && self.mapped_mode().is_none() { summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); } report_unknown_fields( @@ -890,41 +980,632 @@ mod infrast { ); } - pub(super) fn to_maa_value(&self) -> Result { - serialize_to_maa_value(&CliInfrastTask::try_from(self)?) + pub(super) fn to_maa_value( + &self, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + schedule_applied: &mut bool, + ) -> Result { + serialize_to_maa_value(&self.to_cli_task(summary, custom_schedule, schedule_applied)?) } - } - impl TryFrom<&WpfInfrastTask> for CliInfrastTask { - type Error = anyhow::Error; + fn to_cli_task( + &self, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + schedule_applied: &mut bool, + ) -> Result { + if let Some(path) = custom_schedule + && self.wants_custom() + { + *schedule_applied = true; + return self.try_custom_task(&path.to_string_lossy()); + } + + if self.wants_custom() { + match self.try_custom_task(&self.filename) { + Ok(task) => return Ok(task), + Err(err) => { + warn!( + "InfrastTask custom schedule skipped ({err}); writing default infrast mode" + ); + summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); + if !self.filename.is_empty() { + summary.skip_field("InfrastTask", Some(self.name.clone()), "Filename"); + } + } + } + } + Ok(self.default_task()) + } - fn try_from(task: &WpfInfrastTask) -> Result { - if task.mode == "Custom" { - bail!("InfrastTask custom mode is not supported yet"); + fn default_task(&self) -> CliInfrastTask { + let mode = if self.wants_custom() { + Some(0) + } else { + self.mapped_mode() + }; + CliInfrastTask { + task_type: TaskType::Infrast, + name: self.name.clone(), + params: self.shared_params(mode, None, None), + variants: Vec::new(), } - if !task.filename.is_empty() { - bail!("InfrastTask custom plan (Filename) is not supported yet"); + } + + fn shared_params( + &self, + mode: Option, + filename: Option, + plan_index: Option, + ) -> CliInfrastParams { + let custom = mode == Some(CUSTOM_MODE); + CliInfrastParams { + mode, + facility: self.room_list.iter().map(|r| r.room.clone()).collect(), + drones: (!custom).then(|| self.uses_of_drones.clone()), + threshold: Some(self.dorm_threshold as f32 / 100.0), + replenish: Some(self.originium_shard_auto_replenishment), + dorm_notstationed_enabled: Some(self.dorm_filter_not_stationed), + dorm_trust_enabled: Some(self.dorm_trust_enabled), + reception_message_board: Some(self.reception_message_board), + reception_clue_exchange: Some(self.reception_clue_exchange), + reception_send_clue: Some(self.send_clue), + continue_training: Some(self.continue_training), + filename, + plan_index, + enable: (!self.is_enable).then_some(false), + } + } + + fn try_custom_task(&self, filename: &str) -> Result { + ensure!(!filename.is_empty(), "custom mode has empty Filename"); + let filename = resolve_infrast_filename(filename); + let schedule = load_custom_schedule(&filename)?; + let n_plans = i32::try_from(schedule.plans.len()) + .context("too many plans in custom infrast file")?; + + let time_rotation = self.plan_select < 0; + let variants = if time_rotation { + match variants_from_schedule(&schedule) { + Ok(variants) => variants, + Err(err) => { + warn!( + "InfrastTask custom schedule periods could not be converted ({err}); using plan_index" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + + let plan_index = if variants.is_empty() { + let in_range = self.plan_select >= 0 && self.plan_select < n_plans; + Some(if in_range { self.plan_select } else { 0 }) + } else { + None + }; + + if let Some(note) = schedule_note(&schedule, &variants) { + eprintln!("{note}"); } Ok(CliInfrastTask { task_type: TaskType::Infrast, - name: task.name.clone(), - params: CliInfrastParams { - mode: task.mapped_mode(), - facility: task.room_list.iter().map(|r| r.room.clone()).collect(), - drones: Some(task.uses_of_drones.clone()), - threshold: Some(task.dorm_threshold as f32 / 100.0), - replenish: Some(task.originium_shard_auto_replenishment), - dorm_notstationed_enabled: Some(task.dorm_filter_not_stationed), - dorm_trust_enabled: Some(task.dorm_trust_enabled), - reception_message_board: Some(task.reception_message_board), - reception_clue_exchange: Some(task.reception_clue_exchange), - reception_send_clue: Some(task.send_clue), - enable: (!task.is_enable).then_some(false), - }, + name: self.name.clone(), + params: self.shared_params( + Some(CUSTOM_MODE), + Some(filename.to_string_lossy().into_owned()), + plan_index, + ), + variants, }) } } + + fn resolve_infrast_filename(path: &str) -> PathBuf { + expand_tilde(Path::new(path)).into_owned() + } + + fn load_custom_schedule(path: &Path) -> Result { + let text = fs::read_to_string(path) + .with_context(|| format!("failed to read custom infrast file `{}`", path.display()))?; + let file: CustomInfrastFile = serde_json::from_str(&text) + .with_context(|| format!("failed to parse custom infrast file `{}`", path.display()))?; + ensure!(!file.plans.is_empty(), "custom infrast file has no plans"); + Ok(file) + } + + fn variants_from_schedule(file: &CustomInfrastFile) -> Result> { + let has_period: Vec = file.plans.iter().map(|p| !p.period.is_empty()).collect(); + if has_period.iter().any(|has| !*has) { + if has_period.iter().any(|has| *has) { + bail!("some plans have period and some do not"); + } + return Ok(Vec::new()); + } + + let spans: Vec = file + .plans + .iter() + .map(|plan| merge_plan_periods(&plan.period)) + .collect::>()?; + let remainders = assign_day_remainders(&spans); + let days = remainders.iter().copied().max().unwrap_or(0) + 1; + + spans + .iter() + .zip(remainders) + .enumerate() + .map(|(index, (span, remainder))| { + Ok(CliInfrastVariant { + condition: span_condition(*span, days, remainder), + params: CliInfrastVariantParams { + plan_index: i32::try_from(index)?, + }, + }) + }) + .collect() + } + + fn schedule_note(file: &CustomInfrastFile, variants: &[CliInfrastVariant]) -> Option { + if variants.is_empty() { + return Some(format!( + "InfrastTask: custom schedule with {} plan(s); no period data, using plan_index", + file.plans.len() + )); + } + + let days = variants + .iter() + .filter_map(|v| match &v.condition { + Condition::And { conditions } | Condition::Or { conditions } => { + conditions.iter().find_map(|c| match c { + Condition::DayMod { divisor, .. } => Some(*divisor), + Condition::And { conditions } => { + conditions.iter().find_map(|inner| match inner { + Condition::DayMod { divisor, .. } => Some(*divisor), + _ => None, + }) + } + _ => None, + }) + } + Condition::DayMod { divisor, .. } => Some(*divisor), + _ => None, + }) + .max() + .unwrap_or(1); + let times: Vec = file + .plans + .iter() + .filter_map(|plan| plan.period.first()) + .filter_map(|pair| pair.first()) + .cloned() + .collect(); + Some(format!( + "InfrastTask: custom schedule ({days} day(s), {} shift(s)); suggested run times: {}", + file.plans.len(), + times.join(", ") + )) + } + + fn parse_clock(value: &str) -> Result { + NaiveTime::parse_from_str(value, "%H:%M") + .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S")) + .with_context(|| format!("invalid time `{value}` in custom infrast period")) + } + + fn time_to_minutes(time: NaiveTime) -> u32 { + time.num_seconds_from_midnight() / 60 + } + + fn minutes_to_time(minutes: u32) -> Option { + if minutes >= MINUTES_PER_DAY { + None + } else { + NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0) + } + } + + fn period_to_segments(start: NaiveTime, end: NaiveTime) -> Vec<(u32, u32)> { + let start_m = time_to_minutes(start); + let mut end_m = time_to_minutes(end); + if end.hour() == 23 && end.minute() == 59 { + end_m = MINUTES_PER_DAY; + } + if start_m == end_m { + Vec::new() + } else if start_m < end_m { + vec![(start_m, end_m)] + } else { + vec![(start_m, MINUTES_PER_DAY), (0, end_m)] + } + } + + fn merge_plan_periods(period: &[Vec]) -> Result { + let mut segments = Vec::new(); + for entry in period { + ensure!( + entry.len() == 2, + "period entry must be [start, end], got {entry:?}" + ); + let start = parse_clock(&entry[0])?; + let end = parse_clock(&entry[1])?; + segments.extend(period_to_segments(start, end)); + } + ensure!(!segments.is_empty(), "plan period is empty after parsing"); + segments.sort_by_key(|seg| seg.0); + + let mut merged: Vec<(u32, u32)> = Vec::new(); + for (start, end) in segments { + if let Some(last) = merged.last_mut() + && start <= last.1 + { + last.1 = last.1.max(end); + } else { + merged.push((start, end)); + } + } + + match merged.as_slice() { + [(start, end)] => Ok(MergedSpan::NonWrap { + start: *start, + end: *end, + }), + [(0, morning_end), (night_start, night_end)] if *night_end == MINUTES_PER_DAY => { + Ok(MergedSpan::Wrap { + start: *night_start, + end: *morning_end, + }) + } + other => bail!("unsupported disjoint period segments: {other:?}"), + } + } + + fn span_coverage(span: MergedSpan) -> [bool; MINUTES_PER_DAY as usize] { + let mut bits = [false; MINUTES_PER_DAY as usize]; + match span { + MergedSpan::NonWrap { start, end } => { + for minute in start..end { + bits[minute as usize] = true; + } + } + MergedSpan::Wrap { start, end } => { + for minute in start..MINUTES_PER_DAY { + bits[minute as usize] = true; + } + for minute in 0..end { + bits[minute as usize] = true; + } + } + } + bits + } + + fn coverages_overlap(left: &[bool], right: &[bool]) -> bool { + left.iter().zip(right).any(|(a, b)| *a && *b) + } + + fn assign_day_remainders(spans: &[MergedSpan]) -> Vec { + let coverages: Vec<[bool; MINUTES_PER_DAY as usize]> = + spans.iter().copied().map(span_coverage).collect(); + let mut occupied: Vec<[bool; MINUTES_PER_DAY as usize]> = Vec::new(); + let mut remainders = Vec::with_capacity(spans.len()); + for coverage in &coverages { + let remainder = occupied + .iter() + .position(|day| !coverages_overlap(day, coverage)) + .unwrap_or_else(|| { + occupied.push([false; MINUTES_PER_DAY as usize]); + occupied.len() - 1 + }); + for (dst, src) in occupied[remainder].iter_mut().zip(coverage) { + *dst |= src; + } + remainders.push(remainder as u32); + } + remainders + } + + fn day_mod(divisor: u32, remainder: u32) -> Condition { + Condition::DayMod { + divisor, + remainder, + timezone: TimeOffset::Local, + } + } + + fn time_range(start: Option, end: Option) -> Condition { + Condition::Time { + start, + end, + timezone: TimeOffset::Local, + } + } + + fn span_condition(span: MergedSpan, days: u32, remainder: u32) -> Condition { + let time = match span { + MergedSpan::NonWrap { start, end } => { + time_range(minutes_to_time(start), minutes_to_time(end)) + } + MergedSpan::Wrap { start, end } => { + time_range(minutes_to_time(start), minutes_to_time(end)) + } + }; + + if days <= 1 { + return time; + } + + match span { + MergedSpan::NonWrap { .. } => Condition::And { + conditions: vec![day_mod(days, remainder), time], + }, + MergedSpan::Wrap { start, end } => Condition::Or { + conditions: vec![ + Condition::And { + conditions: vec![ + day_mod(days, remainder), + time_range(minutes_to_time(start), None), + ], + }, + Condition::And { + conditions: vec![ + day_mod(days, (remainder + 1) % days), + time_range(None, minutes_to_time(end)), + ], + }, + ], + }, + } + } + + #[cfg(test)] + #[cfg_attr(coverage_nightly, coverage(off))] + mod tests { + use super::*; + use crate::config::migrate::MigrationSummary; + + fn sample_task(mode: &str, filename: &str, plan_select: i32) -> WpfInfrastTask { + serde_json::from_value(serde_json::json!({ + "Name": "", + "IsEnable": true, + "Mode": mode, + "Filename": filename, + "PlanSelect": plan_select, + "ContinueTraining": true, + "RoomList": [{"Room": "Mfg"}, {"Room": "Trade"}], + "UsesOfDrones": "Money", + "DormThreshold": 30, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "DormTrustEnabled": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + })) + .unwrap() + } + + fn write_schedule(contents: &str) -> tempfile::NamedTempFile { + let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); + std::fs::write(file.path(), contents).unwrap(); + file + } + + fn to_json(task: &WpfInfrastTask, summary: &mut MigrationSummary) -> serde_json::Value { + let value = task.to_maa_value(summary, None, &mut false).unwrap(); + serde_json::to_value(value).unwrap() + } + + fn infrast_profile(mode: &str, filename: &str) -> Value { + serde_json::json!({ + "TaskQueue": [{ + "$type": "InfrastTask", + "Name": "", + "IsEnable": true, + "Mode": mode, + "Filename": filename, + "PlanSelect": -1, + "ContinueTraining": true, + "RoomList": [{"Room": "Mfg"}, {"Room": "Trade"}], + "UsesOfDrones": "Money", + "DormThreshold": 30, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "DormTrustEnabled": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + }], + "Gui": {} + }) + } + + #[test] + fn normal_mode_keeps_default_layout() { + let task = sample_task("Normal", "", -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert!(summary.is_empty()); + assert_eq!(json["params"]["mode"], 0); + assert!( + json.get("variants").is_none() || json["variants"].as_array().unwrap().is_empty() + ); + assert!(json["params"].get("filename").is_none()); + } + + #[test] + fn missing_custom_file_falls_back_to_default() { + let task = sample_task("Custom", "/no/such/infrast-plan.json", -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert_eq!(json["params"]["mode"], 0); + assert!(json["params"].get("filename").is_none()); + assert!(summary.skipped_fields.iter().any(|f| f.field == "Filename")); + assert!(summary.skipped_fields.iter().any(|f| f.field == "Mode")); + } + + #[test] + fn tilde_in_filename_expands_to_home() { + let expanded = resolve_infrast_filename("~/一图流-153-一天两换-MAA.json"); + assert_eq!( + expanded, + expand_tilde(Path::new("~/一图流-153-一天两换-MAA.json")).into_owned() + ); + assert!(expanded.is_absolute()); + assert!(!expanded.starts_with("~")); + } + + #[test] + fn one_day_two_shifts_generates_time_variants() { + let schedule = r#"{ + "plans": [ + {"name": "A", "period": [["04:00", "16:00"]]}, + {"name": "B", "period": [["16:00", "23:59"], ["00:00", "04:00"]]} + ] + }"#; + let file = write_schedule(schedule); + let task = sample_task("Custom", file.path().to_str().unwrap(), -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert!(summary.is_empty()); + assert_eq!(json["params"]["mode"], CUSTOM_MODE); + assert_eq!(json["params"]["filename"], file.path().to_str().unwrap()); + let variants = json["variants"].as_array().unwrap(); + assert_eq!(variants.len(), 2); + assert_eq!(variants[0]["params"]["plan_index"], 0); + assert_eq!(variants[0]["condition"]["type"], "Time"); + assert_eq!(variants[0]["condition"]["start"], "04:00:00"); + assert_eq!(variants[0]["condition"]["end"], "16:00:00"); + assert_eq!(variants[1]["params"]["plan_index"], 1); + assert_eq!(variants[1]["condition"]["type"], "Time"); + assert_eq!(variants[1]["condition"]["start"], "16:00:00"); + assert_eq!(variants[1]["condition"]["end"], "04:00:00"); + } + + #[test] + fn two_day_six_shifts_uses_day_mod() { + let schedule = r#"{ + "plans": [ + {"period": [["04:00", "12:00"]]}, + {"period": [["12:00", "20:00"]]}, + {"period": [["20:00", "23:59"], ["00:00", "04:00"]]}, + {"period": [["04:00", "12:00"]]}, + {"period": [["12:00", "20:00"]]}, + {"period": [["20:00", "23:59"], ["00:00", "04:00"]]} + ] + }"#; + let file = write_schedule(schedule); + let task = sample_task("Custom", file.path().to_str().unwrap(), -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + let variants = json["variants"].as_array().unwrap(); + assert_eq!(variants.len(), 6); + assert_eq!(variants[0]["condition"]["type"], "And"); + assert_eq!(variants[0]["condition"]["conditions"][0]["type"], "DayMod"); + assert_eq!(variants[0]["condition"]["conditions"][0]["divisor"], 2); + assert!( + variants[0]["condition"]["conditions"][0] + .get("remainder") + .is_none() + ); + assert_eq!(variants[3]["condition"]["conditions"][0]["remainder"], 1); + assert_eq!(variants[2]["condition"]["type"], "Or"); + assert_eq!(variants[5]["condition"]["type"], "Or"); + assert_eq!(variants[5]["params"]["plan_index"], 5); + } + + #[test] + fn optional_shift_uses_plan_index() { + let schedule = r#"{"plans": [{"name": "A"}, {"name": "B"}]}"#; + let file = write_schedule(schedule); + let task = sample_task("Custom", file.path().to_str().unwrap(), 1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert_eq!(json["params"]["mode"], CUSTOM_MODE); + assert_eq!(json["params"]["plan_index"], 1); + assert!( + json.get("variants").is_none() || json["variants"].as_array().unwrap().is_empty() + ); + } + + #[test] + fn custom_schedule_overrides_wpf_filename() { + let schedule = r#"{ + "plans": [ + {"name": "A", "period": [["04:00", "16:00"]]}, + {"name": "B", "period": [["16:00", "23:59"], ["00:00", "04:00"]]} + ] + }"#; + let file = write_schedule(schedule); + let profile = infrast_profile("Custom", "/original/missing.json"); + let (value, summary) = super::super::migrate(profile, Some(file.path())).unwrap(); + assert!(summary.is_empty()); + let json = serde_json::to_value(value).unwrap(); + let task = &json["tasks"][0]; + assert_eq!(task["params"]["mode"], CUSTOM_MODE); + assert_eq!( + task["params"]["filename"], + resolve_infrast_filename(file.path().to_str().unwrap()) + .to_string_lossy() + .as_ref() + ); + assert_eq!(task["variants"].as_array().unwrap().len(), 2); + } + + #[test] + fn custom_schedule_errors_when_profile_is_not_custom() { + let profile = infrast_profile("Normal", ""); + let err = super::super::migrate(profile, Some(Path::new("plan.json"))).unwrap_err(); + assert!(err.to_string().contains("--custom-schedule"), "{err}"); + } + + #[test] + fn custom_schedule_errors_when_override_file_missing() { + let profile = infrast_profile("Custom", "/original/missing.json"); + let err = super::super::migrate(profile, Some(Path::new("/no/such/override.json"))) + .unwrap_err(); + assert!( + err.to_string() + .contains("failed to read custom infrast file"), + "{err}" + ); + } + + #[test] + fn assign_days_packs_overlapping_clock_spans() { + let spans = [ + MergedSpan::NonWrap { + start: 4 * 60, + end: 12 * 60, + }, + MergedSpan::NonWrap { + start: 12 * 60, + end: 20 * 60, + }, + MergedSpan::Wrap { + start: 20 * 60, + end: 4 * 60, + }, + MergedSpan::NonWrap { + start: 4 * 60, + end: 12 * 60, + }, + MergedSpan::NonWrap { + start: 12 * 60, + end: 20 * 60, + }, + MergedSpan::Wrap { + start: 20 * 60, + end: 4 * 60, + }, + ]; + assert_eq!(assign_day_remainders(&spans), vec![0, 0, 0, 1, 1, 1]); + } + } } mod recruit { diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index ef536e08..1443e02d 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -112,7 +112,13 @@ fn main() -> Result<()> { input, output, config, - }) => config::migrate::wpf(&input, output.as_deref(), config)?, + custom_schedule, + }) => config::migrate::wpf( + &input, + output.as_deref(), + config, + custom_schedule.as_deref(), + )?, Command::Activity { client } => activity::display_stage_activity(client)?, Command::Remainder { divisor, timezone } => { use crate::config::task::{TimeOffset, remainder_of_day_mod}; From 1c83927391b70d83da33ca75fa3db77b06acc498 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 21 Aug 2026 06:59:43 +0800 Subject: [PATCH 19/20] =?UTF-8?q?=E2=9C=A8=20feat:=20map=20WPF=20activity?= =?UTF-8?q?=20stages=20to=20DateTime=20expire=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split `migrate/wpf.rs` into per-task modules under `migrate/wpf/`. Read side-story expire time from StageActivityV2.json at migrate time instead of emitting OnSideStory; fail after hot-update if the activity file cannot be read. Rename migrate `--config` to `--profile-name`. 将 WPF 活动关迁移为 DateTime 截止时间,并拆分 migrate/wpf 模块。 --- .../maa-cli/config_examples/wpf/gui.new.toml | 6 +- crates/maa-cli/docs/zh-CN/wpf.md | 11 +- crates/maa-cli/src/activity.rs | 89 +- crates/maa-cli/src/command.rs | 12 +- crates/maa-cli/src/config/migrate/mod.rs | 55 +- crates/maa-cli/src/config/migrate/wpf.rs | 2281 ----------------- .../maa-cli/src/config/migrate/wpf/award.rs | 246 ++ .../maa-cli/src/config/migrate/wpf/fight.rs | 574 +++++ .../maa-cli/src/config/migrate/wpf/infrast.rs | 779 ++++++ crates/maa-cli/src/config/migrate/wpf/mall.rs | 116 + crates/maa-cli/src/config/migrate/wpf/mod.rs | 401 +++ .../src/config/migrate/wpf/reclamation.rs | 108 + .../maa-cli/src/config/migrate/wpf/recruit.rs | 171 ++ .../src/config/migrate/wpf/roguelike.rs | 176 ++ .../src/config/migrate/wpf/start_up.rs | 110 + crates/maa-cli/src/main.rs | 4 +- 16 files changed, 2812 insertions(+), 2327 deletions(-) delete mode 100644 crates/maa-cli/src/config/migrate/wpf.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/award.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/fight.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/infrast.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/mall.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/mod.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/reclamation.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/recruit.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/roguelike.rs create mode 100644 crates/maa-cli/src/config/migrate/wpf/start_up.rs diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml index 3b5a731a..21fdf35c 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.toml +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -11,7 +11,7 @@ type = "Fight" [tasks.params] series = 0 -stage = "Annihilation" +stage = "LungmenDowntown@Annihilation" [[tasks]] name = "理智作战" @@ -24,7 +24,9 @@ series = 0 [[tasks.variants]] [tasks.variants.condition] -type = "OnSideStory" +end = "2026-08-22T03:59:59" +timezone = 8 +type = "DateTime" [tasks.variants.params] series = 0 diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md index c6dee386..44ceffe3 100644 --- a/crates/maa-cli/docs/zh-CN/wpf.md +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -14,14 +14,14 @@ maa migrate wpf gui.new.json # 指定输出路径与格式(由扩展名决定,支持 toml / yaml / json) maa migrate wpf gui.new.json tasks/daily.toml -# 多配置导出时,用 --config 选择配置名 -maa migrate wpf gui.new.json --config Default +# 多配置导出时,用 --profile-name 选择配置名 +maa migrate wpf gui.new.json --profile-name Default # GUI 是自定义排班时,用本机排班 JSON 覆盖其中的 Filename maa migrate wpf gui.new.json --custom-schedule ~/一图流-153-一天两换-MAA.json ``` -如果导出文件里只有一个配置,会自动选择该配置;有多个配置且未指定 `--config` 时,会交互式提示你选择。 +如果导出文件里只有一个配置,会自动选择该配置;有多个配置且未指定 `--profile-name` 时,会交互式提示你选择。 `--custom-schedule` 只在 GUI 基建任务已经是自定义排班(`Mode = Custom` 或填写了 `Filename`)时生效,用来覆盖排班文件路径。若 GUI 不是自定义排班,传入该选项会报错退出。 @@ -51,7 +51,7 @@ GUI 里 `IsEnable = false` 的任务仍会写出,但会带上 `params.enable = - 资源本 / 芯片本:`Weekday`(官服服务器时区) - 永久关卡(如主线、剿灭、`LS-6`):`Always` -- 活动关卡:`OnSideStory` +- 活动关卡:若能在热更新资源 `StageActivityV2.json` 中查到对应活动,则写入 `DateTime`(使用该活动的截止时间与时区);查不到则回退为 `Always` 并告警 例如 GUI 中同时启用过期理智药与多关卡备选时,迁移结果类似: @@ -64,7 +64,7 @@ type = "Fight" medicine_expire_days = 2 [[tasks.variants]] -condition = { type = "OnSideStory" } +condition = { type = "DateTime", end = "2026-08-22T03:59:59", timezone = 8 } params = { stage = "TO-5" } [[tasks.variants]] @@ -77,6 +77,7 @@ params = { stage = "LS-6" } ``` 这里的 `medicine_expire_days` 对应 GUI 的 `MedicineExpireDays`(在 `UseExpiringMedicine = true` 时写入),表示使用多少天内将过期的理智药。 +活动关卡的 `end` 来自迁移时本地的 `StageActivityV2.json`,之后不会随热更新自动刷新;活动换档后需重新迁移或手动改条件。 若同时启用 `UseWeeklySchedule`,周计划会与关卡开放条件用 `And` 组合。 **注意**:启用源石(`UseStone`)时会发出警告,因为这可能消耗源石。 diff --git a/crates/maa-cli/src/activity.rs b/crates/maa-cli/src/activity.rs index a2b935df..a35fc814 100644 --- a/crates/maa-cli/src/activity.rs +++ b/crates/maa-cli/src/activity.rs @@ -19,16 +19,29 @@ pub fn has_side_story_open(client: ClientType) -> bool { .unwrap_or(false) } -/// Stage codes listed under side-story activities for `client`. +/// Load a side-story stage schedule directly from disk (bypasses the process cache). /// -/// Used to decide whether a fight stage should use an `OnSideStory` condition. -/// Returns an empty list when the activity file is missing or the client has no entry. -pub fn side_story_stages(client: ClientType) -> Vec { - STAGE_ACTIVITY - .as_ref() - .and_then(|stage_activity| stage_activity.get_stage_activity(client)) - .map(StageActivityContent::side_story_stage_codes) - .unwrap_or_default() +/// Used by WPF migration so a missing/unreadable file surfaces as an error instead of +/// silently falling back, and so a hot-update refresh can be re-read in the same process. +pub fn load_side_story_stage_schedule( + client: ClientType, + stage: &str, +) -> Result> { + let path = maa_dirs::activity(); + let stage_activity = load_stage_activity(path) + .with_context(|| format!("Failed to read activity resource {}", path.display()))?; + Ok(stage_activity + .get_stage_activity(client) + .and_then(|content| content.side_story_schedule_for(stage))) +} + +/// Schedule window copied from a side-story activity entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SideStorySchedule { + pub start: NaiveDateTime, + pub end: NaiveDateTime, + /// Hours east of UTC (e.g. `8` for CN official). + pub timezone: i8, } pub fn display_stage_activity(client: ClientType) -> std::io::Result<()> { @@ -129,24 +142,31 @@ impl StageActivityContent { .any(|activity| activity.activity.is_active()) } - /// Collect unique fight stage codes from all side-story activities. + /// Look up the open window for a fight stage code. /// /// Prefers `Value` when present, otherwise falls back to `Display`. - pub fn side_story_stage_codes(&self) -> Vec { - let mut stages = Vec::new(); + /// If the stage appears in multiple activities, keeps the latest expire time. + fn side_story_schedule_for(&self, stage: &str) -> Option { + let mut best: Option = None; for activity in self.side_story_stage.values() { - for stage in &activity.stages { - let code = if stage.value.is_empty() { - stage.display.as_str() + let matched = activity.stages.iter().any(|s| { + let code = if s.value.is_empty() { + s.display.as_str() } else { - stage.value.as_str() + s.value.as_str() }; - if !stages.iter().any(|s| s == code) { - stages.push(code.to_owned()); - } + code == stage + }); + if !matched { + continue; } + let schedule = activity.activity.time_info.to_schedule()?; + best = Some(match best { + Some(prev) if prev.end >= schedule.end => prev, + _ => schedule, + }); } - stages + best } } @@ -240,6 +260,16 @@ impl TimeInfo { let time = time.with_timezone(&self.time_zone).naive_local(); self.utc_start_time < time && time < self.utc_expire_time } + + fn to_schedule(&self) -> Option { + let hours = self.time_zone.local_minus_utc() / 3600; + let timezone = i8::try_from(hours).ok()?; + Some(SideStorySchedule { + start: self.utc_start_time, + end: self.utc_expire_time, + timezone, + }) + } } fn load_item_index(client: ClientType) -> Result { @@ -611,6 +641,25 @@ mod tests { assert!(!content.has_side_story_open()); } + + #[test] + fn test_side_story_schedule_for_from_fixture() { + let json_str = include_str!("../fixtures/activity.json"); + let stage_activity: StageActivityJson = serde_json::from_str(json_str).unwrap(); + let official = stage_activity + .get_stage_activity(ClientType::Official) + .unwrap(); + + let ep8 = official.side_story_schedule_for("EP-8").unwrap(); + assert_eq!(ep8.start, naive_dt(2025, 12, 19, 16, 0, 0)); + assert_eq!(ep8.end, naive_dt(2026, 1, 2, 3, 59, 59)); + assert_eq!(ep8.timezone, 8); + + let ur8 = official.side_story_schedule_for("UR-8").unwrap(); + assert_eq!(ur8.end, naive_dt(2025, 12, 19, 3, 59, 59)); + + assert!(official.side_story_schedule_for("CE-6").is_none()); + } } mod utils { diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index d4ae69b7..a4e85515 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -261,7 +261,7 @@ pub(crate) enum MigrateCommand { /// Migrate a MAA WPF GUI profile TaskQueue into maa-cli task config /// /// The input is typically a GUI-exported JSON profile. When multiple - /// configurations exist, use `--config` or `MAA_GUI_CONFIG` to select one. + /// configurations exist, use `--profile-name` to select one. /// Disabled GUI tasks are kept with `params.enable = false` so they will /// not run until re-enabled manually. Wpf { @@ -270,8 +270,8 @@ pub(crate) enum MigrateCommand { /// Path of the output file, if not specified, the output will be printed to stdout output: Option, /// Select a named configuration when migrating a multi-profile GUI export - #[arg(long)] - config: Option, + #[arg(long = "profile-name")] + profile_name: Option, /// Override the custom infrastructure schedule JSON path in the WPF profile /// /// The WPF InfrastTask must already use custom scheduling (`Mode = Custom` @@ -623,7 +623,7 @@ mod test { Command::Migrate(MigrateCommand::Wpf { input, output: None, - config: None, + profile_name: None, custom_schedule: None, }) if input == Path::new("profile.json") ); @@ -635,14 +635,14 @@ mod test { "wpf", "profile.json", "tasks.toml", - "--config", + "--profile-name", "Default" ]) .command, Command::Migrate(MigrateCommand::Wpf { input, output: Some(output), - config: Some(config), + profile_name: Some(config), custom_schedule: None, }) if input == Path::new("profile.json") && output == Path::new("tasks.toml") diff --git a/crates/maa-cli/src/config/migrate/mod.rs b/crates/maa-cli/src/config/migrate/mod.rs index 183c1ab7..e59384dc 100644 --- a/crates/maa-cli/src/config/migrate/mod.rs +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -4,6 +4,8 @@ //! data structure, migration may select profiles, remap fields, and drop unsupported //! content. Results are therefore potentially lossy and always accompanied by a summary. +use std::fmt; + mod wpf; pub(crate) use wpf::wpf; @@ -65,42 +67,48 @@ impl MigrationSummary { field: field.into(), }); } +} - /// Print a user-facing summary of lossy migration decisions to stderr. - pub fn print(&self) { +impl fmt::Display for MigrationSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.is_empty() { - return; + return Ok(()); } - eprintln!("Migration summary (lossy):"); + writeln!(f, "Migration summary (lossy):")?; if !self.skipped_tasks.is_empty() { - eprintln!(" Skipped tasks (unsupported type):"); + writeln!(f, " Skipped tasks (unsupported type):")?; for task in &self.skipped_tasks { - eprintln!(" - {}", format_task_ref(task)); + writeln!(f, " - {}", format_task_ref(task))?; } } if !self.disabled_tasks.is_empty() { - eprintln!(" Disabled tasks (kept with params.enable=false; will not run):"); + writeln!( + f, + " Disabled tasks (kept with params.enable=false; will not run):" + )?; for task in &self.disabled_tasks { - eprintln!(" - {}", format_task_ref(task)); + writeln!(f, " - {}", format_task_ref(task))?; } } if !self.skipped_fields.is_empty() { - eprintln!(" Skipped fields (not migrated):"); + writeln!(f, " Skipped fields (not migrated):")?; for field in &self.skipped_fields { match &field.task_name { Some(name) if !name.is_empty() => { - eprintln!(" - {} \"{}\": {}", field.task_type, name, field.field); + writeln!(f, " - {} \"{name}\": {}", field.task_type, field.field)?; } _ => { - eprintln!(" - {}: {}", field.task_type, field.field); + writeln!(f, " - {}: {}", field.task_type, field.field)?; } } } } + + Ok(()) } } @@ -110,3 +118,28 @@ fn format_task_ref(task: &SkippedTask) -> String { _ => task.type_tag.clone(), } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn display_formats_skipped_task_with_name() { + let summary = MigrationSummary { + skipped_tasks: vec![SkippedTask { + type_tag: "UserDataUpdateTask".into(), + name: Some("sync".into()), + }], + ..Default::default() + }; + let text = summary.to_string(); + assert!(text.starts_with("Migration summary (lossy):\n")); + assert!(text.contains("UserDataUpdateTask \"sync\"")); + } + + #[test] + fn display_is_empty_for_empty_summary() { + assert!(MigrationSummary::default().to_string().is_empty()); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf.rs b/crates/maa-cli/src/config/migrate/wpf.rs deleted file mode 100644 index a66877bc..00000000 --- a/crates/maa-cli/src/config/migrate/wpf.rs +++ /dev/null @@ -1,2281 +0,0 @@ -use std::{io::BufReader, num::NonZero, path::Path}; - -use anyhow::{Context, Result, bail, ensure}; -use log::trace; -use maa_value::{ - prelude::*, - userinput::{SelectD, UserInput}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::MigrationSummary; -use crate::config::Filetype; - -/// Migrate a MAA WPF GUI profile into a maa-cli task config. -/// -/// ```text -/// maa migrate wpf [output] -/// ``` -pub(crate) fn wpf( - file: &Path, - out: Option<&Path>, - config: Option, - custom_schedule: Option<&Path>, -) -> Result<()> { - ensure!( - file.extension() == Some("json".as_ref()), - "`maa migrate wpf` expected a MAA GUI profile (typically .json); input {file:?} is not a JSON file" - ); - - let value = BufReader::new(std::fs::File::open(file).context("Trying to open wpf profile")?); - let value = serde_json::from_reader(value)?; - let value = select_configuration(value, config)?; - let (value, summary) = migrate(value, custom_schedule)?; - - let mut temp_path = Default::default(); - let out = out.unwrap_or_else(|| { - temp_path = file.with_extension("toml"); - temp_path.as_path() - }); - let ft = Filetype::parse_filetype(out).unwrap(); - if let Some(dir) = out.parent() { - use maa_dirs::Ensure; - dir.ensure()?; - } - ft.write(out, &value) - .with_context(|| format!("Failed to write migrated file {}", out.display()))?; - - summary.print(); - Ok(()) -} - -/// Pick one configuration from a multi-profile GUI export. -/// -/// Legacy single-config profiles are returned unchanged. -/// -/// When multiple configurations exist, `config_name` can be -/// used to select one without an interactive prompt. -pub(super) fn select_configuration(mut input: Value, config_name: Option) -> Result { - let current = input - .get("Current") - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_default(); - let Some(configurations) = input.get_mut("Configurations") else { - bail!("GUI profile missing Configurations"); - }; - - let config_name = match config_name { - Some(name) => name, - None => { - let Some(object) = configurations.as_object() else { - bail!("GUI profile Configurations is not an object"); - }; - match object.len() { - 0 => bail!("GUI profile has no configuration"), - 1 => object.keys().next().unwrap().to_string(), - _ => resolve_configuration_name(object, current)?, - } - } - }; - trace!("Selected configuration: {config_name}"); - let Some(profile) = configurations.get_mut(&config_name) else { - bail!("GUI configuration {config_name} not found"); - }; - Ok(profile.take()) -} - -fn resolve_configuration_name( - object: &serde_json::map::Map, - current: String, -) -> Result { - let names: Vec<&str> = object.keys().map(String::as_str).collect(); - let default_index = object - .iter() - .position(|(name, _)| *name == current) - .and_then(|i| NonZero::new(i + 1)); - - SelectD::::from_iter(names, default_index) - .context("Failed to build configuration selection")? - .with_description("a GUI configuration") - .value() - .context("Failed to select GUI configuration") -} - -/// Migrate a GUI profile into maa-cli task config shape. -pub(super) fn migrate( - input: Value, - custom_schedule: Option<&Path>, -) -> Result<(MAAValue, MigrationSummary)> { - let mut summary = MigrationSummary::default(); - let config: WpfConfiguration = - serde_json::from_value(input).context("Failed to deserialize GUI configuration")?; - let value = config - .migrate_tasks(&mut summary, custom_schedule)? - .context("GUI configuration produced no CLI config")?; - Ok((value, summary)) -} - -/// Serialize a typed CLI config prototype into [`MAAValue`]. -fn serialize_to_maa_value(value: &T) -> Result { - let json = serde_json::to_value(value).context("Failed to serialize CLI config prototype")?; - serde_json::from_value(json).context("Failed to convert CLI config prototype to MAAValue") -} - -/// Meta / structural keys that are never reported as skipped fields. -const META_FIELDS: &[&str] = &["$type", "TaskType", "Name", "IsEnable"]; - -fn report_unknown_fields( - summary: &mut MigrationSummary, - type_tag: &str, - name: Option, - unknown: &serde_json::Map, - handled: &[&str], -) { - for (key, value) in unknown { - if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { - continue; - } - if is_meaningful_json(value) { - summary.skip_field(type_tag, name.clone(), key.clone()); - } - } -} - -fn is_meaningful_json(value: &Value) -> bool { - match value { - Value::Bool(v) => *v, - Value::String(s) => !s.is_empty(), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - i != 0 && i != i64::from(i32::MAX) - } else if let Some(f) = n.as_f64() { - f != 0.0 - } else { - true - } - } - Value::Array(items) => !items.is_empty(), - Value::Object(map) => !map.is_empty(), - Value::Null => false, - } -} - -fn split_semi_list(list: &str) -> Vec { - list.split(';') - .filter(|item| !item.is_empty()) - .map(str::to_string) - .collect() -} - -#[derive(Debug, Serialize)] -struct CliConfig { - tasks: Vec, -} - -#[derive(Debug, Deserialize)] -struct WpfConfiguration { - #[serde(rename = "TaskQueue")] - task_queue: Vec, - #[serde(rename = "Gui")] - gui: WpfGuiSettings, -} - -impl WpfConfiguration { - fn migrate_tasks( - &self, - summary: &mut MigrationSummary, - custom_schedule: Option<&Path>, - ) -> Result> { - let mut tasks = Vec::new(); - let mut schedule_applied = false; - for task in &self.task_queue { - if let Some(item) = - task.migrate_task(&self.gui, summary, custom_schedule, &mut schedule_applied)? - { - tasks.push(item); - } - } - if custom_schedule.is_some() && !schedule_applied { - bail!( - "`--custom-schedule` was provided, but the WPF profile is not using custom infrastructure scheduling" - ); - } - Ok(Some(serialize_to_maa_value(&CliConfig { tasks })?)) - } -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "$type")] -enum WpfTask { - StartUpTask(start_up::WpfStartUpTask), - FightTask(fight::WpfFightTask), - InfrastTask(infrast::WpfInfrastTask), - RecruitTask(recruit::WpfRecruitTask), - MallTask(mall::WpfMallTask), - AwardTask(award::WpfAwardTask), - RoguelikeTask(roguelike::WpfRoguelikeTask), - ReclamationTask(reclamation::WpfReclamationTask), - /// Unknown `$type` values are skipped during migration. - #[serde(other)] - Unsupported, -} - -impl WpfTask { - fn migrate_task( - &self, - gui: &WpfGuiSettings, - summary: &mut MigrationSummary, - custom_schedule: Option<&Path>, - schedule_applied: &mut bool, - ) -> Result> { - match self { - Self::StartUpTask(start_up) => { - start_up.report_to(summary); - Ok(Some(start_up.to_maa_value(gui)?)) - } - Self::FightTask(fight) => { - fight.report_disabled(summary); - Ok(Some(MAAValue::try_from(fight)?)) - } - Self::InfrastTask(task) => { - task.report_to(summary); - Ok(Some(task.to_maa_value( - summary, - custom_schedule, - schedule_applied, - )?)) - } - Self::RecruitTask(task) => { - task.report_to(summary); - Ok(Some(task.to_maa_value()?)) - } - Self::MallTask(task) => { - task.report_to(summary); - Ok(Some(task.to_maa_value()?)) - } - Self::AwardTask(task) => { - task.report_to(summary); - Ok(Some(task.to_maa_value()?)) - } - Self::RoguelikeTask(task) => { - task.report_to(summary); - Ok(Some(task.to_maa_value()?)) - } - Self::ReclamationTask(task) => { - task.report_to(summary); - Ok(Some(task.to_maa_value()?)) - } - Self::Unsupported => { - summary.skip_task("Unsupported", None); - Ok(None) - } - } - } -} - -/// WPF GUI `Gui` object for the selected configuration. -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct WpfGuiSettings { - #[serde(default)] - runtime_settings: Option, - #[serde(flatten)] - #[allow(dead_code)] - unknown: serde_json::Map, -} - -/// `Gui.RuntimeSettings` fields used by StartUp migration. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct WpfRuntimeSettings { - #[serde(default)] - client_type: Option, - #[serde(default)] - start_game: Option, - #[serde(flatten)] - #[allow(dead_code)] - unknown: serde_json::Map, -} - -mod start_up { - use anyhow::Result; - use log::warn; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, WpfGuiSettings, serialize_to_maa_value}; - use crate::config::task::ClientType; - - /// maa-cli StartUp task shape written by migration. - #[derive(Debug, Serialize)] - struct CliStartUpTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliStartUpParams, - } - - #[derive(Debug, Serialize)] - struct CliStartUpParams { - client_type: CliClientTypeParam, - #[serde(skip_serializing_if = "Option::is_none")] - start_game_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - account_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - /// Fixed client type string, or an interactive select prompt. - #[derive(Debug, Serialize)] - #[serde(untagged)] - enum CliClientTypeParam { - Fixed(ClientType), - Prompt { - alternatives: &'static [&'static str], - }, - } - - /// WPF GUI `StartUpTask` (`$type = "StartUpTask"`). - /// - /// Task-queue fields combine with [`WpfGuiSettings::runtime_settings`] when - /// converting to [`CliStartUpTask`] / [`MAAValue`]. - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfStartUpTask { - name: String, - is_enable: bool, - account_switch_enabled: bool, - /// Absent when the GUI export omits the key. - #[serde(default)] - account_name: Option, - #[serde(flatten)] - #[allow(dead_code)] - unknown: Map, - } - - impl WpfStartUpTask { - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("StartUpTask", Some(self.name.clone())); - } - if self.account_switch_enabled && self.account_name.is_none() { - summary.skip_field("StartUpTask", Some(self.name.clone()), "AccountName"); - warn!("AccountName is missing, but GUI enable account switching"); - } - } - - pub(super) fn to_maa_value(&self, gui: &WpfGuiSettings) -> Result { - serialize_to_maa_value(&CliStartUpTask::try_from((self, gui))?) - } - } - - impl TryFrom<(&WpfStartUpTask, &WpfGuiSettings)> for CliStartUpTask { - type Error = anyhow::Error; - - fn try_from((task, gui): (&WpfStartUpTask, &WpfGuiSettings)) -> Result { - let runtime = gui.runtime_settings.as_ref(); - - let client_type = match runtime.and_then(|r| r.client_type.as_deref()) { - Some(s) if !s.is_empty() => match s.parse::() { - Ok(client) => CliClientTypeParam::Fixed(client), - Err(_) => CliClientTypeParam::Prompt { - alternatives: &ClientType::NAMES, - }, - }, - _ => CliClientTypeParam::Prompt { - alternatives: &ClientType::NAMES, - }, - }; - - let account_name = if task.account_switch_enabled { - task.account_name.clone() - } else { - None - }; - - Ok(CliStartUpTask { - task_type: TaskType::StartUp, - name: task.name.clone(), - params: CliStartUpParams { - client_type, - start_game_enabled: runtime.and_then(|r| r.start_game), - account_name, - enable: (!task.is_enable).then_some(false), - }, - }) - } - } -} - -mod fight { - use anyhow::{Context, Result, bail}; - use chrono::Weekday; - use log::warn; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, serialize_to_maa_value}; - use crate::config::task::{ClientType, Condition, TimeOffset}; - - /// maa-cli Fight task shape written by migration. - #[derive(Debug, Serialize)] - struct CliFightTask { - #[serde(rename = "type")] - task_type: TaskType, - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - params: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - variants: Vec, - } - - #[derive(Debug, Serialize)] - struct CliFightVariant { - condition: Condition, - params: CliFightParams, - } - - /// Fight params; field order matches historical `object!` / `insert!` output for parity. - #[derive(Clone, Debug, Default, Serialize)] - struct CliFightParams { - #[serde(skip_serializing_if = "Option::is_none")] - medicine: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stone: Option, - #[serde(skip_serializing_if = "Option::is_none")] - times: Option, - #[serde(skip_serializing_if = "Option::is_none")] - drops: Option>, - series: i32, - #[serde(skip_serializing_if = "Option::is_none")] - medicine_expire_days: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - impl CliFightParams { - fn is_empty(&self) -> bool { - self.medicine.is_none() - && self.stone.is_none() - && self.times.is_none() - && self.drops.is_none() - && self.series == i32::default() - && self.medicine_expire_days.is_none() - && self.stage.is_none() - && self.enable.is_none() - } - - fn stage_only(stage: &str) -> Self { - Self { - stage: Some(stage.to_string()), - ..Self::default() - } - } - } - - /// WPF GUI `FightTask` (`$type = "FightTask"`). - /// - /// Deserialized from the GUI task queue; converts into [`CliFightTask`] / - /// [`MAAValue`] via [`TryFrom`]. Unmapped keys are collected in `unknown`. - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfFightTask { - name: String, - is_enable: bool, - - use_weekly_schedule: bool, - /// Absent when `UseWeeklySchedule` is false in typical GUI exports. - #[serde(default)] - weekly_schedule: Option, - use_optional_stage: bool, - stage_plan: WpfStagePlan, - - use_medicine: bool, - medicine_count: i32, - use_stone: bool, - stone_count: i32, - enable_times_limit: bool, - times_limit: i32, - enable_target_drop: bool, - drop_id: String, - drop_count: i32, - series: i32, - use_expiring_medicine: bool, - medicine_expire_days: i32, - - #[serde(flatten)] - #[allow(dead_code)] - unknown: Map, - } - - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - struct WpfWeeklySchedule { - sunday: bool, - monday: bool, - tuesday: bool, - wednesday: bool, - thursday: bool, - friday: bool, - saturday: bool, - } - - #[derive(Debug, Deserialize)] - #[serde(untagged)] - enum WpfStagePlan { - Single(String), - Many(Vec), - } - - impl WpfFightTask { - pub(super) fn report_disabled(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("FightTask", Some(self.name.clone())); - } - } - - /// Build the open-condition for a fight stage name. - /// - /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) - /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` - /// - Side-story stages listed in `StageActivityV2.json` → `OnSideStory` - fn stage_condition(stage: &str) -> Condition { - use Weekday::*; - match stage { - // 资源本 / 芯片本(同开放日合并) - "CE-6" => Condition::Weekday { - weekdays: vec![Tue, Thu, Sat, Sun], - timezone: TimeOffset::Client(ClientType::Official), - }, - "AP-5" => Condition::Weekday { - weekdays: vec![Mon, Thu, Sat, Sun], - timezone: TimeOffset::Client(ClientType::Official), - }, - "CA-5" => Condition::Weekday { - weekdays: vec![Tue, Wed, Fri, Sun], - timezone: TimeOffset::Client(ClientType::Official), - }, - "SK-5" => Condition::Weekday { - weekdays: vec![Mon, Wed, Fri, Sat], - timezone: TimeOffset::Client(ClientType::Official), - }, - "PR-A-1" | "PR-A-2" => Condition::Weekday { - weekdays: vec![Mon, Thu, Fri, Sun], - timezone: TimeOffset::Client(ClientType::Official), - }, - "PR-B-1" | "PR-B-2" => Condition::Weekday { - weekdays: vec![Mon, Tue, Fri, Sat], - timezone: TimeOffset::Client(ClientType::Official), - }, - "PR-C-1" | "PR-C-2" => Condition::Weekday { - weekdays: vec![Wed, Thu, Sat, Sun], - timezone: TimeOffset::Client(ClientType::Official), - }, - "PR-D-1" | "PR-D-2" => Condition::Weekday { - weekdays: vec![Tue, Wed, Sat, Sun], - timezone: TimeOffset::Client(ClientType::Official), - }, - // 永久开启 - "LS-6" | "Annihilation" | "OF-1" | "OF-F3" => Condition::Always, - _ => { - let side_story = crate::activity::side_story_stages(ClientType::Official); - if side_story.iter().any(|code| code == stage) { - return Condition::OnSideStory { - client: ClientType::Official, - }; - } - warn!( - "FightTask stage `{stage}` has no known open schedule; treating as Always" - ); - Condition::Always - } - } - } - } - - impl From<&WpfFightTask> for CliFightParams { - fn from(task: &WpfFightTask) -> Self { - CliFightParams { - medicine: task.use_medicine.then_some(task.medicine_count), - stone: task.use_stone.then(|| { - let count = task.stone_count; - warn!( - "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" - ); - count - }), - times: task.enable_times_limit.then_some(task.times_limit), - drops: task.enable_target_drop.then(|| { - let mut drops = maa_value::map::StringMap::new(); - drops.insert(task.drop_id.clone(), task.drop_count); - drops - }), - series: task.series, - medicine_expire_days: task - .use_expiring_medicine - .then_some(task.medicine_expire_days), - stage: None, - enable: None, - } - } - } - - impl From<&WpfWeeklySchedule> for Condition { - fn from(schedule: &WpfWeeklySchedule) -> Self { - let days = [ - (schedule.sunday, Weekday::Sun), - (schedule.monday, Weekday::Mon), - (schedule.tuesday, Weekday::Tue), - (schedule.wednesday, Weekday::Wed), - (schedule.thursday, Weekday::Thu), - (schedule.friday, Weekday::Fri), - (schedule.saturday, Weekday::Sat), - ]; - - let weekdays: Vec = days - .into_iter() - .filter_map(|(enabled, day)| enabled.then_some(day)) - .collect(); - - Condition::Weekday { - weekdays, - timezone: TimeOffset::Local, - } - } - } - - impl<'a> TryFrom<&'a WpfStagePlan> for &'a str { - type Error = anyhow::Error; - - fn try_from(plan: &'a WpfStagePlan) -> Result<&'a str> { - match plan { - WpfStagePlan::Single(stage) => Ok(stage.as_str()), - WpfStagePlan::Many(stages) if stages.len() == 1 => Ok(stages[0].as_str()), - WpfStagePlan::Many(_) => { - bail!( - "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ - got a string array; enable UseOptionalStage for multiple stages" - ) - } - } - } - } - - impl<'a> TryFrom<&'a WpfStagePlan> for Vec<&'a str> { - type Error = anyhow::Error; - - fn try_from(plan: &'a WpfStagePlan) -> Result> { - match plan { - WpfStagePlan::Many(stages) => { - if stages.is_empty() { - bail!( - "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" - ); - } - Ok(stages.iter().map(String::as_str).collect()) - } - WpfStagePlan::Single(_) => { - bail!( - "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" - ) - } - } - } - } - - impl TryFrom<&WpfFightTask> for CliFightTask { - type Error = anyhow::Error; - - fn try_from(task: &WpfFightTask) -> Result { - // Shared fight options (medicine, stone, expire days, ...) belong on the task. - // Variants only carry stage selection / open-condition params. - let shared_params = CliFightParams::from(task); - - let mut item = match (task.use_weekly_schedule, task.use_optional_stage) { - // No variants: StagePlan must be a single stage. - (false, false) => { - let stage: &str = (&task.stage_plan).try_into()?; - let mut params = shared_params; - params.stage = Some(stage.to_string()); - CliFightTask { - task_type: TaskType::Fight, - name: task.name.clone(), - params: Some(params), - variants: Vec::new(), - } - } - // One variant gated by weekly schedule; StagePlan must be a single stage. - (true, false) => { - let stage: &str = (&task.stage_plan).try_into()?; - let weekly = Condition::from( - task.weekly_schedule.as_ref().context( - "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", - )?, - ); - CliFightTask { - task_type: TaskType::Fight, - name: task.name.clone(), - params: (!shared_params.is_empty()).then_some(shared_params), - variants: vec![CliFightVariant { - condition: weekly, - params: CliFightParams::stage_only(stage), - }], - } - } - // One variant per optional stage, each with its own open-condition. - (false, true) => { - let stages: Vec<&str> = (&task.stage_plan).try_into()?; - let variants = stages - .into_iter() - .map(|stage| CliFightVariant { - condition: WpfFightTask::stage_condition(stage), - params: CliFightParams::stage_only(stage), - }) - .collect(); - CliFightTask { - task_type: TaskType::Fight, - name: task.name.clone(), - params: (!shared_params.is_empty()).then_some(shared_params), - variants, - } - } - // Weekly schedule AND each stage's open-condition. - (true, true) => { - let stages: Vec<&str> = (&task.stage_plan).try_into()?; - let weekly = Condition::from( - task.weekly_schedule.as_ref().context( - "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", - )?, - ); - let variants = stages - .into_iter() - .map(|stage| CliFightVariant { - condition: Condition::And { - conditions: vec![ - weekly.clone(), - WpfFightTask::stage_condition(stage), - ], - }, - params: CliFightParams::stage_only(stage), - }) - .collect(); - CliFightTask { - task_type: TaskType::Fight, - name: task.name.clone(), - params: (!shared_params.is_empty()).then_some(shared_params), - variants, - } - } - }; - - // MaaCore common switch: params.enable=false disables the task. - if !task.is_enable { - match &mut item.params { - Some(params) => params.enable = Some(false), - None => { - item.params = Some(CliFightParams { - enable: Some(false), - ..CliFightParams::default() - }); - } - } - } - - Ok(item) - } - } - - impl TryFrom<&WpfFightTask> for MAAValue { - type Error = anyhow::Error; - - fn try_from(task: &WpfFightTask) -> Result { - serialize_to_maa_value(&CliFightTask::try_from(task)?) - } - } -} - -mod infrast { - use std::{ - fs, - path::{Path, PathBuf}, - }; - - use anyhow::{Context, Result, bail, ensure}; - use chrono::{NaiveTime, Timelike}; - use log::warn; - use maa_dirs::expand_tilde; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; - use crate::config::task::{Condition, TimeOffset}; - - const HANDLED: &[&str] = &[ - "Mode", - "RoomList", - "UsesOfDrones", - "DormThreshold", - "OriginiumShardAutoReplenishment", - "DormFilterNotStationed", - "DormTrustEnabled", - "ReceptionMessageBoard", - "ReceptionClueExchange", - "SendClue", - "ContinueTraining", - "CustomFileType", - "Filename", - "PlanSelect", - ]; - - const MINUTES_PER_DAY: u32 = 24 * 60; - const CUSTOM_MODE: i32 = 10000; - - #[derive(Debug, Serialize)] - struct CliInfrastTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliInfrastParams, - #[serde(skip_serializing_if = "Vec::is_empty")] - variants: Vec, - } - - #[derive(Debug, Serialize)] - struct CliInfrastVariant { - condition: Condition, - params: CliInfrastVariantParams, - } - - #[derive(Debug, Serialize)] - struct CliInfrastVariantParams { - plan_index: i32, - } - - #[derive(Debug, Serialize)] - struct CliInfrastParams { - #[serde(skip_serializing_if = "Option::is_none")] - mode: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - facility: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - drones: Option, - #[serde(skip_serializing_if = "Option::is_none")] - threshold: Option, - #[serde(skip_serializing_if = "Option::is_none")] - replenish: Option, - #[serde(skip_serializing_if = "Option::is_none")] - dorm_notstationed_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - dorm_trust_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reception_message_board: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reception_clue_exchange: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reception_send_clue: Option, - #[serde(skip_serializing_if = "Option::is_none")] - continue_training: Option, - #[serde(skip_serializing_if = "Option::is_none")] - filename: Option, - #[serde(skip_serializing_if = "Option::is_none")] - plan_index: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - struct WpfInfrastRoom { - room: String, - } - - /// WPF GUI `InfrastTask` (`$type = "InfrastTask"`). - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfInfrastTask { - name: String, - is_enable: bool, - mode: String, - #[serde(default)] - filename: String, - #[serde(default = "default_plan_select")] - plan_select: i32, - #[serde(default)] - continue_training: bool, - room_list: Vec, - uses_of_drones: String, - dorm_threshold: i32, - originium_shard_auto_replenishment: bool, - dorm_filter_not_stationed: bool, - dorm_trust_enabled: bool, - reception_message_board: bool, - reception_clue_exchange: bool, - send_clue: bool, - #[serde(flatten)] - unknown: Map, - } - - fn default_plan_select() -> i32 { - -1 - } - - #[derive(Debug, Deserialize)] - struct CustomInfrastFile { - plans: Vec, - } - - #[derive(Debug, Deserialize)] - struct CustomInfrastPlan { - #[serde(default)] - period: Vec>, - } - - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - enum MergedSpan { - /// `[start, end)` on a single day; `end == MINUTES_PER_DAY` means until midnight. - NonWrap { start: u32, end: u32 }, - /// `[start, 24:00)` plus `[00:00, end)`. - Wrap { start: u32, end: u32 }, - } - - impl WpfInfrastTask { - fn mapped_mode(&self) -> Option { - match self.mode.as_str() { - "Normal" => Some(0), - "Rotation" => Some(20000), - _ => None, - } - } - - fn wants_custom(&self) -> bool { - self.mode == "Custom" || (!self.filename.is_empty() && self.mode != "Rotation") - } - - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("InfrastTask", Some(self.name.clone())); - } - if !self.wants_custom() && self.mapped_mode().is_none() { - summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); - } - report_unknown_fields( - summary, - "InfrastTask", - Some(self.name.clone()), - &self.unknown, - HANDLED, - ); - } - - pub(super) fn to_maa_value( - &self, - summary: &mut MigrationSummary, - custom_schedule: Option<&Path>, - schedule_applied: &mut bool, - ) -> Result { - serialize_to_maa_value(&self.to_cli_task(summary, custom_schedule, schedule_applied)?) - } - - fn to_cli_task( - &self, - summary: &mut MigrationSummary, - custom_schedule: Option<&Path>, - schedule_applied: &mut bool, - ) -> Result { - if let Some(path) = custom_schedule - && self.wants_custom() - { - *schedule_applied = true; - return self.try_custom_task(&path.to_string_lossy()); - } - - if self.wants_custom() { - match self.try_custom_task(&self.filename) { - Ok(task) => return Ok(task), - Err(err) => { - warn!( - "InfrastTask custom schedule skipped ({err}); writing default infrast mode" - ); - summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); - if !self.filename.is_empty() { - summary.skip_field("InfrastTask", Some(self.name.clone()), "Filename"); - } - } - } - } - Ok(self.default_task()) - } - - fn default_task(&self) -> CliInfrastTask { - let mode = if self.wants_custom() { - Some(0) - } else { - self.mapped_mode() - }; - CliInfrastTask { - task_type: TaskType::Infrast, - name: self.name.clone(), - params: self.shared_params(mode, None, None), - variants: Vec::new(), - } - } - - fn shared_params( - &self, - mode: Option, - filename: Option, - plan_index: Option, - ) -> CliInfrastParams { - let custom = mode == Some(CUSTOM_MODE); - CliInfrastParams { - mode, - facility: self.room_list.iter().map(|r| r.room.clone()).collect(), - drones: (!custom).then(|| self.uses_of_drones.clone()), - threshold: Some(self.dorm_threshold as f32 / 100.0), - replenish: Some(self.originium_shard_auto_replenishment), - dorm_notstationed_enabled: Some(self.dorm_filter_not_stationed), - dorm_trust_enabled: Some(self.dorm_trust_enabled), - reception_message_board: Some(self.reception_message_board), - reception_clue_exchange: Some(self.reception_clue_exchange), - reception_send_clue: Some(self.send_clue), - continue_training: Some(self.continue_training), - filename, - plan_index, - enable: (!self.is_enable).then_some(false), - } - } - - fn try_custom_task(&self, filename: &str) -> Result { - ensure!(!filename.is_empty(), "custom mode has empty Filename"); - let filename = resolve_infrast_filename(filename); - let schedule = load_custom_schedule(&filename)?; - let n_plans = i32::try_from(schedule.plans.len()) - .context("too many plans in custom infrast file")?; - - let time_rotation = self.plan_select < 0; - let variants = if time_rotation { - match variants_from_schedule(&schedule) { - Ok(variants) => variants, - Err(err) => { - warn!( - "InfrastTask custom schedule periods could not be converted ({err}); using plan_index" - ); - Vec::new() - } - } - } else { - Vec::new() - }; - - let plan_index = if variants.is_empty() { - let in_range = self.plan_select >= 0 && self.plan_select < n_plans; - Some(if in_range { self.plan_select } else { 0 }) - } else { - None - }; - - if let Some(note) = schedule_note(&schedule, &variants) { - eprintln!("{note}"); - } - - Ok(CliInfrastTask { - task_type: TaskType::Infrast, - name: self.name.clone(), - params: self.shared_params( - Some(CUSTOM_MODE), - Some(filename.to_string_lossy().into_owned()), - plan_index, - ), - variants, - }) - } - } - - fn resolve_infrast_filename(path: &str) -> PathBuf { - expand_tilde(Path::new(path)).into_owned() - } - - fn load_custom_schedule(path: &Path) -> Result { - let text = fs::read_to_string(path) - .with_context(|| format!("failed to read custom infrast file `{}`", path.display()))?; - let file: CustomInfrastFile = serde_json::from_str(&text) - .with_context(|| format!("failed to parse custom infrast file `{}`", path.display()))?; - ensure!(!file.plans.is_empty(), "custom infrast file has no plans"); - Ok(file) - } - - fn variants_from_schedule(file: &CustomInfrastFile) -> Result> { - let has_period: Vec = file.plans.iter().map(|p| !p.period.is_empty()).collect(); - if has_period.iter().any(|has| !*has) { - if has_period.iter().any(|has| *has) { - bail!("some plans have period and some do not"); - } - return Ok(Vec::new()); - } - - let spans: Vec = file - .plans - .iter() - .map(|plan| merge_plan_periods(&plan.period)) - .collect::>()?; - let remainders = assign_day_remainders(&spans); - let days = remainders.iter().copied().max().unwrap_or(0) + 1; - - spans - .iter() - .zip(remainders) - .enumerate() - .map(|(index, (span, remainder))| { - Ok(CliInfrastVariant { - condition: span_condition(*span, days, remainder), - params: CliInfrastVariantParams { - plan_index: i32::try_from(index)?, - }, - }) - }) - .collect() - } - - fn schedule_note(file: &CustomInfrastFile, variants: &[CliInfrastVariant]) -> Option { - if variants.is_empty() { - return Some(format!( - "InfrastTask: custom schedule with {} plan(s); no period data, using plan_index", - file.plans.len() - )); - } - - let days = variants - .iter() - .filter_map(|v| match &v.condition { - Condition::And { conditions } | Condition::Or { conditions } => { - conditions.iter().find_map(|c| match c { - Condition::DayMod { divisor, .. } => Some(*divisor), - Condition::And { conditions } => { - conditions.iter().find_map(|inner| match inner { - Condition::DayMod { divisor, .. } => Some(*divisor), - _ => None, - }) - } - _ => None, - }) - } - Condition::DayMod { divisor, .. } => Some(*divisor), - _ => None, - }) - .max() - .unwrap_or(1); - let times: Vec = file - .plans - .iter() - .filter_map(|plan| plan.period.first()) - .filter_map(|pair| pair.first()) - .cloned() - .collect(); - Some(format!( - "InfrastTask: custom schedule ({days} day(s), {} shift(s)); suggested run times: {}", - file.plans.len(), - times.join(", ") - )) - } - - fn parse_clock(value: &str) -> Result { - NaiveTime::parse_from_str(value, "%H:%M") - .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S")) - .with_context(|| format!("invalid time `{value}` in custom infrast period")) - } - - fn time_to_minutes(time: NaiveTime) -> u32 { - time.num_seconds_from_midnight() / 60 - } - - fn minutes_to_time(minutes: u32) -> Option { - if minutes >= MINUTES_PER_DAY { - None - } else { - NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0) - } - } - - fn period_to_segments(start: NaiveTime, end: NaiveTime) -> Vec<(u32, u32)> { - let start_m = time_to_minutes(start); - let mut end_m = time_to_minutes(end); - if end.hour() == 23 && end.minute() == 59 { - end_m = MINUTES_PER_DAY; - } - if start_m == end_m { - Vec::new() - } else if start_m < end_m { - vec![(start_m, end_m)] - } else { - vec![(start_m, MINUTES_PER_DAY), (0, end_m)] - } - } - - fn merge_plan_periods(period: &[Vec]) -> Result { - let mut segments = Vec::new(); - for entry in period { - ensure!( - entry.len() == 2, - "period entry must be [start, end], got {entry:?}" - ); - let start = parse_clock(&entry[0])?; - let end = parse_clock(&entry[1])?; - segments.extend(period_to_segments(start, end)); - } - ensure!(!segments.is_empty(), "plan period is empty after parsing"); - segments.sort_by_key(|seg| seg.0); - - let mut merged: Vec<(u32, u32)> = Vec::new(); - for (start, end) in segments { - if let Some(last) = merged.last_mut() - && start <= last.1 - { - last.1 = last.1.max(end); - } else { - merged.push((start, end)); - } - } - - match merged.as_slice() { - [(start, end)] => Ok(MergedSpan::NonWrap { - start: *start, - end: *end, - }), - [(0, morning_end), (night_start, night_end)] if *night_end == MINUTES_PER_DAY => { - Ok(MergedSpan::Wrap { - start: *night_start, - end: *morning_end, - }) - } - other => bail!("unsupported disjoint period segments: {other:?}"), - } - } - - fn span_coverage(span: MergedSpan) -> [bool; MINUTES_PER_DAY as usize] { - let mut bits = [false; MINUTES_PER_DAY as usize]; - match span { - MergedSpan::NonWrap { start, end } => { - for minute in start..end { - bits[minute as usize] = true; - } - } - MergedSpan::Wrap { start, end } => { - for minute in start..MINUTES_PER_DAY { - bits[minute as usize] = true; - } - for minute in 0..end { - bits[minute as usize] = true; - } - } - } - bits - } - - fn coverages_overlap(left: &[bool], right: &[bool]) -> bool { - left.iter().zip(right).any(|(a, b)| *a && *b) - } - - fn assign_day_remainders(spans: &[MergedSpan]) -> Vec { - let coverages: Vec<[bool; MINUTES_PER_DAY as usize]> = - spans.iter().copied().map(span_coverage).collect(); - let mut occupied: Vec<[bool; MINUTES_PER_DAY as usize]> = Vec::new(); - let mut remainders = Vec::with_capacity(spans.len()); - for coverage in &coverages { - let remainder = occupied - .iter() - .position(|day| !coverages_overlap(day, coverage)) - .unwrap_or_else(|| { - occupied.push([false; MINUTES_PER_DAY as usize]); - occupied.len() - 1 - }); - for (dst, src) in occupied[remainder].iter_mut().zip(coverage) { - *dst |= src; - } - remainders.push(remainder as u32); - } - remainders - } - - fn day_mod(divisor: u32, remainder: u32) -> Condition { - Condition::DayMod { - divisor, - remainder, - timezone: TimeOffset::Local, - } - } - - fn time_range(start: Option, end: Option) -> Condition { - Condition::Time { - start, - end, - timezone: TimeOffset::Local, - } - } - - fn span_condition(span: MergedSpan, days: u32, remainder: u32) -> Condition { - let time = match span { - MergedSpan::NonWrap { start, end } => { - time_range(minutes_to_time(start), minutes_to_time(end)) - } - MergedSpan::Wrap { start, end } => { - time_range(minutes_to_time(start), minutes_to_time(end)) - } - }; - - if days <= 1 { - return time; - } - - match span { - MergedSpan::NonWrap { .. } => Condition::And { - conditions: vec![day_mod(days, remainder), time], - }, - MergedSpan::Wrap { start, end } => Condition::Or { - conditions: vec![ - Condition::And { - conditions: vec![ - day_mod(days, remainder), - time_range(minutes_to_time(start), None), - ], - }, - Condition::And { - conditions: vec![ - day_mod(days, (remainder + 1) % days), - time_range(None, minutes_to_time(end)), - ], - }, - ], - }, - } - } - - #[cfg(test)] - #[cfg_attr(coverage_nightly, coverage(off))] - mod tests { - use super::*; - use crate::config::migrate::MigrationSummary; - - fn sample_task(mode: &str, filename: &str, plan_select: i32) -> WpfInfrastTask { - serde_json::from_value(serde_json::json!({ - "Name": "", - "IsEnable": true, - "Mode": mode, - "Filename": filename, - "PlanSelect": plan_select, - "ContinueTraining": true, - "RoomList": [{"Room": "Mfg"}, {"Room": "Trade"}], - "UsesOfDrones": "Money", - "DormThreshold": 30, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "DormTrustEnabled": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - })) - .unwrap() - } - - fn write_schedule(contents: &str) -> tempfile::NamedTempFile { - let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); - std::fs::write(file.path(), contents).unwrap(); - file - } - - fn to_json(task: &WpfInfrastTask, summary: &mut MigrationSummary) -> serde_json::Value { - let value = task.to_maa_value(summary, None, &mut false).unwrap(); - serde_json::to_value(value).unwrap() - } - - fn infrast_profile(mode: &str, filename: &str) -> Value { - serde_json::json!({ - "TaskQueue": [{ - "$type": "InfrastTask", - "Name": "", - "IsEnable": true, - "Mode": mode, - "Filename": filename, - "PlanSelect": -1, - "ContinueTraining": true, - "RoomList": [{"Room": "Mfg"}, {"Room": "Trade"}], - "UsesOfDrones": "Money", - "DormThreshold": 30, - "OriginiumShardAutoReplenishment": true, - "DormFilterNotStationed": true, - "DormTrustEnabled": true, - "ReceptionMessageBoard": true, - "ReceptionClueExchange": true, - "SendClue": true, - }], - "Gui": {} - }) - } - - #[test] - fn normal_mode_keeps_default_layout() { - let task = sample_task("Normal", "", -1); - let mut summary = MigrationSummary::default(); - let json = to_json(&task, &mut summary); - assert!(summary.is_empty()); - assert_eq!(json["params"]["mode"], 0); - assert!( - json.get("variants").is_none() || json["variants"].as_array().unwrap().is_empty() - ); - assert!(json["params"].get("filename").is_none()); - } - - #[test] - fn missing_custom_file_falls_back_to_default() { - let task = sample_task("Custom", "/no/such/infrast-plan.json", -1); - let mut summary = MigrationSummary::default(); - let json = to_json(&task, &mut summary); - assert_eq!(json["params"]["mode"], 0); - assert!(json["params"].get("filename").is_none()); - assert!(summary.skipped_fields.iter().any(|f| f.field == "Filename")); - assert!(summary.skipped_fields.iter().any(|f| f.field == "Mode")); - } - - #[test] - fn tilde_in_filename_expands_to_home() { - let expanded = resolve_infrast_filename("~/一图流-153-一天两换-MAA.json"); - assert_eq!( - expanded, - expand_tilde(Path::new("~/一图流-153-一天两换-MAA.json")).into_owned() - ); - assert!(expanded.is_absolute()); - assert!(!expanded.starts_with("~")); - } - - #[test] - fn one_day_two_shifts_generates_time_variants() { - let schedule = r#"{ - "plans": [ - {"name": "A", "period": [["04:00", "16:00"]]}, - {"name": "B", "period": [["16:00", "23:59"], ["00:00", "04:00"]]} - ] - }"#; - let file = write_schedule(schedule); - let task = sample_task("Custom", file.path().to_str().unwrap(), -1); - let mut summary = MigrationSummary::default(); - let json = to_json(&task, &mut summary); - assert!(summary.is_empty()); - assert_eq!(json["params"]["mode"], CUSTOM_MODE); - assert_eq!(json["params"]["filename"], file.path().to_str().unwrap()); - let variants = json["variants"].as_array().unwrap(); - assert_eq!(variants.len(), 2); - assert_eq!(variants[0]["params"]["plan_index"], 0); - assert_eq!(variants[0]["condition"]["type"], "Time"); - assert_eq!(variants[0]["condition"]["start"], "04:00:00"); - assert_eq!(variants[0]["condition"]["end"], "16:00:00"); - assert_eq!(variants[1]["params"]["plan_index"], 1); - assert_eq!(variants[1]["condition"]["type"], "Time"); - assert_eq!(variants[1]["condition"]["start"], "16:00:00"); - assert_eq!(variants[1]["condition"]["end"], "04:00:00"); - } - - #[test] - fn two_day_six_shifts_uses_day_mod() { - let schedule = r#"{ - "plans": [ - {"period": [["04:00", "12:00"]]}, - {"period": [["12:00", "20:00"]]}, - {"period": [["20:00", "23:59"], ["00:00", "04:00"]]}, - {"period": [["04:00", "12:00"]]}, - {"period": [["12:00", "20:00"]]}, - {"period": [["20:00", "23:59"], ["00:00", "04:00"]]} - ] - }"#; - let file = write_schedule(schedule); - let task = sample_task("Custom", file.path().to_str().unwrap(), -1); - let mut summary = MigrationSummary::default(); - let json = to_json(&task, &mut summary); - let variants = json["variants"].as_array().unwrap(); - assert_eq!(variants.len(), 6); - assert_eq!(variants[0]["condition"]["type"], "And"); - assert_eq!(variants[0]["condition"]["conditions"][0]["type"], "DayMod"); - assert_eq!(variants[0]["condition"]["conditions"][0]["divisor"], 2); - assert!( - variants[0]["condition"]["conditions"][0] - .get("remainder") - .is_none() - ); - assert_eq!(variants[3]["condition"]["conditions"][0]["remainder"], 1); - assert_eq!(variants[2]["condition"]["type"], "Or"); - assert_eq!(variants[5]["condition"]["type"], "Or"); - assert_eq!(variants[5]["params"]["plan_index"], 5); - } - - #[test] - fn optional_shift_uses_plan_index() { - let schedule = r#"{"plans": [{"name": "A"}, {"name": "B"}]}"#; - let file = write_schedule(schedule); - let task = sample_task("Custom", file.path().to_str().unwrap(), 1); - let mut summary = MigrationSummary::default(); - let json = to_json(&task, &mut summary); - assert_eq!(json["params"]["mode"], CUSTOM_MODE); - assert_eq!(json["params"]["plan_index"], 1); - assert!( - json.get("variants").is_none() || json["variants"].as_array().unwrap().is_empty() - ); - } - - #[test] - fn custom_schedule_overrides_wpf_filename() { - let schedule = r#"{ - "plans": [ - {"name": "A", "period": [["04:00", "16:00"]]}, - {"name": "B", "period": [["16:00", "23:59"], ["00:00", "04:00"]]} - ] - }"#; - let file = write_schedule(schedule); - let profile = infrast_profile("Custom", "/original/missing.json"); - let (value, summary) = super::super::migrate(profile, Some(file.path())).unwrap(); - assert!(summary.is_empty()); - let json = serde_json::to_value(value).unwrap(); - let task = &json["tasks"][0]; - assert_eq!(task["params"]["mode"], CUSTOM_MODE); - assert_eq!( - task["params"]["filename"], - resolve_infrast_filename(file.path().to_str().unwrap()) - .to_string_lossy() - .as_ref() - ); - assert_eq!(task["variants"].as_array().unwrap().len(), 2); - } - - #[test] - fn custom_schedule_errors_when_profile_is_not_custom() { - let profile = infrast_profile("Normal", ""); - let err = super::super::migrate(profile, Some(Path::new("plan.json"))).unwrap_err(); - assert!(err.to_string().contains("--custom-schedule"), "{err}"); - } - - #[test] - fn custom_schedule_errors_when_override_file_missing() { - let profile = infrast_profile("Custom", "/original/missing.json"); - let err = super::super::migrate(profile, Some(Path::new("/no/such/override.json"))) - .unwrap_err(); - assert!( - err.to_string() - .contains("failed to read custom infrast file"), - "{err}" - ); - } - - #[test] - fn assign_days_packs_overlapping_clock_spans() { - let spans = [ - MergedSpan::NonWrap { - start: 4 * 60, - end: 12 * 60, - }, - MergedSpan::NonWrap { - start: 12 * 60, - end: 20 * 60, - }, - MergedSpan::Wrap { - start: 20 * 60, - end: 4 * 60, - }, - MergedSpan::NonWrap { - start: 4 * 60, - end: 12 * 60, - }, - MergedSpan::NonWrap { - start: 12 * 60, - end: 20 * 60, - }, - MergedSpan::Wrap { - start: 20 * 60, - end: 4 * 60, - }, - ]; - assert_eq!(assign_day_remainders(&spans), vec![0, 0, 0, 1, 1, 1]); - } - } -} - -mod recruit { - use anyhow::Result; - use log::warn; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; - - const HANDLED: &[&str] = &[ - "MaxTimes", - "ExtraTagMode", - "RefreshLevel3", - "ForceRefresh", - "Level6Choose", - "Level6Time", - "Level5Choose", - "Level5Time", - "Level4Choose", - "Level4Time", - "Level3Choose", - "Level3Time", - "PreferTagEnabled", - "Level3PreferTags", - "PreserveTagEnabled", - "PreserveTagList", - ]; - - #[derive(Debug, Serialize)] - struct CliRecruitTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliRecruitParams, - } - - #[derive(Debug, Serialize)] - struct CliRecruitParams { - #[serde(skip_serializing_if = "Option::is_none")] - times: Option, - #[serde(skip_serializing_if = "Option::is_none")] - extra_tags_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - refresh: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - select: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - confirm: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - recruitment_time: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - first_tags: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - preserve_tags: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - /// WPF GUI `RecruitTask` (`$type = "RecruitTask"`). - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfRecruitTask { - name: String, - is_enable: bool, - max_times: i32, - extra_tag_mode: i32, - refresh_level3: bool, - force_refresh: bool, - level3_choose: bool, - #[serde(default)] - level3_time: Option, - level4_choose: bool, - #[serde(default)] - level4_time: Option, - level5_choose: bool, - #[serde(default)] - level5_time: Option, - level6_choose: bool, - #[serde(default)] - level6_time: Option, - prefer_tag_enabled: bool, - #[serde(default)] - level3_prefer_tags: Vec, - preserve_tag_enabled: bool, - #[serde(default)] - preserve_tag_list: Vec, - #[serde(flatten)] - unknown: Map, - } - - impl WpfRecruitTask { - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("RecruitTask", Some(self.name.clone())); - } - // GUI ForceRefresh is not maa-cli/MaaCore `expedite`; maa-cli does not support it. - if self.force_refresh { - summary.skip_field("RecruitTask", Some(self.name.clone()), "ForceRefresh"); - warn!( - "RecruitTask ForceRefresh is not supported by maa-cli; this setting has no effect" - ); - } - if self.level6_choose { - warn!( - "RecruitTask Level6Choose is enabled; recruitment will auto-confirm 6★ operators (dangerous)" - ); - } - report_unknown_fields( - summary, - "RecruitTask", - Some(self.name.clone()), - &self.unknown, - HANDLED, - ); - } - - pub(super) fn to_maa_value(&self) -> Result { - serialize_to_maa_value(&CliRecruitTask::try_from(self)?) - } - } - - impl TryFrom<&WpfRecruitTask> for CliRecruitTask { - type Error = anyhow::Error; - - fn try_from(task: &WpfRecruitTask) -> Result { - let levels = [ - (task.level6_choose, task.level6_time, 6), - (task.level5_choose, task.level5_time, 5), - (task.level4_choose, task.level4_time, 4), - (task.level3_choose, task.level3_time, 3), - ]; - - let mut select = Vec::new(); - let mut confirm = Vec::new(); - let mut recruitment_time = maa_value::map::StringMap::new(); - for (choose, minutes, level) in levels { - if choose { - select.push(level); - confirm.push(level); - } - if let Some(minutes) = minutes { - recruitment_time.insert(level.to_string(), minutes); - } - } - - let first_tags = (task.prefer_tag_enabled && !task.level3_prefer_tags.is_empty()) - .then(|| task.level3_prefer_tags.clone()); - let preserve_tags = (task.preserve_tag_enabled && !task.preserve_tag_list.is_empty()) - .then(|| task.preserve_tag_list.clone()); - - Ok(CliRecruitTask { - task_type: TaskType::Recruit, - name: task.name.clone(), - params: CliRecruitParams { - times: Some(task.max_times), - extra_tags_mode: Some(task.extra_tag_mode), - refresh: Some(task.refresh_level3), - select, - confirm, - recruitment_time: (!recruitment_time.is_empty()).then_some(recruitment_time), - first_tags, - preserve_tags, - enable: (!task.is_enable).then_some(false), - }, - }) - } - } -} - -mod mall { - use anyhow::Result; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value, split_semi_list}; - - const HANDLED: &[&str] = &[ - "Shopping", - "CreditFight", - "CreditFightFormation", - "VisitFriends", - "FirstList", - "BlackList", - "ShoppingIgnoreBlackListWhenFull", - "OnlyBuyDiscount", - "ReserveMaxCredit", - ]; - - #[derive(Debug, Serialize)] - struct CliMallTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliMallParams, - } - - #[derive(Debug, Serialize)] - struct CliMallParams { - #[serde(skip_serializing_if = "Option::is_none")] - shopping: Option, - #[serde(skip_serializing_if = "Option::is_none")] - credit_fight: Option, - #[serde(skip_serializing_if = "Option::is_none")] - formation_index: Option, - #[serde(skip_serializing_if = "Option::is_none")] - visit_friends: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - buy_first: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - blacklist: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - force_shopping_if_credit_full: Option, - #[serde(skip_serializing_if = "Option::is_none")] - only_buy_discount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reserve_max_credit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - /// WPF GUI `MallTask` (`$type = "MallTask"`). - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfMallTask { - name: String, - is_enable: bool, - shopping: bool, - credit_fight: bool, - credit_fight_formation: i32, - visit_friends: bool, - first_list: String, - black_list: String, - shopping_ignore_black_list_when_full: bool, - only_buy_discount: bool, - reserve_max_credit: bool, - #[serde(flatten)] - unknown: Map, - } - - impl WpfMallTask { - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("MallTask", Some(self.name.clone())); - } - report_unknown_fields( - summary, - "MallTask", - Some(self.name.clone()), - &self.unknown, - HANDLED, - ); - } - - pub(super) fn to_maa_value(&self) -> Result { - serialize_to_maa_value(&CliMallTask::try_from(self)?) - } - } - - impl TryFrom<&WpfMallTask> for CliMallTask { - type Error = anyhow::Error; - - fn try_from(task: &WpfMallTask) -> Result { - Ok(CliMallTask { - task_type: TaskType::Mall, - name: task.name.clone(), - params: CliMallParams { - shopping: Some(task.shopping), - credit_fight: Some(task.credit_fight), - formation_index: Some(task.credit_fight_formation), - visit_friends: Some(task.visit_friends), - buy_first: split_semi_list(&task.first_list), - blacklist: split_semi_list(&task.black_list), - force_shopping_if_credit_full: Some(task.shopping_ignore_black_list_when_full), - only_buy_discount: Some(task.only_buy_discount), - reserve_max_credit: Some(task.reserve_max_credit), - enable: (!task.is_enable).then_some(false), - }, - }) - } - } -} - -mod award { - use anyhow::Result; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; - - const HANDLED: &[&str] = &[ - "Award", - "Mail", - "FreeGacha", - "Orundum", - "Mining", - "SpecialAccess", - ]; - - #[derive(Debug, Serialize)] - struct CliAwardTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliAwardParams, - } - - #[derive(Debug, Serialize)] - struct CliAwardParams { - #[serde(skip_serializing_if = "Option::is_none")] - award: Option, - #[serde(skip_serializing_if = "Option::is_none")] - mail: Option, - #[serde(skip_serializing_if = "Option::is_none")] - recruit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - orundum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - mining: Option, - #[serde(skip_serializing_if = "Option::is_none")] - specialaccess: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - /// WPF GUI `AwardTask` (`$type = "AwardTask"`). - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfAwardTask { - name: String, - is_enable: bool, - award: bool, - mail: bool, - free_gacha: bool, - orundum: bool, - mining: bool, - special_access: bool, - #[serde(flatten)] - unknown: Map, - } - - impl WpfAwardTask { - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("AwardTask", Some(self.name.clone())); - } - report_unknown_fields( - summary, - "AwardTask", - Some(self.name.clone()), - &self.unknown, - HANDLED, - ); - } - - pub(super) fn to_maa_value(&self) -> Result { - serialize_to_maa_value(&CliAwardTask::try_from(self)?) - } - } - - impl TryFrom<&WpfAwardTask> for CliAwardTask { - type Error = anyhow::Error; - - fn try_from(task: &WpfAwardTask) -> Result { - Ok(CliAwardTask { - task_type: TaskType::Award, - name: task.name.clone(), - params: CliAwardParams { - award: Some(task.award), - mail: Some(task.mail), - recruit: Some(task.free_gacha), - orundum: Some(task.orundum), - mining: Some(task.mining), - specialaccess: Some(task.special_access), - enable: (!task.is_enable).then_some(false), - }, - }) - } - } -} - -mod roguelike { - use anyhow::Result; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; - - const HANDLED: &[&str] = &[ - "Theme", - "Mode", - "Squad", - "Roles", - "CoreChar", - "StartCount", - "Difficulty", - "Investment", - "InvestCount", - "InvestWithMoreScore", - "StopWhenDepositFull", - "StopAtFinalBoss", - "StopWhenLevelMax", - "UseSupport", - "UseSupportNonFriend", - "RefreshTraderWithDice", - "StartWithEliteTwo", - "StartWithEliteTwoOnly", - ]; - - #[derive(Debug, Serialize)] - struct CliRoguelikeTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliRoguelikeParams, - } - - #[derive(Debug, Serialize)] - struct CliRoguelikeParams { - #[serde(skip_serializing_if = "Option::is_none")] - theme: Option, - #[serde(skip_serializing_if = "Option::is_none")] - mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - squad: Option, - #[serde(skip_serializing_if = "Option::is_none")] - roles: Option, - #[serde(skip_serializing_if = "Option::is_none")] - core_char: Option, - #[serde(skip_serializing_if = "Option::is_none")] - starts_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - difficulty: Option, - #[serde(skip_serializing_if = "Option::is_none")] - investment_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - investments_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - investment_with_more_score: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_when_investment_full: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_at_final_boss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_at_max_level: Option, - #[serde(skip_serializing_if = "Option::is_none")] - use_support: Option, - #[serde(skip_serializing_if = "Option::is_none")] - use_nonfriend_support: Option, - #[serde(skip_serializing_if = "Option::is_none")] - refresh_trader_with_dice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - start_with_elite_two: Option, - #[serde(skip_serializing_if = "Option::is_none")] - only_start_with_elite_two: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - /// WPF GUI `RoguelikeTask` (`$type = "RoguelikeTask"`). - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfRoguelikeTask { - name: String, - is_enable: bool, - theme: String, - mode: String, - squad: String, - roles: String, - core_char: String, - start_count: i32, - difficulty: i32, - investment: bool, - invest_count: i32, - invest_with_more_score: bool, - stop_when_deposit_full: bool, - stop_at_final_boss: bool, - stop_when_level_max: bool, - use_support: bool, - use_support_non_friend: bool, - refresh_trader_with_dice: bool, - start_with_elite_two: bool, - start_with_elite_two_only: bool, - #[serde(flatten)] - unknown: Map, - } - - impl WpfRoguelikeTask { - fn mapped_mode(&self) -> Option { - match self.mode.as_str() { - "Exp" => Some(0), - "Investment" => Some(1), - "Collect" => Some(4), - "CollapsalParadigms" => Some(5), - "MonthlySquad" => Some(6), - "DeepExploration" => Some(7), - _ => None, - } - } - - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("RoguelikeTask", Some(self.name.clone())); - } - if self.mapped_mode().is_none() { - summary.skip_field("RoguelikeTask", Some(self.name.clone()), "Mode"); - } - report_unknown_fields( - summary, - "RoguelikeTask", - Some(self.name.clone()), - &self.unknown, - HANDLED, - ); - } - - pub(super) fn to_maa_value(&self) -> Result { - serialize_to_maa_value(&CliRoguelikeTask::try_from(self)?) - } - } - - impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { - type Error = anyhow::Error; - - fn try_from(task: &WpfRoguelikeTask) -> Result { - Ok(CliRoguelikeTask { - task_type: TaskType::Roguelike, - name: task.name.clone(), - params: CliRoguelikeParams { - theme: Some(task.theme.clone()), - mode: task.mapped_mode(), - squad: Some(task.squad.clone()), - roles: Some(task.roles.clone()), - core_char: Some(task.core_char.clone()), - starts_count: Some(task.start_count), - difficulty: (task.difficulty != i32::MAX).then_some(task.difficulty), - investment_enabled: Some(task.investment), - investments_count: Some(task.invest_count), - investment_with_more_score: Some(task.invest_with_more_score), - stop_when_investment_full: Some(task.stop_when_deposit_full), - stop_at_final_boss: Some(task.stop_at_final_boss), - stop_at_max_level: Some(task.stop_when_level_max), - use_support: Some(task.use_support), - use_nonfriend_support: Some(task.use_support_non_friend), - refresh_trader_with_dice: Some(task.refresh_trader_with_dice), - start_with_elite_two: Some(task.start_with_elite_two), - only_start_with_elite_two: Some(task.start_with_elite_two_only), - enable: (!task.is_enable).then_some(false), - }, - }) - } - } -} - -mod reclamation { - use anyhow::Result; - use maa_types::TaskType; - use maa_value::prelude::*; - use serde::{Deserialize, Serialize}; - use serde_json::{Map, Value}; - - use super::{MigrationSummary, report_unknown_fields, serialize_to_maa_value}; - - const HANDLED: &[&str] = &[ - "Theme", - "Mode", - "ToolToCraft", - "IncrementMode", - "MaxCraftCountPerRound", - ]; - - #[derive(Debug, Serialize)] - struct CliReclamationTask { - #[serde(rename = "type")] - task_type: TaskType, - #[serde(skip_serializing_if = "str::is_empty")] - name: String, - params: CliReclamationParams, - } - - #[derive(Debug, Serialize)] - struct CliReclamationParams { - #[serde(skip_serializing_if = "Option::is_none")] - theme: Option, - #[serde(skip_serializing_if = "Option::is_none")] - mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools_to_craft: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - increment_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - num_craft_batches: Option, - #[serde(skip_serializing_if = "Option::is_none")] - enable: Option, - } - - /// WPF GUI `ReclamationTask` (`$type = "ReclamationTask"`). - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub(super) struct WpfReclamationTask { - name: String, - is_enable: bool, - theme: String, - mode: String, - tool_to_craft: String, - increment_mode: i32, - max_craft_count_per_round: i32, - #[serde(flatten)] - unknown: Map, - } - - impl WpfReclamationTask { - fn mapped_mode(&self) -> Option { - match self.mode.as_str() { - "ProsperityNoSave" => Some(0), - "ProsperityInSave" => Some(1), - _ => None, - } - } - - pub(super) fn report_to(&self, summary: &mut MigrationSummary) { - if !self.is_enable { - summary.disable_task("ReclamationTask", Some(self.name.clone())); - } - if self.mapped_mode().is_none() { - summary.skip_field("ReclamationTask", Some(self.name.clone()), "Mode"); - } - report_unknown_fields( - summary, - "ReclamationTask", - Some(self.name.clone()), - &self.unknown, - HANDLED, - ); - } - - pub(super) fn to_maa_value(&self) -> Result { - serialize_to_maa_value(&CliReclamationTask::try_from(self)?) - } - } - - impl TryFrom<&WpfReclamationTask> for CliReclamationTask { - type Error = anyhow::Error; - - fn try_from(task: &WpfReclamationTask) -> Result { - Ok(CliReclamationTask { - task_type: TaskType::Reclamation, - name: task.name.clone(), - params: CliReclamationParams { - theme: Some(task.theme.clone()), - mode: task.mapped_mode(), - tools_to_craft: (!task.tool_to_craft.is_empty()) - .then(|| vec![task.tool_to_craft.clone()]), - increment_mode: Some(task.increment_mode), - num_craft_batches: Some(task.max_craft_count_per_round), - enable: (!task.is_enable).then_some(false), - }, - }) - } - } -} diff --git a/crates/maa-cli/src/config/migrate/wpf/award.rs b/crates/maa-cli/src/config/migrate/wpf/award.rs new file mode 100644 index 00000000..78404414 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/award.rs @@ -0,0 +1,246 @@ +use anyhow::Result; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields}; + +const HANDLED: &[&str] = &[ + "Award", + "Mail", + "FreeGacha", + "Orundum", + "Mining", + "SpecialAccess", +]; + +#[derive(Debug, Serialize)] +struct CliAwardTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliAwardParams, +} + +#[derive(Debug, Serialize)] +struct CliAwardParams { + #[serde(skip_serializing_if = "Option::is_none")] + award: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + recruit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + orundum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mining: Option, + #[serde(skip_serializing_if = "Option::is_none")] + specialaccess: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +/// WPF GUI `AwardTask` (`$type = "AwardTask"`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfAwardTask { + name: String, + is_enable: bool, + award: bool, + mail: bool, + free_gacha: bool, + orundum: bool, + mining: bool, + special_access: bool, + #[serde(flatten)] + unknown: Map, +} + +impl WpfAwardTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("AwardTask", Some(self.name.clone())); + } + report_unknown_fields( + summary, + "AwardTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } +} + +impl TryFrom<&WpfAwardTask> for Value { + type Error = anyhow::Error; + + fn try_from(task: &WpfAwardTask) -> Result { + Ok(serde_json::to_value(CliAwardTask::try_from(task)?)?) + } +} + +impl TryFrom<&WpfAwardTask> for CliAwardTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfAwardTask) -> Result { + Ok(CliAwardTask { + task_type: TaskType::Award, + name: task.name.clone(), + params: CliAwardParams { + award: Some(task.award), + mail: Some(task.mail), + recruit: Some(task.free_gacha), + orundum: Some(task.orundum), + mining: Some(task.mining), + specialaccess: Some(task.special_access), + enable: (!task.is_enable).then_some(false), + }, + }) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use serde_json::json; + + use super::super::migrate; + + #[test] + fn maps_award_fields_including_renames() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "AwardTask", + "Name": "领奖", + "IsEnable": true, + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.is_empty()); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Award", + "name": "领奖", + "params": { + "award": true, + "mail": true, + "recruit": false, + "orundum": true, + "mining": true, + "specialaccess": true, + } + }] + }) + ); + } + + #[test] + fn disabled_task_sets_enable_false_and_is_reported() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "AwardTask", + "Name": "领奖", + "IsEnable": false, + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!(summary.disabled_tasks.len(), 1); + assert_eq!(summary.disabled_tasks[0].type_tag, "AwardTask"); + assert_eq!(summary.disabled_tasks[0].name.as_deref(), Some("领奖")); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Award", + "name": "领奖", + "params": { + "award": true, + "mail": true, + "recruit": false, + "orundum": true, + "mining": true, + "specialaccess": true, + "enable": false, + } + }] + }) + ); + } + + #[test] + fn unknown_fields_are_reported() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "AwardTask", + "Name": "领奖", + "IsEnable": true, + "Award": true, + "Mail": true, + "FreeGacha": false, + "Orundum": true, + "Mining": true, + "SpecialAccess": true, + "SomeFutureFlag": true, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["SomeFutureFlag"] + ); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Award", + "name": "领奖", + "params": { + "award": true, + "mail": true, + "recruit": false, + "orundum": true, + "mining": true, + "specialaccess": true, + } + }] + }) + ); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/fight.rs b/crates/maa-cli/src/config/migrate/wpf/fight.rs new file mode 100644 index 00000000..c88976bb --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/fight.rs @@ -0,0 +1,574 @@ +use anyhow::{Context, Result, bail}; +use chrono::Weekday; +use log::warn; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields}; +use crate::config::task::{ClientType, Condition, TimeOffset}; + +const HANDLED: &[&str] = &[ + "UseWeeklySchedule", + "WeeklySchedule", + "UseOptionalStage", + "StagePlan", + "UseCustomAnnihilation", + "AnnihilationStage", + "UseMedicine", + "MedicineCount", + "UseStone", + "StoneCount", + "EnableTimesLimit", + "TimesLimit", + "EnableTargetDrop", + "DropId", + "DropCount", + "Series", + "UseExpiringMedicine", + "MedicineExpireDays", +]; + +/// maa-cli Fight task shape written by migration. +#[derive(Debug, Serialize)] +struct CliFightTask { + #[serde(rename = "type")] + task_type: TaskType, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + variants: Vec, +} + +#[derive(Debug, Serialize)] +struct CliFightVariant { + condition: Condition, + params: CliFightParams, +} + +/// Fight params; field order matches historical `object!` / `insert!` output for parity. +#[derive(Clone, Debug, Default, Serialize)] +struct CliFightParams { + #[serde(skip_serializing_if = "Option::is_none")] + medicine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + times: Option, + #[serde(skip_serializing_if = "Option::is_none")] + drops: Option>, + series: i32, + #[serde(skip_serializing_if = "Option::is_none")] + medicine_expire_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +impl CliFightParams { + fn is_empty(&self) -> bool { + self.medicine.is_none() + && self.stone.is_none() + && self.times.is_none() + && self.drops.is_none() + && self.series == i32::default() + && self.medicine_expire_days.is_none() + && self.stage.is_none() + && self.enable.is_none() + } + + fn stage_only(stage: &str) -> Self { + Self { + stage: Some(stage.to_string()), + ..Self::default() + } + } +} + +/// WPF GUI `FightTask` (`$type = "FightTask"`). +/// +/// Deserialized from the GUI task queue; converts into [`CliFightTask`] / +/// [`Value`] via [`TryFrom`]. Unmapped keys are collected in `unknown`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfFightTask { + name: String, + is_enable: bool, + + use_weekly_schedule: bool, + /// Absent when `UseWeeklySchedule` is false in typical GUI exports. + #[serde(default)] + weekly_schedule: Option, + use_optional_stage: bool, + stage_plan: WpfStagePlan, + + /// When StagePlan is solely `Annihilation`, pick [`Self::annihilation_stage`] instead. + #[serde(default)] + use_custom_annihilation: bool, + /// Specific annihilation map (`Chernobog@Annihilation`, …); empty when unused. + #[serde(default)] + annihilation_stage: String, + + use_medicine: bool, + medicine_count: i32, + use_stone: bool, + stone_count: i32, + enable_times_limit: bool, + times_limit: i32, + enable_target_drop: bool, + drop_id: String, + drop_count: i32, + series: i32, + use_expiring_medicine: bool, + medicine_expire_days: i32, + + #[serde(flatten)] + #[allow(dead_code)] + unknown: Map, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WpfWeeklySchedule { + sunday: bool, + monday: bool, + tuesday: bool, + wednesday: bool, + thursday: bool, + friday: bool, + saturday: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum WpfStagePlan { + Single(String), + Many(Vec), +} + +impl WpfStagePlan { + /// GUI annihilation-only plan: a single stage named `Annihilation`. + fn is_sole_annihilation(&self) -> bool { + match self { + Self::Single(stage) => stage == "Annihilation", + Self::Many(stages) => stages.len() == 1 && stages[0] == "Annihilation", + } + } +} + +impl WpfFightTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("FightTask", Some(self.name.clone())); + } + report_unknown_fields( + summary, + "FightTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + /// When StagePlan is solely `Annihilation`, honour UseCustomAnnihilation / + /// AnnihilationStage; otherwise keep the StagePlan entry as-is. + fn resolve_stage<'a>(&'a self, stage: &'a str) -> Result<&'a str> { + if !self.stage_plan.is_sole_annihilation() { + return Ok(stage); + } + if self.use_custom_annihilation { + if self.annihilation_stage.is_empty() { + bail!("FightTask UseCustomAnnihilation is true but AnnihilationStage is empty"); + } + Ok(self.annihilation_stage.as_str()) + } else { + Ok("Annihilation") + } + } + + /// Build the open-condition for a fight stage name. + /// + /// - Resource stages with a weekly rotation → `Weekday` (server timezone Official) + /// - Permanent stages (mainline, annihilation, LS-6, OF-*) → `Always` + /// - Side-story stages listed in `StageActivityV2.json` → `DateTime` with that activity's + /// expire time (and timezone) from the resource file + /// + /// If the activity resource cannot be read (even after a hot-update attempt), migration + /// fails instead of inventing a condition. Stages absent from a readable activity file + /// still fall back to `Always` with a warning (e.g. mainline). + fn stage_condition(stage: &str) -> Result { + use Weekday::*; + Ok(match stage { + // 资源本 / 芯片本(同开放日合并) + "CE-6" => Condition::Weekday { + weekdays: vec![Tue, Thu, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "AP-5" => Condition::Weekday { + weekdays: vec![Mon, Thu, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "CA-5" => Condition::Weekday { + weekdays: vec![Tue, Wed, Fri, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "SK-5" => Condition::Weekday { + weekdays: vec![Mon, Wed, Fri, Sat], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-A-1" | "PR-A-2" => Condition::Weekday { + weekdays: vec![Mon, Thu, Fri, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-B-1" | "PR-B-2" => Condition::Weekday { + weekdays: vec![Mon, Tue, Fri, Sat], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-C-1" | "PR-C-2" => Condition::Weekday { + weekdays: vec![Wed, Thu, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + "PR-D-1" | "PR-D-2" => Condition::Weekday { + weekdays: vec![Tue, Wed, Sat, Sun], + timezone: TimeOffset::Client(ClientType::Official), + }, + // 永久开启(含指定剿灭图) + "LS-6" + | "Annihilation" + | "OF-1" + | "OF-F3" + | "Chernobog@Annihilation" + | "LungmenOutskirts@Annihilation" + | "LungmenDowntown@Annihilation" => Condition::Always, + _ => match Self::activity_stage_schedule(stage)? { + Some(schedule) => Condition::DateTime { + start: None, + end: Some(schedule.end), + timezone: TimeOffset::TimeZone(schedule.timezone), + }, + None => { + warn!( + "FightTask stage `{stage}` has no known open schedule; treating as Always" + ); + Condition::Always + } + }, + }) + } + + /// Resolve expire window for an activity stage from `StageActivityV2.json`. + /// + /// On read failure, attempts a hot update once and reloads. Persistent read failure + /// is an error so migration does not invent dates. + fn activity_stage_schedule(stage: &str) -> Result> { + match crate::activity::load_side_story_stage_schedule(ClientType::Official, stage) { + Ok(schedule) => Ok(schedule), + Err(load_err) => { + let path = maa_dirs::activity(); + warn!( + "Failed to read activity resource {}: {load_err}; trying hot update", + path.display() + ); + crate::installer::hot_update::update().with_context(|| { + format!( + "Failed to update hot-update resources after failing to read {}", + path.display() + ) + })?; + crate::activity::load_side_story_stage_schedule(ClientType::Official, stage) + .with_context(|| { + format!( + "Cannot read expire time for FightTask stage `{stage}` from {}", + path.display() + ) + }) + } + } + } +} + +impl From<&WpfFightTask> for CliFightParams { + fn from(task: &WpfFightTask) -> Self { + CliFightParams { + medicine: task.use_medicine.then_some(task.medicine_count), + stone: task.use_stone.then(|| { + let count = task.stone_count; + warn!( + "FightTask enables stone={count}; this setting may consume Originite Prime (源石)" + ); + count + }), + times: task.enable_times_limit.then_some(task.times_limit), + drops: task.enable_target_drop.then(|| { + let mut drops = maa_value::map::StringMap::new(); + drops.insert(task.drop_id.clone(), task.drop_count); + drops + }), + series: task.series, + medicine_expire_days: task + .use_expiring_medicine + .then_some(task.medicine_expire_days), + stage: None, + enable: None, + } + } +} + +impl From<&WpfWeeklySchedule> for Condition { + fn from(schedule: &WpfWeeklySchedule) -> Self { + let days = [ + (schedule.sunday, Weekday::Sun), + (schedule.monday, Weekday::Mon), + (schedule.tuesday, Weekday::Tue), + (schedule.wednesday, Weekday::Wed), + (schedule.thursday, Weekday::Thu), + (schedule.friday, Weekday::Fri), + (schedule.saturday, Weekday::Sat), + ]; + + let weekdays: Vec = days + .into_iter() + .filter_map(|(enabled, day)| enabled.then_some(day)) + .collect(); + + Condition::Weekday { + weekdays, + timezone: TimeOffset::Local, + } + } +} + +impl<'a> TryFrom<&'a WpfStagePlan> for &'a str { + type Error = anyhow::Error; + + fn try_from(plan: &'a WpfStagePlan) -> Result<&'a str> { + match plan { + WpfStagePlan::Single(stage) => Ok(stage.as_str()), + WpfStagePlan::Many(stages) if stages.len() == 1 => Ok(stages[0].as_str()), + WpfStagePlan::Many(_) => { + bail!( + "FightTask StagePlan must be a single stage string when UseOptionalStage is false, \ + got a string array; enable UseOptionalStage for multiple stages" + ) + } + } + } +} + +impl<'a> TryFrom<&'a WpfStagePlan> for Vec<&'a str> { + type Error = anyhow::Error; + + fn try_from(plan: &'a WpfStagePlan) -> Result> { + match plan { + WpfStagePlan::Many(stages) => { + if stages.is_empty() { + bail!( + "FightTask StagePlan must be a non-empty string array when UseOptionalStage is true" + ); + } + Ok(stages.iter().map(String::as_str).collect()) + } + WpfStagePlan::Single(_) => { + bail!( + "FightTask StagePlan must be a string array when UseOptionalStage is true, got a string" + ) + } + } + } +} + +impl TryFrom<&WpfFightTask> for CliFightTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfFightTask) -> Result { + // Shared fight options (medicine, stone, expire days, ...) belong on the task. + // Variants only carry stage selection / open-condition params. + let shared_params = CliFightParams::from(task); + + let mut item = match (task.use_weekly_schedule, task.use_optional_stage) { + // No variants: StagePlan must be a single stage. + (false, false) => { + let stage: &str = (&task.stage_plan).try_into()?; + let stage = task.resolve_stage(stage)?; + let mut params = shared_params; + params.stage = Some(stage.to_string()); + CliFightTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: Some(params), + variants: Vec::new(), + } + } + // One variant gated by weekly schedule; StagePlan must be a single stage. + (true, false) => { + let stage: &str = (&task.stage_plan).try_into()?; + let stage = task.resolve_stage(stage)?; + let weekly = Condition::from(task.weekly_schedule.as_ref().context( + "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", + )?); + CliFightTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: (!shared_params.is_empty()).then_some(shared_params), + variants: vec![CliFightVariant { + condition: weekly, + params: CliFightParams::stage_only(stage), + }], + } + } + // One variant per optional stage, each with its own open-condition. + (false, true) => { + let stages: Vec<&str> = (&task.stage_plan).try_into()?; + let mut variants = Vec::with_capacity(stages.len()); + for stage in stages { + let stage = task.resolve_stage(stage)?; + variants.push(CliFightVariant { + condition: WpfFightTask::stage_condition(stage)?, + params: CliFightParams::stage_only(stage), + }); + } + CliFightTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: (!shared_params.is_empty()).then_some(shared_params), + variants, + } + } + // Weekly schedule AND each stage's open-condition. + (true, true) => { + let stages: Vec<&str> = (&task.stage_plan).try_into()?; + let weekly = Condition::from(task.weekly_schedule.as_ref().context( + "FightTask UseWeeklySchedule is true but WeeklySchedule is missing or invalid", + )?); + let mut variants = Vec::with_capacity(stages.len()); + for stage in stages { + let stage = task.resolve_stage(stage)?; + variants.push(CliFightVariant { + condition: Condition::And { + conditions: vec![weekly.clone(), WpfFightTask::stage_condition(stage)?], + }, + params: CliFightParams::stage_only(stage), + }); + } + CliFightTask { + task_type: TaskType::Fight, + name: task.name.clone(), + params: (!shared_params.is_empty()).then_some(shared_params), + variants, + } + } + }; + + // MaaCore common switch: params.enable=false disables the task. + if !task.is_enable { + match &mut item.params { + Some(params) => params.enable = Some(false), + None => { + item.params = Some(CliFightParams { + enable: Some(false), + ..CliFightParams::default() + }); + } + } + } + + Ok(item) + } +} + +impl TryFrom<&WpfFightTask> for Value { + type Error = anyhow::Error; + + fn try_from(task: &WpfFightTask) -> Result { + Ok(serde_json::to_value(CliFightTask::try_from(task)?)?) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::super::{WpfConfig, migrate}; + + fn fight_profile(extra: serde_json::Value) -> WpfConfig { + let mut task = serde_json::json!({ + "$type": "FightTask", + "Name": "剿灭", + "IsEnable": true, + "UseMedicine": false, + "MedicineCount": 0, + "UseStone": false, + "StoneCount": 0, + "EnableTargetDrop": false, + "DropId": "", + "DropCount": 0, + "EnableTimesLimit": false, + "TimesLimit": 0, + "Series": 0, + "UseExpiringMedicine": false, + "MedicineExpireDays": 2, + "UseOptionalStage": false, + "UseWeeklySchedule": false, + "StagePlan": ["Annihilation"], + }); + if let Some(obj) = extra.as_object() + && let Some(task_obj) = task.as_object_mut() + { + for (key, value) in obj { + task_obj.insert(key.clone(), value.clone()); + } + } + serde_json::from_value(serde_json::json!({ + "TaskQueue": [task], + "Gui": {}, + })) + .unwrap() + } + + #[test] + fn custom_annihilation_uses_annihilation_stage() { + let config = fight_profile(serde_json::json!({ + "UseCustomAnnihilation": true, + "AnnihilationStage": "LungmenDowntown@Annihilation", + })); + let (cli, summary) = migrate(config, None).unwrap(); + assert!( + !summary + .skipped_fields + .iter() + .any(|f| f.field == "UseCustomAnnihilation" || f.field == "AnnihilationStage") + ); + assert_eq!( + cli.tasks[0].get("params").and_then(|p| p.get("stage")), + Some(&serde_json::json!("LungmenDowntown@Annihilation")) + ); + } + + #[test] + fn custom_annihilation_off_keeps_annihilation() { + let config = fight_profile(serde_json::json!({ + "UseCustomAnnihilation": false, + "AnnihilationStage": "LungmenDowntown@Annihilation", + })); + let (cli, _) = migrate(config, None).unwrap(); + assert_eq!( + cli.tasks[0].get("params").and_then(|p| p.get("stage")), + Some(&serde_json::json!("Annihilation")) + ); + } + + #[test] + fn custom_annihilation_true_requires_stage() { + let config = fight_profile(serde_json::json!({ + "UseCustomAnnihilation": true, + "AnnihilationStage": "", + })); + let err = migrate(config, None).unwrap_err(); + assert!( + err.to_string() + .contains("UseCustomAnnihilation is true but AnnihilationStage is empty"), + "{err}" + ); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/infrast.rs b/crates/maa-cli/src/config/migrate/wpf/infrast.rs new file mode 100644 index 00000000..32e497fe --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/infrast.rs @@ -0,0 +1,779 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail, ensure}; +use chrono::{NaiveTime, Timelike}; +use log::warn; +use maa_dirs::expand_tilde; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields}; +use crate::config::task::{Condition, TimeOffset}; + +const HANDLED: &[&str] = &[ + "Mode", + "RoomList", + "UsesOfDrones", + "DormThreshold", + "OriginiumShardAutoReplenishment", + "DormFilterNotStationed", + "DormTrustEnabled", + "ReceptionMessageBoard", + "ReceptionClueExchange", + "SendClue", + "ContinueTraining", + "CustomFileType", + "Filename", + "PlanSelect", +]; + +const MINUTES_PER_DAY: u32 = 24 * 60; +const CUSTOM_MODE: i32 = 10000; + +#[derive(Debug, Serialize)] +struct CliInfrastTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliInfrastParams, + #[serde(skip_serializing_if = "Vec::is_empty")] + variants: Vec, +} + +#[derive(Debug, Serialize)] +struct CliInfrastVariant { + condition: Condition, + params: CliInfrastVariantParams, +} + +#[derive(Debug, Serialize)] +struct CliInfrastVariantParams { + plan_index: i32, +} + +#[derive(Debug, Serialize)] +struct CliInfrastParams { + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + facility: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + drones: Option, + #[serde(skip_serializing_if = "Option::is_none")] + threshold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + replenish: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dorm_notstationed_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dorm_trust_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reception_message_board: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reception_clue_exchange: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reception_send_clue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + continue_training: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + plan_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WpfInfrastRoom { + room: String, +} + +/// WPF GUI `InfrastTask` (`$type = "InfrastTask"`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfInfrastTask { + name: String, + is_enable: bool, + mode: String, + #[serde(default)] + filename: String, + #[serde(default = "default_plan_select")] + plan_select: i32, + #[serde(default)] + continue_training: bool, + room_list: Vec, + uses_of_drones: String, + dorm_threshold: i32, + originium_shard_auto_replenishment: bool, + dorm_filter_not_stationed: bool, + dorm_trust_enabled: bool, + reception_message_board: bool, + reception_clue_exchange: bool, + send_clue: bool, + #[serde(flatten)] + unknown: Map, +} + +fn default_plan_select() -> i32 { + -1 +} + +#[derive(Debug, Deserialize)] +struct CustomInfrastFile { + plans: Vec, +} + +#[derive(Debug, Deserialize)] +struct CustomInfrastPlan { + #[serde(default)] + period: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MergedSpan { + /// `[start, end)` on a single day; `end == MINUTES_PER_DAY` means until midnight. + NonWrap { start: u32, end: u32 }, + /// `[start, 24:00)` plus `[00:00, end)`. + Wrap { start: u32, end: u32 }, +} + +impl WpfInfrastTask { + fn mapped_mode(&self) -> Option { + match self.mode.as_str() { + "Normal" => Some(0), + "Rotation" => Some(20000), + _ => None, + } + } + + fn wants_custom(&self) -> bool { + self.mode == "Custom" || (!self.filename.is_empty() && self.mode != "Rotation") + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("InfrastTask", Some(self.name.clone())); + } + if !self.wants_custom() && self.mapped_mode().is_none() { + summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); + } + report_unknown_fields( + summary, + "InfrastTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } + + pub(super) fn to_cli_task( + &self, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + schedule_applied: &mut bool, + ) -> Result { + if let Some(path) = custom_schedule + && self.wants_custom() + { + *schedule_applied = true; + return self.try_custom_task(&path.to_string_lossy()); + } + + if self.wants_custom() { + match self.try_custom_task(&self.filename) { + Ok(task) => return Ok(task), + Err(err) => { + warn!( + "InfrastTask custom schedule skipped ({err}); writing default infrast mode" + ); + summary.skip_field("InfrastTask", Some(self.name.clone()), "Mode"); + if !self.filename.is_empty() { + summary.skip_field("InfrastTask", Some(self.name.clone()), "Filename"); + } + } + } + } + Ok(self.default_task()) + } + + fn default_task(&self) -> CliInfrastTask { + let mode = if self.wants_custom() { + Some(0) + } else { + self.mapped_mode() + }; + CliInfrastTask { + task_type: TaskType::Infrast, + name: self.name.clone(), + params: self.shared_params(mode, None, None), + variants: Vec::new(), + } + } + + fn shared_params( + &self, + mode: Option, + filename: Option, + plan_index: Option, + ) -> CliInfrastParams { + let custom = mode == Some(CUSTOM_MODE); + CliInfrastParams { + mode, + facility: self.room_list.iter().map(|r| r.room.clone()).collect(), + drones: (!custom).then(|| self.uses_of_drones.clone()), + // Prefer f64 so TOML/JSON emit `0.3` instead of the f32 binary expansion. + threshold: Some(f64::from(self.dorm_threshold) / 100.0), + replenish: Some(self.originium_shard_auto_replenishment), + dorm_notstationed_enabled: Some(self.dorm_filter_not_stationed), + dorm_trust_enabled: Some(self.dorm_trust_enabled), + reception_message_board: Some(self.reception_message_board), + reception_clue_exchange: Some(self.reception_clue_exchange), + reception_send_clue: Some(self.send_clue), + continue_training: Some(self.continue_training), + filename, + plan_index, + enable: (!self.is_enable).then_some(false), + } + } + + fn try_custom_task(&self, filename: &str) -> Result { + ensure!(!filename.is_empty(), "custom mode has empty Filename"); + let filename = resolve_infrast_filename(filename); + let schedule = load_custom_schedule(&filename)?; + let n_plans = + i32::try_from(schedule.plans.len()).context("too many plans in custom infrast file")?; + + let time_rotation = self.plan_select < 0; + let variants = if time_rotation { + match variants_from_schedule(&schedule) { + Ok(variants) => variants, + Err(err) => { + warn!( + "InfrastTask custom schedule periods could not be converted ({err}); using plan_index" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + + let plan_index = if variants.is_empty() { + warn!( + "InfrastTask: custom schedule with {} plan(s); no period data, using plan_index", + schedule.plans.len() + ); + let in_range = self.plan_select >= 0 && self.plan_select < n_plans; + Some(if in_range { self.plan_select } else { 0 }) + } else { + let days = variants + .iter() + .filter_map(|v| match &v.condition { + Condition::And { conditions } | Condition::Or { conditions } => { + conditions.iter().find_map(|c| match c { + Condition::DayMod { divisor, .. } => Some(*divisor), + Condition::And { conditions } => { + conditions.iter().find_map(|inner| match inner { + Condition::DayMod { divisor, .. } => Some(*divisor), + _ => None, + }) + } + _ => None, + }) + } + Condition::DayMod { divisor, .. } => Some(*divisor), + _ => None, + }) + .max() + .unwrap_or(1); + let times: Vec<&str> = schedule + .plans + .iter() + .filter_map(|plan| plan.period.first()) + .filter_map(|pair| pair.first()) + .map(String::as_str) + .collect(); + warn!( + "InfrastTask: custom schedule ({days} day(s), {} shift(s)); suggested run times: {}", + schedule.plans.len(), + times.join(", ") + ); + None + }; + + Ok(CliInfrastTask { + task_type: TaskType::Infrast, + name: self.name.clone(), + params: self.shared_params( + Some(CUSTOM_MODE), + Some(filename.to_string_lossy().into_owned()), + plan_index, + ), + variants, + }) + } +} + +fn resolve_infrast_filename(path: &str) -> PathBuf { + expand_tilde(Path::new(path)).into_owned() +} + +fn load_custom_schedule(path: &Path) -> Result { + let text = fs::read_to_string(path) + .with_context(|| format!("failed to read custom infrast file `{}`", path.display()))?; + let file: CustomInfrastFile = serde_json::from_str(&text) + .with_context(|| format!("failed to parse custom infrast file `{}`", path.display()))?; + ensure!(!file.plans.is_empty(), "custom infrast file has no plans"); + Ok(file) +} + +fn variants_from_schedule(file: &CustomInfrastFile) -> Result> { + let has_period: Vec = file.plans.iter().map(|p| !p.period.is_empty()).collect(); + if has_period.iter().any(|has| !*has) { + if has_period.iter().any(|has| *has) { + bail!("some plans have period and some do not"); + } + return Ok(Vec::new()); + } + + let spans: Vec = file + .plans + .iter() + .map(|plan| merge_plan_periods(&plan.period)) + .collect::>()?; + let remainders = assign_day_remainders(&spans); + let days = remainders.iter().copied().max().unwrap_or(0) + 1; + + spans + .iter() + .zip(remainders) + .enumerate() + .map(|(index, (span, remainder))| { + Ok(CliInfrastVariant { + condition: span_condition(*span, days, remainder), + params: CliInfrastVariantParams { + plan_index: i32::try_from(index)?, + }, + }) + }) + .collect() +} + +fn parse_clock(value: &str) -> Result { + NaiveTime::parse_from_str(value, "%H:%M") + .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S")) + .with_context(|| format!("invalid time `{value}` in custom infrast period")) +} + +fn time_to_minutes(time: NaiveTime) -> u32 { + time.num_seconds_from_midnight() / 60 +} + +fn minutes_to_time(minutes: u32) -> Option { + if minutes >= MINUTES_PER_DAY { + None + } else { + NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0) + } +} + +fn period_to_segments(start: NaiveTime, end: NaiveTime) -> Vec<(u32, u32)> { + let start_m = time_to_minutes(start); + let mut end_m = time_to_minutes(end); + if end.hour() == 23 && end.minute() == 59 { + end_m = MINUTES_PER_DAY; + } + if start_m == end_m { + Vec::new() + } else if start_m < end_m { + vec![(start_m, end_m)] + } else { + vec![(start_m, MINUTES_PER_DAY), (0, end_m)] + } +} + +fn merge_plan_periods(period: &[Vec]) -> Result { + let mut segments = Vec::new(); + for entry in period { + ensure!( + entry.len() == 2, + "period entry must be [start, end], got {entry:?}" + ); + let start = parse_clock(&entry[0])?; + let end = parse_clock(&entry[1])?; + segments.extend(period_to_segments(start, end)); + } + ensure!(!segments.is_empty(), "plan period is empty after parsing"); + segments.sort_by_key(|seg| seg.0); + + let mut merged: Vec<(u32, u32)> = Vec::new(); + for (start, end) in segments { + if let Some(last) = merged.last_mut() + && start <= last.1 + { + last.1 = last.1.max(end); + } else { + merged.push((start, end)); + } + } + + match merged.as_slice() { + [(start, end)] => Ok(MergedSpan::NonWrap { + start: *start, + end: *end, + }), + [(0, morning_end), (night_start, night_end)] if *night_end == MINUTES_PER_DAY => { + Ok(MergedSpan::Wrap { + start: *night_start, + end: *morning_end, + }) + } + other => bail!("unsupported disjoint period segments: {other:?}"), + } +} + +fn span_coverage(span: MergedSpan) -> [bool; MINUTES_PER_DAY as usize] { + let mut bits = [false; MINUTES_PER_DAY as usize]; + match span { + MergedSpan::NonWrap { start, end } => { + for minute in start..end { + bits[minute as usize] = true; + } + } + MergedSpan::Wrap { start, end } => { + for minute in start..MINUTES_PER_DAY { + bits[minute as usize] = true; + } + for minute in 0..end { + bits[minute as usize] = true; + } + } + } + bits +} + +fn coverages_overlap(left: &[bool], right: &[bool]) -> bool { + left.iter().zip(right).any(|(a, b)| *a && *b) +} + +fn assign_day_remainders(spans: &[MergedSpan]) -> Vec { + let coverages: Vec<[bool; MINUTES_PER_DAY as usize]> = + spans.iter().copied().map(span_coverage).collect(); + let mut occupied: Vec<[bool; MINUTES_PER_DAY as usize]> = Vec::new(); + let mut remainders = Vec::with_capacity(spans.len()); + for coverage in &coverages { + let remainder = occupied + .iter() + .position(|day| !coverages_overlap(day, coverage)) + .unwrap_or_else(|| { + occupied.push([false; MINUTES_PER_DAY as usize]); + occupied.len() - 1 + }); + for (dst, src) in occupied[remainder].iter_mut().zip(coverage) { + *dst |= src; + } + remainders.push(remainder as u32); + } + remainders +} + +fn day_mod(divisor: u32, remainder: u32) -> Condition { + Condition::DayMod { + divisor, + remainder, + timezone: TimeOffset::Local, + } +} + +fn time_range(start: Option, end: Option) -> Condition { + Condition::Time { + start, + end, + timezone: TimeOffset::Local, + } +} + +fn span_condition(span: MergedSpan, days: u32, remainder: u32) -> Condition { + let time = match span { + MergedSpan::NonWrap { start, end } => { + time_range(minutes_to_time(start), minutes_to_time(end)) + } + MergedSpan::Wrap { start, end } => time_range(minutes_to_time(start), minutes_to_time(end)), + }; + + if days <= 1 { + return time; + } + + match span { + MergedSpan::NonWrap { .. } => Condition::And { + conditions: vec![day_mod(days, remainder), time], + }, + MergedSpan::Wrap { start, end } => Condition::Or { + conditions: vec![ + Condition::And { + conditions: vec![ + day_mod(days, remainder), + time_range(minutes_to_time(start), None), + ], + }, + Condition::And { + conditions: vec![ + day_mod(days, (remainder + 1) % days), + time_range(None, minutes_to_time(end)), + ], + }, + ], + }, + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{ + super::{WpfConfig, migrate}, + *, + }; + use crate::config::migrate::MigrationSummary; + + fn sample_task(mode: &str, filename: &str, plan_select: i32) -> WpfInfrastTask { + serde_json::from_value(serde_json::json!({ + "Name": "", + "IsEnable": true, + "Mode": mode, + "Filename": filename, + "PlanSelect": plan_select, + "ContinueTraining": true, + "RoomList": [{"Room": "Mfg"}, {"Room": "Trade"}], + "UsesOfDrones": "Money", + "DormThreshold": 30, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "DormTrustEnabled": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + })) + .unwrap() + } + + fn write_schedule(contents: &str) -> tempfile::NamedTempFile { + let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); + std::fs::write(file.path(), contents).unwrap(); + file + } + + fn to_json(task: &WpfInfrastTask, summary: &mut MigrationSummary) -> serde_json::Value { + serde_json::to_value(task.to_cli_task(summary, None, &mut false).unwrap()).unwrap() + } + + fn infrast_profile(mode: &str, filename: &str) -> WpfConfig { + serde_json::from_value(serde_json::json!({ + "TaskQueue": [{ + "$type": "InfrastTask", + "Name": "", + "IsEnable": true, + "Mode": mode, + "Filename": filename, + "PlanSelect": -1, + "ContinueTraining": true, + "RoomList": [{"Room": "Mfg"}, {"Room": "Trade"}], + "UsesOfDrones": "Money", + "DormThreshold": 30, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "DormTrustEnabled": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + }], + "Gui": {} + })) + .unwrap() + } + + #[test] + fn normal_mode_keeps_default_layout() { + let task = sample_task("Normal", "", -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert!(summary.is_empty()); + assert_eq!(json["params"]["mode"], 0); + assert!(json.get("variants").is_none() || json["variants"].as_array().unwrap().is_empty()); + assert!(json["params"].get("filename").is_none()); + assert_eq!(json["params"]["threshold"], 0.3); + } + + #[test] + fn missing_custom_file_falls_back_to_default() { + let task = sample_task("Custom", "/no/such/infrast-plan.json", -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert_eq!(json["params"]["mode"], 0); + assert!(json["params"].get("filename").is_none()); + assert!(summary.skipped_fields.iter().any(|f| f.field == "Filename")); + assert!(summary.skipped_fields.iter().any(|f| f.field == "Mode")); + } + + #[test] + fn tilde_in_filename_expands_to_home() { + let expanded = resolve_infrast_filename("~/一图流-153-一天两换-MAA.json"); + assert_eq!( + expanded, + expand_tilde(Path::new("~/一图流-153-一天两换-MAA.json")).into_owned() + ); + assert!(expanded.is_absolute()); + assert!(!expanded.starts_with("~")); + } + + #[test] + fn one_day_two_shifts_generates_time_variants() { + let schedule = r#"{ + "plans": [ + {"name": "A", "period": [["04:00", "16:00"]]}, + {"name": "B", "period": [["16:00", "23:59"], ["00:00", "04:00"]]} + ] + }"#; + let file = write_schedule(schedule); + let task = sample_task("Custom", file.path().to_str().unwrap(), -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert!(summary.is_empty()); + assert_eq!(json["params"]["mode"], CUSTOM_MODE); + assert_eq!(json["params"]["filename"], file.path().to_str().unwrap()); + let variants = json["variants"].as_array().unwrap(); + assert_eq!(variants.len(), 2); + assert_eq!(variants[0]["params"]["plan_index"], 0); + assert_eq!(variants[0]["condition"]["type"], "Time"); + assert_eq!(variants[0]["condition"]["start"], "04:00:00"); + assert_eq!(variants[0]["condition"]["end"], "16:00:00"); + assert_eq!(variants[1]["params"]["plan_index"], 1); + assert_eq!(variants[1]["condition"]["type"], "Time"); + assert_eq!(variants[1]["condition"]["start"], "16:00:00"); + assert_eq!(variants[1]["condition"]["end"], "04:00:00"); + } + + #[test] + fn two_day_six_shifts_uses_day_mod() { + let schedule = r#"{ + "plans": [ + {"period": [["04:00", "12:00"]]}, + {"period": [["12:00", "20:00"]]}, + {"period": [["20:00", "23:59"], ["00:00", "04:00"]]}, + {"period": [["04:00", "12:00"]]}, + {"period": [["12:00", "20:00"]]}, + {"period": [["20:00", "23:59"], ["00:00", "04:00"]]} + ] + }"#; + let file = write_schedule(schedule); + let task = sample_task("Custom", file.path().to_str().unwrap(), -1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + let variants = json["variants"].as_array().unwrap(); + assert_eq!(variants.len(), 6); + assert_eq!(variants[0]["condition"]["type"], "And"); + assert_eq!(variants[0]["condition"]["conditions"][0]["type"], "DayMod"); + assert_eq!(variants[0]["condition"]["conditions"][0]["divisor"], 2); + assert!( + variants[0]["condition"]["conditions"][0] + .get("remainder") + .is_none() + ); + assert_eq!(variants[3]["condition"]["conditions"][0]["remainder"], 1); + assert_eq!(variants[2]["condition"]["type"], "Or"); + assert_eq!(variants[5]["condition"]["type"], "Or"); + assert_eq!(variants[5]["params"]["plan_index"], 5); + } + + #[test] + fn optional_shift_uses_plan_index() { + let schedule = r#"{"plans": [{"name": "A"}, {"name": "B"}]}"#; + let file = write_schedule(schedule); + let task = sample_task("Custom", file.path().to_str().unwrap(), 1); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert_eq!(json["params"]["mode"], CUSTOM_MODE); + assert_eq!(json["params"]["plan_index"], 1); + assert!(json.get("variants").is_none() || json["variants"].as_array().unwrap().is_empty()); + } + + #[test] + fn custom_schedule_overrides_wpf_filename() { + let schedule = r#"{ + "plans": [ + {"name": "A", "period": [["04:00", "16:00"]]}, + {"name": "B", "period": [["16:00", "23:59"], ["00:00", "04:00"]]} + ] + }"#; + let file = write_schedule(schedule); + let profile = infrast_profile("Custom", "/original/missing.json"); + let (value, summary) = migrate(profile, Some(file.path())).unwrap(); + assert!(summary.is_empty()); + let json = serde_json::to_value(value).unwrap(); + let task = &json["tasks"][0]; + assert_eq!(task["params"]["mode"], CUSTOM_MODE); + assert_eq!( + task["params"]["filename"], + resolve_infrast_filename(file.path().to_str().unwrap()) + .to_string_lossy() + .as_ref() + ); + assert_eq!(task["variants"].as_array().unwrap().len(), 2); + } + + #[test] + fn custom_schedule_errors_when_profile_is_not_custom() { + let profile = infrast_profile("Normal", ""); + let err = migrate(profile, Some(Path::new("plan.json"))).unwrap_err(); + assert!(err.to_string().contains("--custom-schedule"), "{err}"); + } + + #[test] + fn custom_schedule_errors_when_override_file_missing() { + let profile = infrast_profile("Custom", "/original/missing.json"); + let err = migrate(profile, Some(Path::new("/no/such/override.json"))).unwrap_err(); + assert!( + err.to_string() + .contains("failed to read custom infrast file"), + "{err}" + ); + } + + #[test] + fn assign_days_packs_overlapping_clock_spans() { + let spans = [ + MergedSpan::NonWrap { + start: 4 * 60, + end: 12 * 60, + }, + MergedSpan::NonWrap { + start: 12 * 60, + end: 20 * 60, + }, + MergedSpan::Wrap { + start: 20 * 60, + end: 4 * 60, + }, + MergedSpan::NonWrap { + start: 4 * 60, + end: 12 * 60, + }, + MergedSpan::NonWrap { + start: 12 * 60, + end: 20 * 60, + }, + MergedSpan::Wrap { + start: 20 * 60, + end: 4 * 60, + }, + ]; + assert_eq!(assign_day_remainders(&spans), vec![0, 0, 0, 1, 1, 1]); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/mall.rs b/crates/maa-cli/src/config/migrate/wpf/mall.rs new file mode 100644 index 00000000..4d9afbbd --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/mall.rs @@ -0,0 +1,116 @@ +use anyhow::Result; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields, split_semi_list}; + +const HANDLED: &[&str] = &[ + "Shopping", + "CreditFight", + "CreditFightFormation", + "VisitFriends", + "FirstList", + "BlackList", + "ShoppingIgnoreBlackListWhenFull", + "OnlyBuyDiscount", + "ReserveMaxCredit", +]; + +#[derive(Debug, Serialize)] +struct CliMallTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliMallParams, +} + +#[derive(Debug, Serialize)] +struct CliMallParams { + #[serde(skip_serializing_if = "Option::is_none")] + shopping: Option, + #[serde(skip_serializing_if = "Option::is_none")] + credit_fight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + formation_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + visit_friends: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + buy_first: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + blacklist: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + force_shopping_if_credit_full: Option, + #[serde(skip_serializing_if = "Option::is_none")] + only_buy_discount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reserve_max_credit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +/// WPF GUI `MallTask` (`$type = "MallTask"`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfMallTask { + name: String, + is_enable: bool, + shopping: bool, + credit_fight: bool, + credit_fight_formation: i32, + visit_friends: bool, + first_list: String, + black_list: String, + shopping_ignore_black_list_when_full: bool, + only_buy_discount: bool, + reserve_max_credit: bool, + #[serde(flatten)] + unknown: Map, +} + +impl WpfMallTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("MallTask", Some(self.name.clone())); + } + report_unknown_fields( + summary, + "MallTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } +} + +impl TryFrom<&WpfMallTask> for Value { + type Error = anyhow::Error; + + fn try_from(task: &WpfMallTask) -> Result { + Ok(serde_json::to_value(CliMallTask::try_from(task)?)?) + } +} + +impl TryFrom<&WpfMallTask> for CliMallTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfMallTask) -> Result { + Ok(CliMallTask { + task_type: TaskType::Mall, + name: task.name.clone(), + params: CliMallParams { + shopping: Some(task.shopping), + credit_fight: Some(task.credit_fight), + formation_index: Some(task.credit_fight_formation), + visit_friends: Some(task.visit_friends), + buy_first: split_semi_list(&task.first_list), + blacklist: split_semi_list(&task.black_list), + force_shopping_if_credit_full: Some(task.shopping_ignore_black_list_when_full), + only_buy_discount: Some(task.only_buy_discount), + reserve_max_credit: Some(task.reserve_max_credit), + enable: (!task.is_enable).then_some(false), + }, + }) + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/mod.rs b/crates/maa-cli/src/config/migrate/wpf/mod.rs new file mode 100644 index 00000000..289f82e3 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/mod.rs @@ -0,0 +1,401 @@ +use std::{collections::BTreeMap, num::NonZero, path::Path}; + +use anyhow::{Context, Result, bail, ensure}; +use log::trace; +use maa_value::userinput::{SelectD, UserInput}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; + +use super::MigrationSummary; +use crate::config::Filetype; + +const SUPPORTED_CONFIG_VERSION: u32 = 1; + +#[derive(Debug, Serialize)] +pub(super) struct CliConfig { + pub(super) tasks: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfProfile { + config_version: u32, + #[serde(default)] + current: Option, + configurations: BTreeMap, + #[serde(flatten)] + #[allow(dead_code)] + unknown: Map, +} + +#[derive(Debug, Deserialize)] +pub(super) struct WpfConfig { + #[serde(rename = "TaskQueue", deserialize_with = "deserialize_task_queue")] + task_queue: Vec, + #[serde(rename = "Gui")] + gui: WpfGuiSettings, +} + +impl WpfConfig { + fn migrate_tasks( + &self, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + ) -> Result> { + let mut tasks = Vec::new(); + let mut schedule_applied = false; + for task in &self.task_queue { + if let Some(item) = + task.migrate_task(&self.gui, summary, custom_schedule, &mut schedule_applied)? + { + tasks.push(item); + } + } + if custom_schedule.is_some() && !schedule_applied { + bail!( + "`--custom-schedule` was provided, but the WPF profile is not using custom infrastructure scheduling" + ); + } + Ok(Some(CliConfig { tasks })) + } +} + +#[derive(Debug)] +enum WpfTask { + StartUpTask(start_up::WpfStartUpTask), + FightTask(fight::WpfFightTask), + InfrastTask(infrast::WpfInfrastTask), + RecruitTask(recruit::WpfRecruitTask), + MallTask(mall::WpfMallTask), + AwardTask(award::WpfAwardTask), + RoguelikeTask(roguelike::WpfRoguelikeTask), + ReclamationTask(reclamation::WpfReclamationTask), + /// Unknown `$type` values are skipped during migration. + Unsupported { + type_tag: String, + name: Option, + }, +} + +/// Migrate a MAA WPF GUI profile into a maa-cli task config. +/// +/// ```text +/// maa migrate wpf [output] +/// ``` +pub(crate) fn wpf( + file: &Path, + out: Option<&Path>, + profile_name: Option, + custom_schedule: Option<&Path>, +) -> Result<()> { + ensure!( + file.extension() == Some("json".as_ref()), + "`maa migrate wpf` expected a MAA GUI profile (typically .json); input {file:?} is not a JSON file" + ); + + let profile: WpfProfile = + serde_json::from_reader(std::fs::File::open(file).context("Trying to open wpf profile")?)?; + ensure!( + profile.config_version == SUPPORTED_CONFIG_VERSION, + "Unsupported WPF ConfigVersion {} (expected {SUPPORTED_CONFIG_VERSION})", + profile.config_version + ); + let configuration = select_configuration(profile, profile_name)?; + let (value, summary) = migrate(configuration, custom_schedule)?; + + let mut temp_path = Default::default(); + let out = out.unwrap_or_else(|| { + temp_path = file.with_extension("toml"); + temp_path.as_path() + }); + let ft = Filetype::parse_filetype(out).unwrap(); + if let Some(dir) = out.parent() { + use maa_dirs::Ensure; + dir.ensure()?; + } + ft.write(out, &value) + .with_context(|| format!("Failed to write migrated file {}", out.display()))?; + + eprint!("{summary}"); + Ok(()) +} + +/// Pick one configuration from a multi-profile GUI export. +/// +/// When multiple configurations exist, `profile_name` can be +/// used to select one without an interactive prompt. +pub(super) fn select_configuration( + mut profile: WpfProfile, + profile_name: Option, +) -> Result { + let current = profile.current.unwrap_or_default(); + let profile_name = match profile_name { + Some(name) => name, + None => match profile.configurations.len() { + 0 => bail!("GUI profile has no configuration"), + 1 => profile + .configurations + .keys() + .next() + .context("GUI profile has no configuration")? + .clone(), + _ => resolve_configuration_name(&profile.configurations, current)?, + }, + }; + trace!("Selected configuration: {profile_name}"); + profile + .configurations + .remove(&profile_name) + .with_context(|| format!("GUI configuration {profile_name} not found")) +} + +fn resolve_configuration_name( + configurations: &BTreeMap, + current: String, +) -> Result { + let names: Vec<&str> = configurations.keys().map(String::as_str).collect(); + let default_index = configurations + .iter() + .position(|(name, _)| *name == current) + .and_then(|i| NonZero::new(i + 1)); + + SelectD::::from_iter(names, default_index) + .context("Failed to build configuration selection")? + .with_description("a GUI configuration") + .value() + .context("Failed to select GUI configuration") +} + +/// Migrate a GUI configuration into maa-cli task config shape. +pub(super) fn migrate( + config: WpfConfig, + custom_schedule: Option<&Path>, +) -> Result<(CliConfig, MigrationSummary)> { + let mut summary = MigrationSummary::default(); + let value = config + .migrate_tasks(&mut summary, custom_schedule)? + .context("GUI configuration produced no CLI config")?; + Ok((value, summary)) +} + +/// Meta / structural keys that are never reported as skipped fields. +const META_FIELDS: &[&str] = &["$type", "TaskType", "Name", "IsEnable"]; + +pub(super) fn report_unknown_fields( + summary: &mut MigrationSummary, + type_tag: &str, + name: Option, + unknown: &serde_json::Map, + handled: &[&str], +) { + for (key, value) in unknown { + if META_FIELDS.contains(&key.as_str()) || handled.contains(&key.as_str()) { + continue; + } + if is_meaningful_json(value) { + summary.skip_field(type_tag, name.clone(), key.clone()); + } + } +} + +fn is_meaningful_json(value: &Value) -> bool { + match value { + Value::Bool(v) => *v, + Value::String(s) => !s.is_empty(), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + i != 0 && i != i64::from(i32::MAX) + } else if let Some(f) = n.as_f64() { + f != 0.0 + } else { + true + } + } + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + Value::Null => false, + } +} + +pub(super) fn split_semi_list(list: &str) -> Vec { + list.split(';') + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +fn deserialize_task_queue<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let items = Vec::::deserialize(deserializer)?; + items + .into_iter() + .map(parse_wpf_task) + .collect::, _>>() + .map_err(serde::de::Error::custom) +} + +fn parse_wpf_task(value: Value) -> Result { + let type_tag = value + .get("$type") + .and_then(Value::as_str) + .context("GUI task missing $type")?; + let name = value + .get("Name") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + match type_tag { + "StartUpTask" => Ok(WpfTask::StartUpTask(serde_json::from_value(value)?)), + "FightTask" => Ok(WpfTask::FightTask(serde_json::from_value(value)?)), + "InfrastTask" => Ok(WpfTask::InfrastTask(serde_json::from_value(value)?)), + "RecruitTask" => Ok(WpfTask::RecruitTask(serde_json::from_value(value)?)), + "MallTask" => Ok(WpfTask::MallTask(serde_json::from_value(value)?)), + "AwardTask" => Ok(WpfTask::AwardTask(serde_json::from_value(value)?)), + "RoguelikeTask" => Ok(WpfTask::RoguelikeTask(serde_json::from_value(value)?)), + "ReclamationTask" => Ok(WpfTask::ReclamationTask(serde_json::from_value(value)?)), + other => Ok(WpfTask::Unsupported { + type_tag: other.to_string(), + name, + }), + } +} + +impl WpfTask { + fn migrate_task( + &self, + gui: &WpfGuiSettings, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + schedule_applied: &mut bool, + ) -> Result> { + match self { + Self::StartUpTask(start_up) => { + start_up.report_to(summary); + Ok(Some(serde_json::to_value(start_up.to_cli_task(gui)?)?)) + } + Self::FightTask(fight) => { + fight.report_to(summary); + Ok(Some(Value::try_from(fight)?)) + } + Self::InfrastTask(task) => { + task.report_to(summary); + Ok(Some(serde_json::to_value(task.to_cli_task( + summary, + custom_schedule, + schedule_applied, + )?)?)) + } + Self::RecruitTask(task) => { + task.report_to(summary); + Ok(Some(Value::try_from(task)?)) + } + Self::MallTask(task) => { + task.report_to(summary); + Ok(Some(Value::try_from(task)?)) + } + Self::AwardTask(task) => { + task.report_to(summary); + Ok(Some(Value::try_from(task)?)) + } + Self::RoguelikeTask(task) => { + task.report_to(summary); + Ok(Some(Value::try_from(task)?)) + } + Self::ReclamationTask(task) => { + task.report_to(summary); + Ok(Some(Value::try_from(task)?)) + } + Self::Unsupported { type_tag, name } => { + summary.skip_task(type_tag, name.clone()); + Ok(None) + } + } + } +} + +/// WPF GUI `Gui` object for the selected configuration. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfGuiSettings { + #[serde(default)] + runtime_settings: Option, + #[serde(flatten)] + #[allow(dead_code)] + unknown: serde_json::Map, +} + +/// `Gui.RuntimeSettings` fields used by StartUp migration. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WpfRuntimeSettings { + #[serde(default)] + client_type: Option, + #[serde(default)] + start_game: Option, + #[serde(flatten)] + #[allow(dead_code)] + unknown: serde_json::Map, +} + +mod award; +mod fight; +mod infrast; +mod mall; +mod reclamation; +mod recruit; +mod roguelike; +mod start_up; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + fn configuration_from_task_queue(task: Value) -> WpfConfig { + serde_json::from_value(serde_json::json!({ + "TaskQueue": [task], + "Gui": {}, + })) + .unwrap() + } + + #[test] + fn unsupported_task_keeps_original_type_and_name() { + let config = configuration_from_task_queue(serde_json::json!({ + "$type": "UserDataUpdateTask", + "Name": "sync", + "IsEnable": false, + })); + let (_, summary) = migrate(config, None).unwrap(); + assert_eq!(summary.skipped_tasks.len(), 1); + assert_eq!(summary.skipped_tasks[0].type_tag, "UserDataUpdateTask"); + assert_eq!(summary.skipped_tasks[0].name.as_deref(), Some("sync")); + } + + #[test] + fn unsupported_config_version_is_rejected() { + let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); + std::fs::write( + file.path(), + serde_json::json!({ + "ConfigVersion": 2, + "Current": "Default", + "Configurations": { + "Default": { + "TaskQueue": [], + "Gui": {}, + } + } + }) + .to_string(), + ) + .unwrap(); + let err = wpf(file.path(), None, None, None).unwrap_err(); + assert!( + err.to_string().contains("Unsupported WPF ConfigVersion 2"), + "{err}" + ); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/reclamation.rs b/crates/maa-cli/src/config/migrate/wpf/reclamation.rs new file mode 100644 index 00000000..daa5cb84 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/reclamation.rs @@ -0,0 +1,108 @@ +use anyhow::Result; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields}; + +const HANDLED: &[&str] = &[ + "Theme", + "Mode", + "ToolToCraft", + "IncrementMode", + "MaxCraftCountPerRound", +]; + +#[derive(Debug, Serialize)] +struct CliReclamationTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliReclamationParams, +} + +#[derive(Debug, Serialize)] +struct CliReclamationParams { + #[serde(skip_serializing_if = "Option::is_none")] + theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools_to_craft: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + increment_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + num_craft_batches: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +/// WPF GUI `ReclamationTask` (`$type = "ReclamationTask"`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfReclamationTask { + name: String, + is_enable: bool, + theme: String, + mode: String, + tool_to_craft: String, + increment_mode: i32, + max_craft_count_per_round: i32, + #[serde(flatten)] + unknown: Map, +} + +impl WpfReclamationTask { + fn mapped_mode(&self) -> Option { + match self.mode.as_str() { + "ProsperityNoSave" => Some(0), + "ProsperityInSave" => Some(1), + _ => None, + } + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("ReclamationTask", Some(self.name.clone())); + } + if self.mapped_mode().is_none() { + summary.skip_field("ReclamationTask", Some(self.name.clone()), "Mode"); + } + report_unknown_fields( + summary, + "ReclamationTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } +} + +impl TryFrom<&WpfReclamationTask> for Value { + type Error = anyhow::Error; + + fn try_from(task: &WpfReclamationTask) -> Result { + Ok(serde_json::to_value(CliReclamationTask::try_from(task)?)?) + } +} + +impl TryFrom<&WpfReclamationTask> for CliReclamationTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfReclamationTask) -> Result { + Ok(CliReclamationTask { + task_type: TaskType::Reclamation, + name: task.name.clone(), + params: CliReclamationParams { + theme: Some(task.theme.clone()), + mode: task.mapped_mode(), + tools_to_craft: (!task.tool_to_craft.is_empty()) + .then(|| vec![task.tool_to_craft.clone()]), + increment_mode: Some(task.increment_mode), + num_craft_batches: Some(task.max_craft_count_per_round), + enable: (!task.is_enable).then_some(false), + }, + }) + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/recruit.rs b/crates/maa-cli/src/config/migrate/wpf/recruit.rs new file mode 100644 index 00000000..60f4116f --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/recruit.rs @@ -0,0 +1,171 @@ +use anyhow::Result; +use log::warn; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields}; + +const HANDLED: &[&str] = &[ + "MaxTimes", + "ExtraTagMode", + "RefreshLevel3", + "ForceRefresh", + "Level6Choose", + "Level6Time", + "Level5Choose", + "Level5Time", + "Level4Choose", + "Level4Time", + "Level3Choose", + "Level3Time", + "PreferTagEnabled", + "Level3PreferTags", + "PreserveTagEnabled", + "PreserveTagList", +]; + +#[derive(Debug, Serialize)] +struct CliRecruitTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliRecruitParams, +} + +#[derive(Debug, Serialize)] +struct CliRecruitParams { + #[serde(skip_serializing_if = "Option::is_none")] + times: Option, + #[serde(skip_serializing_if = "Option::is_none")] + extra_tags_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + select: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + confirm: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + recruitment_time: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + first_tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + preserve_tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +/// WPF GUI `RecruitTask` (`$type = "RecruitTask"`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfRecruitTask { + name: String, + is_enable: bool, + max_times: i32, + extra_tag_mode: i32, + refresh_level3: bool, + force_refresh: bool, + level3_choose: bool, + #[serde(default)] + level3_time: Option, + level4_choose: bool, + #[serde(default)] + level4_time: Option, + level5_choose: bool, + #[serde(default)] + level5_time: Option, + level6_choose: bool, + #[serde(default)] + level6_time: Option, + prefer_tag_enabled: bool, + #[serde(default)] + level3_prefer_tags: Vec, + preserve_tag_enabled: bool, + #[serde(default)] + preserve_tag_list: Vec, + #[serde(flatten)] + unknown: Map, +} + +impl WpfRecruitTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("RecruitTask", Some(self.name.clone())); + } + // GUI ForceRefresh is not maa-cli/MaaCore `expedite`; maa-cli does not support it. + if self.force_refresh { + summary.skip_field("RecruitTask", Some(self.name.clone()), "ForceRefresh"); + warn!( + "RecruitTask ForceRefresh is not supported by maa-cli; this setting has no effect" + ); + } + if self.level6_choose { + warn!( + "RecruitTask Level6Choose is enabled; recruitment will auto-confirm 6★ operators (dangerous)" + ); + } + report_unknown_fields( + summary, + "RecruitTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } +} + +impl TryFrom<&WpfRecruitTask> for Value { + type Error = anyhow::Error; + + fn try_from(task: &WpfRecruitTask) -> Result { + Ok(serde_json::to_value(CliRecruitTask::try_from(task)?)?) + } +} + +impl TryFrom<&WpfRecruitTask> for CliRecruitTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfRecruitTask) -> Result { + let levels = [ + (task.level6_choose, task.level6_time, 6), + (task.level5_choose, task.level5_time, 5), + (task.level4_choose, task.level4_time, 4), + (task.level3_choose, task.level3_time, 3), + ]; + + let mut select = Vec::new(); + let mut confirm = Vec::new(); + let mut recruitment_time = maa_value::map::StringMap::new(); + for (choose, minutes, level) in levels { + if choose { + select.push(level); + confirm.push(level); + } + if let Some(minutes) = minutes { + recruitment_time.insert(level.to_string(), minutes); + } + } + + let first_tags = (task.prefer_tag_enabled && !task.level3_prefer_tags.is_empty()) + .then(|| task.level3_prefer_tags.clone()); + let preserve_tags = (task.preserve_tag_enabled && !task.preserve_tag_list.is_empty()) + .then(|| task.preserve_tag_list.clone()); + + Ok(CliRecruitTask { + task_type: TaskType::Recruit, + name: task.name.clone(), + params: CliRecruitParams { + times: Some(task.max_times), + extra_tags_mode: Some(task.extra_tag_mode), + refresh: Some(task.refresh_level3), + select, + confirm, + recruitment_time: (!recruitment_time.is_empty()).then_some(recruitment_time), + first_tags, + preserve_tags, + enable: (!task.is_enable).then_some(false), + }, + }) + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs new file mode 100644 index 00000000..4e0ac36f --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs @@ -0,0 +1,176 @@ +use anyhow::Result; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, report_unknown_fields}; + +const HANDLED: &[&str] = &[ + "Theme", + "Mode", + "Squad", + "Roles", + "CoreChar", + "StartCount", + "Difficulty", + "Investment", + "InvestCount", + "InvestWithMoreScore", + "StopWhenDepositFull", + "StopAtFinalBoss", + "StopWhenLevelMax", + "UseSupport", + "UseSupportNonFriend", + "RefreshTraderWithDice", + "StartWithEliteTwo", + "StartWithEliteTwoOnly", +]; + +#[derive(Debug, Serialize)] +struct CliRoguelikeTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliRoguelikeParams, +} + +#[derive(Debug, Serialize)] +struct CliRoguelikeParams { + #[serde(skip_serializing_if = "Option::is_none")] + theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + squad: Option, + #[serde(skip_serializing_if = "Option::is_none")] + roles: Option, + #[serde(skip_serializing_if = "Option::is_none")] + core_char: Option, + #[serde(skip_serializing_if = "Option::is_none")] + starts_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + difficulty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + investment_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + investments_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + investment_with_more_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_when_investment_full: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_at_final_boss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_at_max_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + use_support: Option, + #[serde(skip_serializing_if = "Option::is_none")] + use_nonfriend_support: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_trader_with_dice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + start_with_elite_two: Option, + #[serde(skip_serializing_if = "Option::is_none")] + only_start_with_elite_two: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +/// WPF GUI `RoguelikeTask` (`$type = "RoguelikeTask"`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfRoguelikeTask { + name: String, + is_enable: bool, + theme: String, + mode: String, + squad: String, + roles: String, + core_char: String, + start_count: i32, + difficulty: i32, + investment: bool, + invest_count: i32, + invest_with_more_score: bool, + stop_when_deposit_full: bool, + stop_at_final_boss: bool, + stop_when_level_max: bool, + use_support: bool, + use_support_non_friend: bool, + refresh_trader_with_dice: bool, + start_with_elite_two: bool, + start_with_elite_two_only: bool, + #[serde(flatten)] + unknown: Map, +} + +impl WpfRoguelikeTask { + fn mapped_mode(&self) -> Option { + match self.mode.as_str() { + "Exp" => Some(0), + "Investment" => Some(1), + "Collect" => Some(4), + "CollapsalParadigms" => Some(5), + "MonthlySquad" => Some(6), + "DeepExploration" => Some(7), + _ => None, + } + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("RoguelikeTask", Some(self.name.clone())); + } + if self.mapped_mode().is_none() { + summary.skip_field("RoguelikeTask", Some(self.name.clone()), "Mode"); + } + report_unknown_fields( + summary, + "RoguelikeTask", + Some(self.name.clone()), + &self.unknown, + HANDLED, + ); + } +} + +impl TryFrom<&WpfRoguelikeTask> for Value { + type Error = anyhow::Error; + + fn try_from(task: &WpfRoguelikeTask) -> Result { + Ok(serde_json::to_value(CliRoguelikeTask::try_from(task)?)?) + } +} + +impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { + type Error = anyhow::Error; + + fn try_from(task: &WpfRoguelikeTask) -> Result { + Ok(CliRoguelikeTask { + task_type: TaskType::Roguelike, + name: task.name.clone(), + params: CliRoguelikeParams { + theme: Some(task.theme.clone()), + mode: task.mapped_mode(), + squad: Some(task.squad.clone()), + roles: Some(task.roles.clone()), + core_char: Some(task.core_char.clone()), + starts_count: Some(task.start_count), + difficulty: (task.difficulty != i32::MAX).then_some(task.difficulty), + investment_enabled: Some(task.investment), + investments_count: Some(task.invest_count), + investment_with_more_score: Some(task.invest_with_more_score), + stop_when_investment_full: Some(task.stop_when_deposit_full), + stop_at_final_boss: Some(task.stop_at_final_boss), + stop_at_max_level: Some(task.stop_when_level_max), + use_support: Some(task.use_support), + use_nonfriend_support: Some(task.use_support_non_friend), + refresh_trader_with_dice: Some(task.refresh_trader_with_dice), + start_with_elite_two: Some(task.start_with_elite_two), + only_start_with_elite_two: Some(task.start_with_elite_two_only), + enable: (!task.is_enable).then_some(false), + }, + }) + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/start_up.rs b/crates/maa-cli/src/config/migrate/wpf/start_up.rs new file mode 100644 index 00000000..9fb4d57c --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/start_up.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use log::warn; +use maa_types::TaskType; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::{MigrationSummary, WpfGuiSettings}; +use crate::config::task::ClientType; + +/// maa-cli StartUp task shape written by migration. +#[derive(Debug, Serialize)] +struct CliStartUpTask { + #[serde(rename = "type")] + task_type: TaskType, + #[serde(skip_serializing_if = "str::is_empty")] + name: String, + params: CliStartUpParams, +} + +#[derive(Debug, Serialize)] +struct CliStartUpParams { + client_type: CliClientTypeParam, + #[serde(skip_serializing_if = "Option::is_none")] + start_game_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + account_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +/// Fixed client type string, or an interactive select prompt. +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum CliClientTypeParam { + Fixed(ClientType), + Prompt { + alternatives: &'static [&'static str], + }, +} + +/// WPF GUI `StartUpTask` (`$type = "StartUpTask"`). +/// +/// Task-queue fields combine with [`WpfGuiSettings::runtime_settings`] when +/// converting to [`CliStartUpTask`]. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct WpfStartUpTask { + name: String, + is_enable: bool, + account_switch_enabled: bool, + /// Absent when the GUI export omits the key. + #[serde(default)] + account_name: Option, + #[serde(flatten)] + #[allow(dead_code)] + unknown: Map, +} + +impl WpfStartUpTask { + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("StartUpTask", Some(self.name.clone())); + } + if self.account_switch_enabled && self.account_name.is_none() { + summary.skip_field("StartUpTask", Some(self.name.clone()), "AccountName"); + warn!("AccountName is missing, but GUI enable account switching"); + } + } + + pub(super) fn to_cli_task(&self, gui: &WpfGuiSettings) -> Result { + CliStartUpTask::try_from((self, gui)) + } +} + +impl TryFrom<(&WpfStartUpTask, &WpfGuiSettings)> for CliStartUpTask { + type Error = anyhow::Error; + + fn try_from((task, gui): (&WpfStartUpTask, &WpfGuiSettings)) -> Result { + let runtime = gui.runtime_settings.as_ref(); + + let client_type = match runtime.and_then(|r| r.client_type.as_deref()) { + Some(s) if !s.is_empty() => match s.parse::() { + Ok(client) => CliClientTypeParam::Fixed(client), + Err(_) => CliClientTypeParam::Prompt { + alternatives: &ClientType::NAMES, + }, + }, + _ => CliClientTypeParam::Prompt { + alternatives: &ClientType::NAMES, + }, + }; + + let account_name = if task.account_switch_enabled { + task.account_name.clone() + } else { + None + }; + + Ok(CliStartUpTask { + task_type: TaskType::StartUp, + name: task.name.clone(), + params: CliStartUpParams { + client_type, + start_game_enabled: runtime.and_then(|r| r.start_game), + account_name, + enable: (!task.is_enable).then_some(false), + }, + }) + } +} diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 1443e02d..0181fe38 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -111,12 +111,12 @@ fn main() -> Result<()> { Command::Migrate(command::MigrateCommand::Wpf { input, output, - config, + profile_name, custom_schedule, }) => config::migrate::wpf( &input, output.as_deref(), - config, + profile_name, custom_schedule.as_deref(), )?, Command::Activity { client } => activity::display_stage_activity(client)?, From 773e7c71bec870d64d9bf5a1e627e7993ba6b7b3 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sat, 22 Aug 2026 02:58:34 +0800 Subject: [PATCH 20/20] =?UTF-8?q?=F0=9F=93=8B=20test:=20enhance=20WPF=20mi?= =?UTF-8?q?gration=20tests=20for=20various=20task=20configurations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for Fight, Mall, Reclamation, Recruit, and Roguelike tasks, ensuring correct mapping of parameters and handling of optional fields. Validate behavior for shared parameters, stage plans, and error handling for invalid configurations. Improve overall test coverage for WPF migration functionality. --- .../maa-cli/src/config/migrate/wpf/fight.rs | 212 ++++++++++++++++++ crates/maa-cli/src/config/migrate/wpf/mall.rs | 112 +++++++++ crates/maa-cli/src/config/migrate/wpf/mod.rs | 90 ++++++++ .../src/config/migrate/wpf/reclamation.rs | 128 +++++++++++ .../maa-cli/src/config/migrate/wpf/recruit.rs | 160 +++++++++++++ .../src/config/migrate/wpf/roguelike.rs | 134 +++++++++++ .../src/config/migrate/wpf/start_up.rs | 137 +++++++++++ 7 files changed, 973 insertions(+) diff --git a/crates/maa-cli/src/config/migrate/wpf/fight.rs b/crates/maa-cli/src/config/migrate/wpf/fight.rs index c88976bb..b7d99f40 100644 --- a/crates/maa-cli/src/config/migrate/wpf/fight.rs +++ b/crates/maa-cli/src/config/migrate/wpf/fight.rs @@ -571,4 +571,216 @@ mod tests { "{err}" ); } + + #[test] + fn simple_single_stage_with_shared_params() { + let config = fight_profile(serde_json::json!({ + "Name": "理智", + "StagePlan": "CE-6", + "UseMedicine": true, + "MedicineCount": 999, + "UseStone": true, + "StoneCount": 1, + "EnableTimesLimit": true, + "TimesLimit": 5, + "EnableTargetDrop": true, + "DropId": "30012", + "DropCount": 9, + "Series": 1, + "UseExpiringMedicine": true, + "MedicineExpireDays": 2, + })); + let (cli, summary) = migrate(config, None).unwrap(); + assert!(summary.is_empty()); + assert_eq!( + serde_json::to_value(&cli.tasks[0]).unwrap(), + serde_json::json!({ + "type": "Fight", + "name": "理智", + "params": { + "medicine": 999, + "stone": 1, + "times": 5, + "drops": { "30012": 9 }, + "series": 1, + "medicine_expire_days": 2, + "stage": "CE-6", + } + }) + ); + } + + #[test] + fn optional_stages_use_weekday_and_always() { + let config = fight_profile(serde_json::json!({ + "UseOptionalStage": true, + "StagePlan": ["CE-6", "LS-6"], + })); + let (cli, summary) = migrate(config, None).unwrap(); + assert!(summary.is_empty()); + assert_eq!( + serde_json::to_value(&cli.tasks[0]).unwrap(), + serde_json::json!({ + "type": "Fight", + "name": "剿灭", + "variants": [ + { + "condition": { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat", "Sun"], + "timezone": "Official", + }, + "params": { "series": 0, "stage": "CE-6" }, + }, + { + "condition": { "type": "Always" }, + "params": { "series": 0, "stage": "LS-6" }, + }, + ] + }) + ); + } + + #[test] + fn weekly_schedule_single_stage() { + let config = fight_profile(serde_json::json!({ + "UseWeeklySchedule": true, + "WeeklySchedule": { + "Sunday": false, + "Monday": true, + "Tuesday": false, + "Wednesday": true, + "Thursday": false, + "Friday": true, + "Saturday": false, + }, + "StagePlan": "LS-6", + })); + let (cli, _) = migrate(config, None).unwrap(); + assert_eq!( + serde_json::to_value(&cli.tasks[0]).unwrap(), + serde_json::json!({ + "type": "Fight", + "name": "剿灭", + "variants": [{ + "condition": { + "type": "Weekday", + "weekdays": ["Mon", "Wed", "Fri"], + }, + "params": { "series": 0, "stage": "LS-6" }, + }] + }) + ); + } + + #[test] + fn weekly_and_optional_combined() { + let config = fight_profile(serde_json::json!({ + "UseWeeklySchedule": true, + "UseOptionalStage": true, + "WeeklySchedule": { + "Sunday": true, + "Monday": false, + "Tuesday": false, + "Wednesday": false, + "Thursday": false, + "Friday": false, + "Saturday": false, + }, + "StagePlan": ["CE-6", "LS-6"], + "Series": 2, + })); + let (cli, _) = migrate(config, None).unwrap(); + assert_eq!( + serde_json::to_value(&cli.tasks[0]).unwrap(), + serde_json::json!({ + "type": "Fight", + "name": "剿灭", + "params": { "series": 2 }, + "variants": [ + { + "condition": { + "type": "And", + "conditions": [ + { "type": "Weekday", "weekdays": ["Sun"] }, + { + "type": "Weekday", + "weekdays": ["Tue", "Thu", "Sat", "Sun"], + "timezone": "Official", + }, + ], + }, + "params": { "series": 0, "stage": "CE-6" }, + }, + { + "condition": { + "type": "And", + "conditions": [ + { "type": "Weekday", "weekdays": ["Sun"] }, + { "type": "Always" }, + ], + }, + "params": { "series": 0, "stage": "LS-6" }, + }, + ] + }) + ); + } + + #[test] + fn disabled_fight_sets_enable_false() { + let config = fight_profile(serde_json::json!({ + "IsEnable": false, + "StagePlan": "LS-6", + })); + let (cli, summary) = migrate(config, None).unwrap(); + assert_eq!(summary.disabled_tasks.len(), 1); + assert_eq!( + cli.tasks[0].pointer("/params/enable"), + Some(&serde_json::json!(false)) + ); + } + + #[test] + fn stage_plan_validation_errors() { + let err = migrate( + fight_profile(serde_json::json!({ + "UseOptionalStage": false, + "StagePlan": ["A", "B"], + })), + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("string array"), "{err}"); + + let err = migrate( + fight_profile(serde_json::json!({ + "UseOptionalStage": true, + "StagePlan": "CE-6", + })), + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("string array"), "{err}"); + + let err = migrate( + fight_profile(serde_json::json!({ + "UseOptionalStage": true, + "StagePlan": [], + })), + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("non-empty"), "{err}"); + + let err = migrate( + fight_profile(serde_json::json!({ + "UseWeeklySchedule": true, + "StagePlan": "LS-6", + })), + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("WeeklySchedule"), "{err}"); + } } diff --git a/crates/maa-cli/src/config/migrate/wpf/mall.rs b/crates/maa-cli/src/config/migrate/wpf/mall.rs index 4d9afbbd..961fa357 100644 --- a/crates/maa-cli/src/config/migrate/wpf/mall.rs +++ b/crates/maa-cli/src/config/migrate/wpf/mall.rs @@ -114,3 +114,115 @@ impl TryFrom<&WpfMallTask> for CliMallTask { }) } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use serde_json::json; + + use super::super::migrate; + + #[test] + fn maps_mall_fields_and_splits_semi_lists() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "MallTask", + "Name": "", + "IsEnable": true, + "Shopping": true, + "CreditFight": false, + "CreditFightFormation": 0, + "VisitFriends": true, + "FirstList": "加急许可;招聘许可", + "BlackList": "碳;家具;", + "ShoppingIgnoreBlackListWhenFull": true, + "OnlyBuyDiscount": false, + "ReserveMaxCredit": false, + "CreditFightLastTime": "2026/06/08 00:00:00", + "IsCreditFightAvailable": false, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["CreditFightLastTime"] + ); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Mall", + "params": { + "shopping": true, + "credit_fight": false, + "formation_index": 0, + "visit_friends": true, + "buy_first": ["加急许可", "招聘许可"], + "blacklist": ["碳", "家具"], + "force_shopping_if_credit_full": true, + "only_buy_discount": false, + "reserve_max_credit": false, + } + }] + }) + ); + } + + #[test] + fn empty_semi_lists_omit_arrays() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "MallTask", + "Name": "信用", + "IsEnable": false, + "Shopping": false, + "CreditFight": false, + "CreditFightFormation": 1, + "VisitFriends": false, + "FirstList": "", + "BlackList": "", + "ShoppingIgnoreBlackListWhenFull": false, + "OnlyBuyDiscount": true, + "ReserveMaxCredit": true, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!(summary.disabled_tasks.len(), 1); + assert_eq!(summary.disabled_tasks[0].type_tag, "MallTask"); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Mall", + "name": "信用", + "params": { + "shopping": false, + "credit_fight": false, + "formation_index": 1, + "visit_friends": false, + "force_shopping_if_credit_full": false, + "only_buy_discount": true, + "reserve_max_credit": true, + "enable": false, + } + }] + }) + ); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/mod.rs b/crates/maa-cli/src/config/migrate/wpf/mod.rs index 289f82e3..1a89c3f6 100644 --- a/crates/maa-cli/src/config/migrate/wpf/mod.rs +++ b/crates/maa-cli/src/config/migrate/wpf/mod.rs @@ -398,4 +398,94 @@ mod tests { "{err}" ); } + + #[test] + fn split_semi_list_filters_empty_items() { + assert_eq!(split_semi_list("加急许可;招聘许可;"), vec![ + "加急许可".to_string(), + "招聘许可".to_string() + ]); + assert!(split_semi_list("").is_empty()); + assert!(split_semi_list(";;;").is_empty()); + } + + #[test] + fn select_configuration_picks_named_or_sole_profile() { + let sole: WpfProfile = serde_json::from_value(serde_json::json!({ + "ConfigVersion": 1, + "Configurations": { + "Only": { + "TaskQueue": [{ + "$type": "AwardTask", + "Name": "", + "IsEnable": true, + "Award": true, + "Mail": false, + "FreeGacha": false, + "Orundum": false, + "Mining": false, + "SpecialAccess": false, + }], + "Gui": {}, + } + } + })) + .unwrap(); + let cfg = select_configuration(sole, None).unwrap(); + assert_eq!(cfg.task_queue.len(), 1); + + let multi: WpfProfile = serde_json::from_value(serde_json::json!({ + "ConfigVersion": 1, + "Current": "Default", + "Configurations": { + "Default": { "TaskQueue": [], "Gui": {} }, + "Alt": { "TaskQueue": [], "Gui": {} }, + } + })) + .unwrap(); + select_configuration(multi, Some("Alt".into())).unwrap(); + + let missing: WpfProfile = serde_json::from_value(serde_json::json!({ + "ConfigVersion": 1, + "Configurations": { + "Default": { "TaskQueue": [], "Gui": {} }, + } + })) + .unwrap(); + let err = select_configuration(missing, Some("Nope".into())).unwrap_err(); + assert!(err.to_string().contains("not found"), "{err}"); + + let empty: WpfProfile = serde_json::from_value(serde_json::json!({ + "ConfigVersion": 1, + "Configurations": {}, + })) + .unwrap(); + let err = select_configuration(empty, None).unwrap_err(); + assert!(err.to_string().contains("no configuration"), "{err}"); + } + + #[test] + fn report_unknown_fields_ignores_non_meaningful_values() { + let mut summary = MigrationSummary::default(); + let unknown = serde_json::json!({ + "FutureFlag": true, + "EmptyString": "", + "Zero": 0, + "MaxInt": 2147483647, + "EmptyArray": [], + "NullField": null, + }) + .as_object() + .unwrap() + .clone(); + report_unknown_fields(&mut summary, "DemoTask", Some("x".into()), &unknown, &[]); + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["FutureFlag"] + ); + } } diff --git a/crates/maa-cli/src/config/migrate/wpf/reclamation.rs b/crates/maa-cli/src/config/migrate/wpf/reclamation.rs index daa5cb84..3a44939c 100644 --- a/crates/maa-cli/src/config/migrate/wpf/reclamation.rs +++ b/crates/maa-cli/src/config/migrate/wpf/reclamation.rs @@ -106,3 +106,131 @@ impl TryFrom<&WpfReclamationTask> for CliReclamationTask { }) } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use serde_json::json; + + use super::super::migrate; + + #[test] + fn maps_reclamation_and_skips_clear_store() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "ReclamationTask", + "Name": "", + "Theme": "Tales", + "Mode": "ProsperityInSave", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 16, + "ClearStore": true, + "IsEnable": false, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!(summary.disabled_tasks.len(), 1); + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["ClearStore"] + ); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Reclamation", + "params": { + "theme": "Tales", + "mode": 1, + "increment_mode": 0, + "num_craft_batches": 16, + "enable": false, + } + }] + }) + ); + } + + #[test] + fn maps_tool_to_craft_and_prosperity_no_save() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "ReclamationTask", + "Name": "生息", + "Theme": "Tales", + "Mode": "ProsperityNoSave", + "ToolToCraft": "手电钻", + "IncrementMode": 1, + "MaxCraftCountPerRound": 8, + "IsEnable": true, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.is_empty()); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Reclamation", + "name": "生息", + "params": { + "theme": "Tales", + "mode": 0, + "tools_to_craft": ["手电钻"], + "increment_mode": 1, + "num_craft_batches": 8, + } + }] + }) + ); + } + + #[test] + fn unknown_reclamation_mode_is_skipped() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "ReclamationTask", + "Name": "", + "Theme": "Tales", + "Mode": "UnknownMode", + "ToolToCraft": "", + "IncrementMode": 0, + "MaxCraftCountPerRound": 1, + "IsEnable": true, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["Mode"] + ); + assert!(config.tasks[0].pointer("/params/mode").is_none()); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/recruit.rs b/crates/maa-cli/src/config/migrate/wpf/recruit.rs index 60f4116f..f0530d42 100644 --- a/crates/maa-cli/src/config/migrate/wpf/recruit.rs +++ b/crates/maa-cli/src/config/migrate/wpf/recruit.rs @@ -169,3 +169,163 @@ impl TryFrom<&WpfRecruitTask> for CliRecruitTask { }) } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use serde_json::json; + + use super::super::migrate; + + #[test] + fn maps_recruit_fields_and_skips_force_refresh() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "RecruitTask", + "Name": "", + "IsEnable": true, + "MaxTimes": 4, + "ExtraTagMode": 0, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level4Choose": true, + "Level5Choose": true, + "Level6Choose": false, + "Level3Time": 540, + "Level4Time": 540, + "PreferTagEnabled": true, + "Level3PreferTags": [], + "PreserveTagList": ["支援机械"], + "PreserveTagEnabled": false, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["ForceRefresh"] + ); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Recruit", + "params": { + "times": 4, + "extra_tags_mode": 0, + "refresh": true, + "select": [5, 4, 3], + "confirm": [5, 4, 3], + "recruitment_time": { "3": 540, "4": 540 }, + } + }] + }) + ); + } + + #[test] + fn prefer_and_preserve_tags_when_enabled() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "RecruitTask", + "Name": "公招", + "IsEnable": true, + "MaxTimes": 1, + "ExtraTagMode": 0, + "RefreshLevel3": false, + "ForceRefresh": false, + "Level3Choose": true, + "Level4Choose": false, + "Level5Choose": false, + "Level6Choose": false, + "Level3Time": 460, + "PreferTagEnabled": true, + "Level3PreferTags": ["快速复活"], + "PreserveTagEnabled": true, + "PreserveTagList": ["支援机械"], + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.is_empty()); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Recruit", + "name": "公招", + "params": { + "times": 1, + "extra_tags_mode": 0, + "refresh": false, + "select": [3], + "confirm": [3], + "recruitment_time": { "3": 460 }, + "first_tags": ["快速复活"], + "preserve_tags": ["支援机械"], + } + }] + }) + ); + } + + #[test] + fn disabled_recruit_sets_enable_false() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "RecruitTask", + "Name": "公招", + "IsEnable": false, + "MaxTimes": 1, + "ExtraTagMode": 0, + "RefreshLevel3": false, + "ForceRefresh": false, + "Level3Choose": false, + "Level4Choose": false, + "Level5Choose": false, + "Level6Choose": false, + "PreferTagEnabled": false, + "PreserveTagEnabled": false, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!(summary.disabled_tasks.len(), 1); + assert_eq!(summary.disabled_tasks[0].type_tag, "RecruitTask"); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Recruit", + "name": "公招", + "params": { + "times": 1, + "extra_tags_mode": 0, + "refresh": false, + "enable": false, + } + }] + }) + ); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs index 4e0ac36f..3f125d1e 100644 --- a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs +++ b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs @@ -174,3 +174,137 @@ impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { }) } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use serde_json::json; + + use super::super::migrate; + + fn roguelike_task(mode: &str, difficulty: i32, enabled: bool) -> serde_json::Value { + json!({ + "$type": "RoguelikeTask", + "Name": "肉鸽", + "IsEnable": enabled, + "Theme": "JieGarden", + "Mode": mode, + "Squad": "指挥分队", + "Roles": "稳扎稳打", + "CoreChar": "", + "StartCount": 999, + "Difficulty": difficulty, + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "StopWhenDepositFull": false, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "UseSupport": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + }) + } + + #[test] + fn maps_roguelike_and_omits_max_difficulty() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [roguelike_task("Investment", i32::MAX, false)], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!(summary.disabled_tasks.len(), 1); + assert_eq!(summary.disabled_tasks[0].type_tag, "RoguelikeTask"); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "Roguelike", + "name": "肉鸽", + "params": { + "theme": "JieGarden", + "mode": 1, + "squad": "指挥分队", + "roles": "稳扎稳打", + "core_char": "", + "starts_count": 999, + "investment_enabled": true, + "investments_count": 999, + "investment_with_more_score": false, + "stop_when_investment_full": false, + "stop_at_final_boss": false, + "stop_at_max_level": false, + "use_support": false, + "use_nonfriend_support": false, + "refresh_trader_with_dice": false, + "start_with_elite_two": false, + "only_start_with_elite_two": false, + "enable": false, + } + }] + }) + ); + } + + #[test] + fn maps_known_roguelike_modes() { + for (mode, expected) in [ + ("Exp", 0), + ("Collect", 4), + ("CollapsalParadigms", 5), + ("MonthlySquad", 6), + ("DeepExploration", 7), + ] { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [roguelike_task(mode, 3, true)], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + assert!(summary.is_empty(), "{mode}"); + assert_eq!( + config.tasks[0].pointer("/params/mode"), + Some(&json!(expected)), + "{mode}" + ); + assert_eq!( + config.tasks[0].pointer("/params/difficulty"), + Some(&json!(3)), + "{mode}" + ); + } + } + + #[test] + fn unknown_roguelike_mode_is_skipped() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [roguelike_task("FutureMode", 1, true)], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["Mode"] + ); + assert!(config.tasks[0].pointer("/params/mode").is_none()); + } +} diff --git a/crates/maa-cli/src/config/migrate/wpf/start_up.rs b/crates/maa-cli/src/config/migrate/wpf/start_up.rs index 9fb4d57c..60f2c6e7 100644 --- a/crates/maa-cli/src/config/migrate/wpf/start_up.rs +++ b/crates/maa-cli/src/config/migrate/wpf/start_up.rs @@ -108,3 +108,140 @@ impl TryFrom<(&WpfStartUpTask, &WpfGuiSettings)> for CliStartUpTask { }) } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use serde_json::json; + + use super::super::migrate; + use crate::config::task::ClientType; + + #[test] + fn startup_uses_runtime_settings() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "StartUpTask", + "Name": "", + "IsEnable": true, + "AccountSwitchEnabled": false, + }], + "Gui": { + "RuntimeSettings": { + "ClientType": "Official", + "StartGame": true, + } + }, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.is_empty()); + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "tasks": [{ + "type": "StartUp", + "params": { + "client_type": "Official", + "start_game_enabled": true, + } + }] + }) + ); + } + + #[test] + fn missing_or_invalid_client_type_uses_prompt() { + for client_type in [json!(null), json!(""), json!("NotAClient")] { + let mut gui = json!({ "Gui": { "RuntimeSettings": {} } }); + if !client_type.is_null() { + gui["Gui"]["RuntimeSettings"]["ClientType"] = client_type.clone(); + } + let (config, _) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "StartUpTask", + "Name": "启动", + "IsEnable": true, + "AccountSwitchEnabled": false, + }], + "Gui": gui["Gui"], + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + config.tasks[0].pointer("/params/client_type"), + Some(&json!({ "alternatives": ClientType::NAMES })), + "{client_type}" + ); + } + } + + #[test] + fn account_switch_maps_name_or_reports_missing() { + let (with_name, summary_ok) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "StartUpTask", + "Name": "", + "IsEnable": true, + "AccountSwitchEnabled": true, + "AccountName": "main", + }], + "Gui": { + "RuntimeSettings": { "ClientType": "Bilibili", "StartGame": false } + }, + })) + .unwrap(), + None, + ) + .unwrap(); + assert!(summary_ok.is_empty()); + assert_eq!( + with_name.tasks[0].pointer("/params/account_name"), + Some(&json!("main")) + ); + + let (without_name, summary_skip) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "StartUpTask", + "Name": "", + "IsEnable": false, + "AccountSwitchEnabled": true, + }], + "Gui": { + "RuntimeSettings": { "ClientType": "Official" } + }, + })) + .unwrap(), + None, + ) + .unwrap(); + assert_eq!(summary_skip.disabled_tasks.len(), 1); + assert_eq!( + summary_skip + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["AccountName"] + ); + assert!( + without_name.tasks[0] + .pointer("/params/account_name") + .is_none() + ); + assert_eq!( + without_name.tasks[0].pointer("/params/enable"), + Some(&json!(false)) + ); + } +}