From 8bdfaa9a32b0fc5dad29252c4dc6ba37ebeebc0a Mon Sep 17 00:00:00 2001 From: nan-mu Date: Thu, 9 Jul 2026 02:53:14 +0800 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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/29] =?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)) + ); + } +} From a7403ab1bf6a02e5dd098d28c48be608b74c1be2 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sat, 3 Oct 2026 18:16:19 +0800 Subject: [PATCH 21/29] perf: buffer WPF profile reads --- crates/maa-cli/src/config/migrate/wpf/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/wpf/mod.rs b/crates/maa-cli/src/config/migrate/wpf/mod.rs index 1a89c3f6..2d93ef2b 100644 --- a/crates/maa-cli/src/config/migrate/wpf/mod.rs +++ b/crates/maa-cli/src/config/migrate/wpf/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, num::NonZero, path::Path}; +use std::{collections::BTreeMap, io::BufReader, num::NonZero, path::Path}; use anyhow::{Context, Result, bail, ensure}; use log::trace; @@ -93,8 +93,8 @@ pub(crate) fn wpf( "`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")?)?; + let profile_file = std::fs::File::open(file).context("Trying to open wpf profile")?; + let profile: WpfProfile = serde_json::from_reader(BufReader::new(profile_file))?; ensure!( profile.config_version == SUPPORTED_CONFIG_VERSION, "Unsupported WPF ConfigVersion {} (expected {SUPPORTED_CONFIG_VERSION})", From 5822be714f880cfabb08325a0f5f0d3840fd906c Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sat, 3 Oct 2026 18:17:53 +0800 Subject: [PATCH 22/29] fix: preserve WPF activity start times --- crates/maa-cli/docs/zh-CN/wpf.md | 6 +-- .../maa-cli/src/config/migrate/wpf/fight.rs | 43 +++++++++++++++---- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md index 44ceffe3..40bfec99 100644 --- a/crates/maa-cli/docs/zh-CN/wpf.md +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -51,7 +51,7 @@ GUI 里 `IsEnable = false` 的任务仍会写出,但会带上 `params.enable = - 资源本 / 芯片本:`Weekday`(官服服务器时区) - 永久关卡(如主线、剿灭、`LS-6`):`Always` -- 活动关卡:若能在热更新资源 `StageActivityV2.json` 中查到对应活动,则写入 `DateTime`(使用该活动的截止时间与时区);查不到则回退为 `Always` 并告警 +- 活动关卡:若能在热更新资源 `StageActivityV2.json` 中查到对应活动,则写入 `DateTime`(使用该活动的开始时间、截止时间与时区);查不到则回退为 `Always` 并告警 例如 GUI 中同时启用过期理智药与多关卡备选时,迁移结果类似: @@ -64,7 +64,7 @@ type = "Fight" medicine_expire_days = 2 [[tasks.variants]] -condition = { type = "DateTime", end = "2026-08-22T03:59:59", timezone = 8 } +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } params = { stage = "TO-5" } [[tasks.variants]] @@ -77,7 +77,7 @@ params = { stage = "LS-6" } ``` 这里的 `medicine_expire_days` 对应 GUI 的 `MedicineExpireDays`(在 `UseExpiringMedicine = true` 时写入),表示使用多少天内将过期的理智药。 -活动关卡的 `end` 来自迁移时本地的 `StageActivityV2.json`,之后不会随热更新自动刷新;活动换档后需重新迁移或手动改条件。 +活动关卡的 `start` 和 `end` 来自迁移时本地的 `StageActivityV2.json`,之后不会随热更新自动刷新;活动换档后需重新迁移或手动改条件。 若同时启用 `UseWeeklySchedule`,周计划会与关卡开放条件用 `And` 组合。 **注意**:启用源石(`UseStone`)时会发出警告,因为这可能消耗源石。 diff --git a/crates/maa-cli/src/config/migrate/wpf/fight.rs b/crates/maa-cli/src/config/migrate/wpf/fight.rs index b7d99f40..c865a90c 100644 --- a/crates/maa-cli/src/config/migrate/wpf/fight.rs +++ b/crates/maa-cli/src/config/migrate/wpf/fight.rs @@ -192,8 +192,8 @@ impl WpfFightTask { /// /// - 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 + /// - Side-story stages listed in `StageActivityV2.json` → `DateTime` with that activity's open + /// window (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 @@ -243,11 +243,7 @@ impl WpfFightTask { | "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), - }, + Some(schedule) => Self::activity_schedule_condition(schedule), None => { warn!( "FightTask stage `{stage}` has no known open schedule; treating as Always" @@ -258,7 +254,15 @@ impl WpfFightTask { }) } - /// Resolve expire window for an activity stage from `StageActivityV2.json`. + fn activity_schedule_condition(schedule: crate::activity::SideStorySchedule) -> Condition { + Condition::DateTime { + start: Some(schedule.start), + end: Some(schedule.end), + timezone: TimeOffset::TimeZone(schedule.timezone), + } + } + + /// Resolve the open 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. @@ -489,7 +493,10 @@ impl TryFrom<&WpfFightTask> for Value { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use super::super::{WpfConfig, migrate}; + use super::{ + super::{WpfConfig, migrate}, + *, + }; fn fight_profile(extra: serde_json::Value) -> WpfConfig { let mut task = serde_json::json!({ @@ -526,6 +533,24 @@ mod tests { .unwrap() } + #[test] + fn activity_schedule_condition_keeps_start_and_end() { + let schedule = crate::activity::SideStorySchedule { + start: chrono::NaiveDateTime::MIN, + end: chrono::NaiveDateTime::MAX, + timezone: 8, + }; + + assert_eq!( + WpfFightTask::activity_schedule_condition(schedule), + Condition::DateTime { + start: Some(schedule.start), + end: Some(schedule.end), + timezone: TimeOffset::TimeZone(schedule.timezone), + } + ); + } + #[test] fn custom_annihilation_uses_annihilation_stage() { let config = fight_profile(serde_json::json!({ From f34ec69bd3ec6dd23c6e5798e04ea6bb9d7684cc Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sat, 3 Oct 2026 18:18:46 +0800 Subject: [PATCH 23/29] fix: document WPF migration output path --- crates/maa-cli/src/command.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index a4e85515..6952942f 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -267,7 +267,8 @@ pub(crate) enum MigrateCommand { Wpf { /// Path of the input GUI profile input: PathBuf, - /// Path of the output file, if not specified, the output will be printed to stdout + /// Path of the output file; defaults to the input path with its extension replaced by + /// .toml output: Option, /// Select a named configuration when migrating a multi-profile GUI export #[arg(long = "profile-name")] @@ -666,6 +667,20 @@ mod test { ); } + #[test] + fn migrate_wpf_help_describes_default_output_path() { + let help = match Cli::try_parse_from(["maa", "migrate", "wpf", "--help"]) { + Err(error) => { + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + error.to_string() + } + Ok(_) => panic!("--help should stop argument parsing"), + }; + + assert!(help.contains("defaults to the input path"), "{help}"); + assert!(!help.contains("printed to stdout"), "{help}"); + } + #[test] fn activity() { assert_matches!(parse_from(["maa", "activity"]).command, Command::Activity { From 6ded5f0e47c8eb60af633919a212d511d1b4ed23 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Sat, 10 Oct 2026 16:30:42 +0800 Subject: [PATCH 24/29] refactor: update WPF GUI configuration for task parameters - Changed `UseCustomAnnihilation` to false and updated `AnnihilationStage` to "Chernobog@Annihilation". - Modified `StagePlan` to replace "TO-5" with "YW-8". - Set `PreferTagEnabled` to false. - Updated file paths for configuration files. - Added new GUI settings for `ConnectSettings`, `Performance`, `ExternalNotification`, and `RemoteControl`. - Enhanced `RoguelikeTask` to support new `StartingOpers` structure, replacing `CoreChar` and `UseSupport` with a more flexible mapping. - Updated documentation to reflect changes in migration output and task parameter handling. --- .../maa-cli/config_examples/wpf/gui.new.json | 567 +++++++++++++++--- .../maa-cli/config_examples/wpf/gui.new.toml | 42 +- crates/maa-cli/docs/zh-CN/wpf.md | 1 + .../src/config/migrate/wpf/roguelike.rs | 128 +++- 4 files changed, 607 insertions(+), 131 deletions(-) diff --git a/crates/maa-cli/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json index bb9f7d6b..ce3009e3 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.json +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -30,8 +30,8 @@ "UseExpiringMedicine": false, "MedicineExpireDays": 2, "UseExpireMedicineForActivity": false, - "UseCustomAnnihilation": true, - "AnnihilationStage": "LungmenDowntown@Annihilation", + "UseCustomAnnihilation": false, + "AnnihilationStage": "Chernobog@Annihilation", "HideUnavailableStage": false, "IsStageManually": false, "UseOptionalStage": false, @@ -57,7 +57,7 @@ "TimesLimit": 2147483647, "Series": 0, "StagePlan": [ - "TO-5", + "YW-8", "CE-6", "LS-6" ], @@ -83,7 +83,7 @@ "MaxTimes": 4, "ExtraTagMode": 0, "Level3PreferTags": [], - "PreferTagEnabled": true, + "PreferTagEnabled": false, "PreserveTagList": [ "支援机械" ], @@ -91,6 +91,8 @@ "RefreshLevel3": true, "ForceRefresh": true, "Level3Choose": true, + "Level3RecruitmentPermitReserveEnabled": false, + "Level3RecruitmentPermitReserve": 8, "Level4Choose": true, "Level5Choose": true, "Level6Choose": false, @@ -112,8 +114,16 @@ "ReceptionClueExchange": true, "SendClue": true, "ContinueTraining": true, + "FiammettaTarget1": "清流", + "FiammettaTarget2": "可露希尔", + "FiammettaTarget3": "但书", + "FiammettaRecoveryEnabled": false, + "UsePinusSylvestris": false, + "UsePerceptionInformation": false, + "UseWorldlyPlight": false, + "UseAbyssalHunter": false, "CustomFileType": "user_defined", - "Filename": "~/一图流-153-一天两换-MAA.json", + "Filename": "/Users/nan/Library/Application Support/com.loong.maa/config/tasks/一图流-153-一天两换-MAA.json", "PlanSelect": -1, "RoomList": [ { @@ -177,20 +187,305 @@ "Orundum": true, "Mining": true, "SpecialAccess": true, + "SignInEvent": false, "Name": "", "IsEnable": true, "TaskType": "Award" }, + { + "$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": { + "IsEnabled": false, + "EmulatorPath": "", + "ManualSetIndex": false, + "InstanceIndex": 0 + }, + "MuMuEmulator12": { + "IsEnabled": true, + "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", + "EnableBridgeConnection": false, + "InstanceIndex": 0 + }, + "Win32Extra": { + "ScreencapMethod": "FramePool", + "MouseMethod": "PostMessageWithCursorPos", + "KeyboardMethod": "SendMessage", + "MuteWhileRunning": false + }, + "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, + "RoguelikeDelayAbortUntilCombatComplete": false, + "PreRunScript": "", + "PostRunScript": "", + "ExecuteScriptOnCopilot": false, + "ExecuteScriptOnManualStop": false, + "BlockSleep": false, + "BlockSleepWithScreenOn": true, + "EnableStallTimeout": true, + "StallTimeoutReminderIntervalMinutes": 30, + "StallTimeoutMinutes": 25, + "EnableRunDurationLimit": false, + "RunDurationLimitMinutes": 240, + "RunDurationLimitExecutePostActions": true + }, + "ThirdParty": { + "ReportToPenguin": true, + "PenguinId": "482853894", + "ReportToYituliu": true, + "YituliuOpenApiToken": "", + "OperBoxUseYituliuApi": false + }, + "StartUpSettings": { + "RunDirectly": false, + "SkipStartupAutoRunAfterUpdate": true, + "StartEmulator": false, + "RestartEmulatorWhenAdbFailed": false, + "EmulatorPath": "", + "EmulatorAddCommand": "", + "EmulatorWaitSeconds": 60 + }, + "WindowTitlePrefix": "", + "PostActions": "None", + "AchievementPopupDisabled": false, + "AchievementPopupAutoClose": false + }, + "Toolbox": { + "OperBoxExportFormat": "Json", + "GachaShowDisclaimerNoMore": false, + "PeepTargetFps": 20, + "ChooseLevel3": true, + "ChooseLevel3Time": 540, + "ChooseLevel4": true, + "ChooseLevel4Time": 540, + "ChooseLevel5": true, + "ChooseLevel6": true, + "AutoSetTime": true, + "ShowPotential": true, + "OperBoxAvatarMode": true, + "EventShopBlackListDataSupplementInstrument": false, + "EventShopBlackListDataSupplementStick": false, + "EventShopBlackListLmd": false, + "EventShopBlackListFurniturePart": false, + "EventShopBlackListOther": true, + "EventShopBlackList": "" + }, + "Copilot": { + "TaskList": [ + { + "Name": "TO-EX-1", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-1.json", + "CopilotId": 99410, + "IsRaid": false, + "IsChecked": false, + "Index": 0 + }, + { + "Name": "TO-EX-1", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-1.json", + "CopilotId": 99410, + "IsRaid": true, + "IsChecked": false, + "Index": 1 + }, + { + "Name": "TO-EX-2", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-2.json", + "CopilotId": 99425, + "IsRaid": false, + "IsChecked": true, + "Index": 2 + }, + { + "Name": "TO-EX-2", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-2.json", + "CopilotId": 99425, + "IsRaid": true, + "IsChecked": true, + "Index": 3 + }, + { + "Name": "TO-EX-3", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-3.json", + "CopilotId": 99448, + "IsRaid": false, + "IsChecked": true, + "Index": 4 + }, + { + "Name": "TO-EX-3", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-3.json", + "CopilotId": 99448, + "IsRaid": true, + "IsChecked": true, + "Index": 5 + }, + { + "Name": "TO-EX-4", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-4.json", + "CopilotId": 99485, + "IsRaid": false, + "IsChecked": true, + "Index": 6 + }, + { + "Name": "TO-EX-4", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-4.json", + "CopilotId": 99485, + "IsRaid": true, + "IsChecked": true, + "Index": 7 + }, + { + "Name": "TO-EX-5", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-5.json", + "CopilotId": 99487, + "IsRaid": false, + "IsChecked": true, + "Index": 8 + }, + { + "Name": "TO-EX-5", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-5.json", + "CopilotId": 99487, + "IsRaid": true, + "IsChecked": true, + "Index": 9 + }, + { + "Name": "TO-EX-6", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-6.json", + "CopilotId": 99491, + "IsRaid": false, + "IsChecked": true, + "Index": 10 + }, + { + "Name": "TO-EX-6", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-6.json", + "CopilotId": 99491, + "IsRaid": true, + "IsChecked": true, + "Index": 11 + }, + { + "Name": "TO-EX-7", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-7.json", + "CopilotId": 99505, + "IsRaid": false, + "IsChecked": true, + "Index": 12 + }, + { + "Name": "TO-EX-7", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-7.json", + "CopilotId": 99505, + "IsRaid": true, + "IsChecked": true, + "Index": 13 + } + ], + "EnableUserAdditional": false, + "UserAdditional": [], + "SupportMode": "WhenNeeded", + "SelectFormation": 1, + "LoopTimes": 1 + }, + "TaskSelectedIndex": 2 + }, + "肉鸽": { + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": false, + "TaskType": "StartUp" + }, { "$type": "RoguelikeTask", - "Theme": "JieGarden", + "Theme": "BlackFlow", "Difficulty": 2147483647, "Mode": "Investment", - "Squad": "指挥分队", + "Squad": "特勤分队", "SquadCollectible": "指挥分队", "Roles": "稳扎稳打", - "StartCount": 999999, - "CoreChar": "维什戴尔", + "StartCount": 99999, + "StartingOpers": [ + { + "Name": "机械师", + "UseSupport": false + } + ], + "UseAdditionalStartingOpers": false, "Investment": true, "InvestCount": 999, "InvestWithMoreScore": false, @@ -203,7 +498,7 @@ "MonthlySquadCheckComms": true, "DeepExplorationAutoIterate": true, "FindPlaytimeTarget": "Ling", - "UseSupport": false, + "BlackFlowCultivationTarget": "Cat", "UseSupportNonFriend": false, "RefreshTraderWithDice": false, "SquadIsFoldartal": false, @@ -216,21 +511,54 @@ "StopWhenLevelMax": false, "StartWithSeed": false, "Seed": "", - "Name": "", + "Name": "黑流树海-刷源石锭", "IsEnable": false, "TaskType": "Roguelike" }, { - "$type": "ReclamationTask", - "Theme": "Tales", - "Mode": "ProsperityInSave", - "ToolToCraft": "", - "IncrementMode": 0, - "MaxCraftCountPerRound": 16, - "ClearStore": true, - "Name": "", - "IsEnable": false, - "TaskType": "Reclamation" + "$type": "RoguelikeTask", + "Theme": "BlackFlow", + "Difficulty": 2147483647, + "Mode": "BlackFlowBabyAnimal", + "Squad": "堡垒战术分队", + "SquadCollectible": "指挥分队", + "Roles": "坚不可摧", + "StartCount": 99999, + "StartingOpers": [ + { + "Name": "机械师", + "UseSupport": false + } + ], + "UseAdditionalStartingOpers": false, + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "BlackFlowCultivationTarget": "Cerberus", + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "黑流树海-刷襁褓", + "IsEnable": true, + "TaskType": "Roguelike" }, { "$type": "UserDataUpdateTask", @@ -256,22 +584,22 @@ ], "Extras": { "LDPlayer": { + "IsEnabled": false, "EmulatorPath": "", "ManualSetIndex": false, - "InstanceIndex": 0, - "IsEnabled": false + "InstanceIndex": 0 }, "MuMuEmulator12": { + "IsEnabled": true, "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", "EnableBridgeConnection": false, - "EnableTouch": true, - "InstanceIndex": 0, - "IsEnabled": true + "InstanceIndex": 0 }, "Win32Extra": { "ScreencapMethod": "FramePool", - "MouseMethod": "SendMessageWithCursorPos", - "KeyboardMethod": "SendMessage" + "MouseMethod": "PostMessageWithCursorPos", + "KeyboardMethod": "SendMessage", + "MuteWhileRunning": false }, "BluestacksExtra": { "ConfigKeyword": "", @@ -308,7 +636,6 @@ "ClientType": "Official", "StartGame": true, "DeployWithPause": false, - "AutoRestartOnDrop": true, "RoguelikeDelayAbortUntilCombatComplete": false, "PreRunScript": "", "PostRunScript": "", @@ -316,12 +643,19 @@ "ExecuteScriptOnManualStop": false, "BlockSleep": false, "BlockSleepWithScreenOn": true, + "EnableStallTimeout": true, + "StallTimeoutReminderIntervalMinutes": 30, + "StallTimeoutMinutes": 25, + "EnableRunDurationLimit": false, + "RunDurationLimitMinutes": 240, + "RunDurationLimitExecutePostActions": true + }, + "ThirdParty": { "ReportToPenguin": true, "PenguinId": "482853894", "ReportToYituliu": true, - "EnableStallTimeout": true, - "StallTimeoutReminderIntervalMinutes": 30, - "StallTimeoutMinutes": 25 + "YituliuOpenApiToken": "", + "OperBoxUseYituliuApi": false }, "StartUpSettings": { "RunDirectly": false, @@ -333,12 +667,12 @@ "EmulatorWaitSeconds": 60 }, "WindowTitlePrefix": "", - "PostActions": "ExitArknights, ExitSelf", + "PostActions": "None", "AchievementPopupDisabled": false, "AchievementPopupAutoClose": false }, "Toolbox": { - "OperBoxExportFormat": "Clipboard", + "OperBoxExportFormat": "Json", "GachaShowDisclaimerNoMore": false, "PeepTargetFps": 20, "ChooseLevel3": true, @@ -348,89 +682,142 @@ "ChooseLevel5": true, "ChooseLevel6": true, "AutoSetTime": true, - "ShowPotential": true + "ShowPotential": true, + "OperBoxAvatarMode": false, + "EventShopBlackListDataSupplementInstrument": false, + "EventShopBlackListDataSupplementStick": false, + "EventShopBlackListLmd": false, + "EventShopBlackListFurniturePart": false, + "EventShopBlackListOther": true, + "EventShopBlackList": "" }, "Copilot": { "TaskList": [ { - "Name": "TO-1", - "FilePath": "config\\copilot\\TO-1.json", - "CopilotId": 98585, + "Name": "TO-EX-1", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-1.json", + "CopilotId": 99410, "IsRaid": false, "IsChecked": false, "Index": 0 }, { - "Name": "TO-TR-1", - "FilePath": "config\\copilot\\TO-TR-1.json", - "CopilotId": 98593, - "IsRaid": false, + "Name": "TO-EX-1", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-1.json", + "CopilotId": 99410, + "IsRaid": true, "IsChecked": false, "Index": 1 }, { - "Name": "TO-2", - "FilePath": "config\\copilot\\TO-2.json", - "CopilotId": 98591, + "Name": "TO-EX-2", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-2.json", + "CopilotId": 99425, "IsRaid": false, - "IsChecked": false, + "IsChecked": true, "Index": 2 }, { - "Name": "TO-3", - "FilePath": "config\\copilot\\TO-3.json", - "CopilotId": 98592, - "IsRaid": false, - "IsChecked": false, + "Name": "TO-EX-2", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-2.json", + "CopilotId": 99425, + "IsRaid": true, + "IsChecked": true, "Index": 3 }, { - "Name": "TO-4", - "FilePath": "config\\copilot\\TO-4.json", - "CopilotId": 98597, + "Name": "TO-EX-3", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-3.json", + "CopilotId": 99448, "IsRaid": false, - "IsChecked": false, + "IsChecked": true, "Index": 4 }, { - "Name": "TO-5", - "FilePath": "config\\copilot\\TO-5.json", - "CopilotId": 98606, - "IsRaid": false, - "IsChecked": false, + "Name": "TO-EX-3", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-3.json", + "CopilotId": 99448, + "IsRaid": true, + "IsChecked": true, "Index": 5 }, { - "Name": "TO-6", - "FilePath": "config\\copilot\\TO-6.json", - "CopilotId": 98608, + "Name": "TO-EX-4", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-4.json", + "CopilotId": 99485, "IsRaid": false, - "IsChecked": false, + "IsChecked": true, "Index": 6 }, { - "Name": "TO-7", - "FilePath": "config\\copilot\\TO-7.json", - "CopilotId": 98632, - "IsRaid": false, - "IsChecked": false, + "Name": "TO-EX-4", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-4.json", + "CopilotId": 99485, + "IsRaid": true, + "IsChecked": true, "Index": 7 }, { - "Name": "TO-8", - "FilePath": "config\\copilot\\TO-8.json", - "CopilotId": 98634, + "Name": "TO-EX-5", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-5.json", + "CopilotId": 99487, "IsRaid": false, - "IsChecked": false, + "IsChecked": true, "Index": 8 }, { - "Name": "TO-9", - "FilePath": "config\\copilot\\TO-9.json", - "CopilotId": 98636, - "IsRaid": false, - "IsChecked": false, + "Name": "TO-EX-5", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-5.json", + "CopilotId": 99487, + "IsRaid": true, + "IsChecked": true, "Index": 9 + }, + { + "Name": "TO-EX-6", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-6.json", + "CopilotId": 99491, + "IsRaid": false, + "IsChecked": true, + "Index": 10 + }, + { + "Name": "TO-EX-6", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-6.json", + "CopilotId": 99491, + "IsRaid": true, + "IsChecked": true, + "Index": 11 + }, + { + "Name": "TO-EX-7", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-7.json", + "CopilotId": 99505, + "IsRaid": false, + "IsChecked": true, + "Index": 12 + }, + { + "Name": "TO-EX-7", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-7.json", + "CopilotId": 99505, + "IsRaid": true, + "IsChecked": true, + "Index": 13 } ], "EnableUserAdditional": false, @@ -508,14 +895,14 @@ "ConfigVersion": 1, "Current": "Default", "Update": { - "Name": "v6.16.5", + "Name": "v6.19.0-beta.1", "UpdatePackage": "", "IsFirstBoot": false, "DoNotShowUpdate": false, "VersionType": "Beta", "AllowNightlyUpdates": false, "HasAcknowledgedNightlyWarning": false, - "UpdateSource": "Github", + "UpdateSource": "GitHub", "ForceGithubGlobalSource": false, "MirrorChyanCdk": "", "MirrorChyanCdkExpiredTime": 0, @@ -529,13 +916,14 @@ "ShowUpdaterProgress": true }, "AnnouncementInfo": { - "DoNotShowAgain": true, + "DoNotShowAgain": false, "DoNotShow": false }, "Gui": { "DarkMode": "SyncWithOs", "UseNotify": true, "MainTasksInvertNullFunction": false, + "HideHoverActionButtons": false, "Localization": "zh-cn", "OperNameLanguage": "OperNameLanguageMAA", "UseTray": true, @@ -558,15 +946,16 @@ "Y": -1 }, "NormalPosition": { - "Left": 442, - "Top": 229, - "Right": 1882, - "Bottom": 1329 + "Left": 49, + "Top": 192, + "Right": 1489, + "Bottom": 1292 } }, "LoadWindowPlacement": true, "SaveWindowPlacement": true, "InverseClearMode": "Clear", + "TransitionSpeed": "Normal", "TaskQueueInverseMode": false, "UseCardLog": true, "MaxNumberOfLogThumbnails": 100, @@ -594,7 +983,8 @@ "UpdateSettings", "AchievementSettings", "IssueReport", - "AboutUs" + "AboutUs", + "ThirdPartyServiceSettings" ], "CollapesStates": [], "HotKeys": { @@ -617,6 +1007,7 @@ "BackgroundMonetEnabled": false, "BackgroundMonetMode": "Auto", "BackgroundMonetCustomColor": "#326CF3", - "BackgroundMonetCachedColor": "" + "BackgroundMonetCachedColor": "", + "BackgroundMonetKeepMaskNeutral": false } -} \ 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 index 21fdf35c..6f194794 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 = "LungmenDowntown@Annihilation" +stage = "Annihilation" [[tasks]] name = "理智作战" @@ -24,13 +24,14 @@ series = 0 [[tasks.variants]] [tasks.variants.condition] -end = "2026-08-22T03:59:59" +end = "2026-10-23T03:59:59" +start = "2026-10-09T12:00:00" timezone = 8 type = "DateTime" [tasks.variants.params] series = 0 -stage = "TO-5" +stage = "YW-8" [[tasks.variants]] @@ -97,7 +98,7 @@ facility = [ "Processing", "Training", ] -filename = "/Users/nan/一图流-153-一天两换-MAA.json" +filename = "/Users/nan/Library/Application Support/com.loong.maa/config/tasks/一图流-153-一天两换-MAA.json" mode = 10000 reception_clue_exchange = true reception_message_board = true @@ -155,36 +156,3 @@ mining = true orundum = true recruit = false specialaccess = true - -[[tasks]] -type = "Roguelike" - -[tasks.params] -core_char = "维什戴尔" -enable = false -investment_enabled = true -investment_with_more_score = false -investments_count = 999 -mode = 1 -only_start_with_elite_two = false -refresh_trader_with_dice = false -roles = "稳扎稳打" -squad = "指挥分队" -start_with_elite_two = false -starts_count = 999999 -stop_at_final_boss = false -stop_at_max_level = false -stop_when_investment_full = true -theme = "JieGarden" -use_nonfriend_support = false -use_support = false - -[[tasks]] -type = "Reclamation" - -[tasks.params] -enable = false -increment_mode = 0 -mode = 1 -num_craft_batches = 16 -theme = "Tales" diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md index 40bfec99..2976e6e7 100644 --- a/crates/maa-cli/docs/zh-CN/wpf.md +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -113,6 +113,7 @@ params = { stage = "LS-6" } - `Mall`:信用商店、信用作战、访问好友、黑白名单等会映射到对应参数。 - `Award`:日常奖励、邮件、源石锭等;GUI 的 `FreeGacha` 对应 `recruit`。 - `Roguelike` / `Reclamation`:主题、模式与常见选项会迁移;无法识别的模式字段会记入跳过摘要。 +- 较新的 GUI 用 `StartingOpers` 代替 `CoreChar` / `UseSupport` 时,会取列表首项映射为 `core_char` 与 `use_support`;多余开局干员会记入跳过摘要。 - 未识别的 `$type` 会整项跳过,并出现在迁移摘要的「跳过任务」列表中。 各任务类型的完整参数含义请参考 [MAA 集成文档][task-types]。迁移后若要继续用条件变体精细控制执行逻辑,请参阅 [任务条件](config.md#任务条件)。 diff --git a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs index 3f125d1e..c3819657 100644 --- a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs +++ b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs @@ -11,6 +11,7 @@ const HANDLED: &[&str] = &[ "Squad", "Roles", "CoreChar", + "StartingOpers", "StartCount", "Difficulty", "Investment", @@ -77,6 +78,16 @@ struct CliRoguelikeParams { enable: Option, } +/// Newer GUI exports list starting operators here instead of flat `CoreChar` / +/// `UseSupport`. Only the first entry maps to maa-cli / MaaCore params. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WpfStartingOper { + name: String, + #[serde(default)] + use_support: bool, +} + /// WPF GUI `RoguelikeTask` (`$type = "RoguelikeTask"`). #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] @@ -87,7 +98,11 @@ pub(super) struct WpfRoguelikeTask { mode: String, squad: String, roles: String, - core_char: String, + /// Older exports; newer ones omit this in favor of [`Self::starting_opers`]. + #[serde(default)] + core_char: Option, + #[serde(default)] + starting_opers: Vec, start_count: i32, difficulty: i32, investment: bool, @@ -96,7 +111,9 @@ pub(super) struct WpfRoguelikeTask { stop_when_deposit_full: bool, stop_at_final_boss: bool, stop_when_level_max: bool, - use_support: bool, + /// Older exports; newer ones put support on each [`WpfStartingOper`]. + #[serde(default)] + use_support: Option, use_support_non_friend: bool, refresh_trader_with_dice: bool, start_with_elite_two: bool, @@ -118,6 +135,21 @@ impl WpfRoguelikeTask { } } + /// Prefer `StartingOpers[0]`, then legacy `CoreChar` / `UseSupport`. + fn starting_oper(&self) -> (Option, bool) { + if let Some(oper) = self.starting_opers.first() { + let name = (!oper.name.is_empty()).then(|| oper.name.clone()); + return (name, oper.use_support); + } + ( + self.core_char + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_string), + self.use_support.unwrap_or(false), + ) + } + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { if !self.is_enable { summary.disable_task("RoguelikeTask", Some(self.name.clone())); @@ -125,6 +157,9 @@ impl WpfRoguelikeTask { if self.mapped_mode().is_none() { summary.skip_field("RoguelikeTask", Some(self.name.clone()), "Mode"); } + if self.starting_opers.len() > 1 { + summary.skip_field("RoguelikeTask", Some(self.name.clone()), "StartingOpers"); + } report_unknown_fields( summary, "RoguelikeTask", @@ -147,6 +182,7 @@ impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { type Error = anyhow::Error; fn try_from(task: &WpfRoguelikeTask) -> Result { + let (core_char, use_support) = task.starting_oper(); Ok(CliRoguelikeTask { task_type: TaskType::Roguelike, name: task.name.clone(), @@ -155,7 +191,7 @@ impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { mode: task.mapped_mode(), squad: Some(task.squad.clone()), roles: Some(task.roles.clone()), - core_char: Some(task.core_char.clone()), + core_char, starts_count: Some(task.start_count), difficulty: (task.difficulty != i32::MAX).then_some(task.difficulty), investment_enabled: Some(task.investment), @@ -164,7 +200,7 @@ impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { 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_support: Some(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), @@ -191,7 +227,7 @@ mod tests { "Mode": mode, "Squad": "指挥分队", "Roles": "稳扎稳打", - "CoreChar": "", + "CoreChar": "维什戴尔", "StartCount": 999, "Difficulty": difficulty, "Investment": true, @@ -233,7 +269,7 @@ mod tests { "mode": 1, "squad": "指挥分队", "roles": "稳扎稳打", - "core_char": "", + "core_char": "维什戴尔", "starts_count": 999, "investment_enabled": true, "investments_count": 999, @@ -253,6 +289,86 @@ mod tests { ); } + #[test] + fn maps_starting_opers_when_core_char_absent() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "RoguelikeTask", + "Name": "黑流树海-刷源石锭", + "IsEnable": true, + "Theme": "BlackFlow", + "Mode": "Investment", + "Squad": "特勤分队", + "Roles": "稳扎稳打", + "StartCount": 99999, + "StartingOpers": [ + { "Name": "机械师", "UseSupport": true }, + { "Name": "额外干员", "UseSupport": false }, + ], + "Difficulty": i32::MAX, + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert_eq!( + summary + .skipped_fields + .iter() + .map(|f| f.field.as_str()) + .collect::>(), + ["StartingOpers"] + ); + assert_eq!( + config.tasks[0].pointer("/params/core_char"), + Some(&json!("机械师")) + ); + assert_eq!( + config.tasks[0].pointer("/params/use_support"), + Some(&json!(true)) + ); + assert_eq!( + config.tasks[0].pointer("/params/theme"), + Some(&json!("BlackFlow")) + ); + } + + #[test] + fn accepts_roguelike_without_core_char_or_starting_opers() { + let mut task = roguelike_task("Exp", 1, true); + task.as_object_mut().unwrap().remove("CoreChar"); + task.as_object_mut().unwrap().remove("UseSupport"); + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [task], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + assert!(summary.is_empty()); + assert!(config.tasks[0].pointer("/params/core_char").is_none()); + assert_eq!( + config.tasks[0].pointer("/params/use_support"), + Some(&json!(false)) + ); + } + #[test] fn maps_known_roguelike_modes() { for (mode, expected) in [ From 4394b835611518c473f009f118161dcfca49b780 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 23 Oct 2026 00:22:29 +0800 Subject: [PATCH 25/29] =?UTF-8?q?=E2=9C=A8=20feat:=20resolve=20relative=20?= =?UTF-8?q?WPF=20infrast=20schedules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relative Filename beside the GUI profile stayed unresolved, so custom shifts fell back to the default mode. Resolve that path from the input file's directory and keep the Fiammetta targets the integration protocol defines. Replace the sample path, Penguin id, and GPU name with a shifts-only schedule next to the profile. Box the roguelike task so its larger payload stays within the enum size limit. 相对排班按输入文件目录解析,并保留菲亚梅塔目标。 Co-authored-by: Cursor --- .../maa-cli/config_examples/wpf/gui.new.json | 273 +++++++----------- .../maa-cli/config_examples/wpf/gui.new.toml | 2 +- ...\244\251\344\270\244\346\215\242-MAA.json" | 38 +++ .../maa-cli/src/config/migrate/wpf/infrast.rs | 147 +++++++++- crates/maa-cli/src/config/migrate/wpf/mod.rs | 34 ++- 5 files changed, 306 insertions(+), 188 deletions(-) create mode 100644 "crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" diff --git a/crates/maa-cli/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json index ce3009e3..6cb017ab 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.json +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -122,8 +122,9 @@ "UsePerceptionInformation": false, "UseWorldlyPlight": false, "UseAbyssalHunter": false, + "UseWeeklySchedule": false, "CustomFileType": "user_defined", - "Filename": "/Users/nan/Library/Application Support/com.loong.maa/config/tasks/一图流-153-一天两换-MAA.json", + "Filename": "一图流-153-一天两换-MAA.json", "PlanSelect": -1, "RoomList": [ { @@ -208,8 +209,8 @@ "AutoDetect": false, "AlwaysAutoDetect": false, "Config": "MuMuEmulator12", - "AdbPath": "C:\\Program Files\\Netease\\MuMuPlayer\\nx_main\\adb.exe", - "AdbReplaced": false, + "AdbPath": "adb/platform-tools/adb.exe", + "AdbReplaced": true, "Address": "127.0.0.1:16384", "AddressHistory": [ "127.0.0.1:16384" @@ -240,22 +241,33 @@ }, "AllowAdbRestart": true, "AllowAdbHardRestart": true, - "TouchMode": "MumuExtras", + "TouchMode": "MaaTouch", "EnableAdbLite": false, "KillAdbOnExit": false }, - "Performance": { - "UseGpu": true, - "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", - "GpuInstancePath": "", - "AllowDeprecatedGpu": false + "Notification": { + "EnableStallTimeout": true, + "StallTimeoutMinutes": 25, + "ReminderIntervalMinutes": 30 }, "ExternalNotification": { "Configs": [], - "SendWhenComplete": true, - "ShowWhenCompleteWithDetails": false, - "SendWhenError": true, - "SendWhenStalled": false + "Delivery": { + "Enable": false, + "SendWhenComplete": true, + "SendWhenError": true, + "SendWhenStalled": false, + "SendBeforeScheduledStart": false, + "SendAfterLogCount": false, + "NewLogCount": 10, + "SendWhenContentMatches": false, + "Whitelist": "", + "IncludePreviousLogs": false, + "MaxEntries": 2, + "TimeMinutes": 60, + "FilterPreviousLogs": false, + "Blacklist": "" + } }, "RemoteControl": { "RemoteControlGetTaskEndpointUri": "", @@ -275,19 +287,17 @@ "ExecuteScriptOnManualStop": false, "BlockSleep": false, "BlockSleepWithScreenOn": true, - "EnableStallTimeout": true, - "StallTimeoutReminderIntervalMinutes": 30, - "StallTimeoutMinutes": 25, "EnableRunDurationLimit": false, "RunDurationLimitMinutes": 240, "RunDurationLimitExecutePostActions": true }, "ThirdParty": { "ReportToPenguin": true, - "PenguinId": "482853894", + "PenguinId": "", "ReportToYituliu": true, "YituliuOpenApiToken": "", - "OperBoxUseYituliuApi": false + "OperBoxUseYituliuApi": false, + "EnableOperBoxAssist": false }, "StartUpSettings": { "RunDirectly": false, @@ -296,7 +306,12 @@ "RestartEmulatorWhenAdbFailed": false, "EmulatorPath": "", "EmulatorAddCommand": "", - "EmulatorWaitSeconds": 60 + "EmulatorWaitSeconds": 60, + "StartPcClient": false, + "RestartPcClientWhenConnectFailed": false, + "PcClientPath": "", + "PcClientAddCommand": "", + "PcClientWaitSeconds": 60 }, "WindowTitlePrefix": "", "PostActions": "None", @@ -326,135 +341,19 @@ "Copilot": { "TaskList": [ { - "Name": "TO-EX-1", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-1.json", - "CopilotId": 99410, - "IsRaid": false, - "IsChecked": false, - "Index": 0 - }, - { - "Name": "TO-EX-1", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-1.json", - "CopilotId": 99410, - "IsRaid": true, - "IsChecked": false, - "Index": 1 - }, - { - "Name": "TO-EX-2", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-2.json", - "CopilotId": 99425, - "IsRaid": false, - "IsChecked": true, - "Index": 2 - }, - { - "Name": "TO-EX-2", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-2.json", - "CopilotId": 99425, - "IsRaid": true, - "IsChecked": true, - "Index": 3 - }, - { - "Name": "TO-EX-3", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-3.json", - "CopilotId": 99448, - "IsRaid": false, - "IsChecked": true, - "Index": 4 - }, - { - "Name": "TO-EX-3", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-3.json", - "CopilotId": 99448, - "IsRaid": true, - "IsChecked": true, - "Index": 5 - }, - { - "Name": "TO-EX-4", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-4.json", - "CopilotId": 99485, - "IsRaid": false, - "IsChecked": true, - "Index": 6 - }, - { - "Name": "TO-EX-4", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-4.json", - "CopilotId": 99485, - "IsRaid": true, - "IsChecked": true, - "Index": 7 - }, - { - "Name": "TO-EX-5", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-5.json", - "CopilotId": 99487, - "IsRaid": false, - "IsChecked": true, - "Index": 8 - }, - { - "Name": "TO-EX-5", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-5.json", - "CopilotId": 99487, - "IsRaid": true, - "IsChecked": true, - "Index": 9 - }, - { - "Name": "TO-EX-6", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-6.json", - "CopilotId": 99491, - "IsRaid": false, - "IsChecked": true, - "Index": 10 - }, - { - "Name": "TO-EX-6", + "Name": "YW-EX-8", "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-6.json", - "CopilotId": 99491, + "FilePath": "config\\copilot\\YW-EX-8_1792346947472.json", + "CopilotId": 106975, "IsRaid": true, "IsChecked": true, - "Index": 11 - }, - { - "Name": "TO-EX-7", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-7.json", - "CopilotId": 99505, - "IsRaid": false, - "IsChecked": true, - "Index": 12 - }, - { - "Name": "TO-EX-7", - "IsNavNameOverride": false, - "FilePath": "config\\copilot\\TO-EX-7.json", - "CopilotId": 99505, - "IsRaid": true, - "IsChecked": true, - "Index": 13 + "Index": 0 } ], "EnableUserAdditional": false, "UserAdditional": [], "SupportMode": "WhenNeeded", + "UseFormation": false, "SelectFormation": 1, "LoopTimes": 1 }, @@ -473,7 +372,7 @@ { "$type": "RoguelikeTask", "Theme": "BlackFlow", - "Difficulty": 2147483647, + "Difficulty": 5, "Mode": "Investment", "Squad": "特勤分队", "SquadCollectible": "指挥分队", @@ -499,6 +398,12 @@ "DeepExplorationAutoIterate": true, "FindPlaytimeTarget": "Ling", "BlackFlowCultivationTarget": "Cat", + "BlackFlowEnding": "Ending1", + "BlackFlowFastFlight": false, + "BlackFlowStartAnimal1": "None", + "BlackFlowStartAnimal2": "None", + "BlackFlowStartRewards": "None", + "BlackFlowStartRunFastFlight": false, "UseSupportNonFriend": false, "RefreshTraderWithDice": false, "SquadIsFoldartal": false, @@ -512,14 +417,14 @@ "StartWithSeed": false, "Seed": "", "Name": "黑流树海-刷源石锭", - "IsEnable": false, + "IsEnable": true, "TaskType": "Roguelike" }, { "$type": "RoguelikeTask", "Theme": "BlackFlow", "Difficulty": 2147483647, - "Mode": "BlackFlowBabyAnimal", + "Mode": "BlackFlowStartRun", "Squad": "堡垒战术分队", "SquadCollectible": "指挥分队", "Roles": "坚不可摧", @@ -544,6 +449,12 @@ "DeepExplorationAutoIterate": true, "FindPlaytimeTarget": "Ling", "BlackFlowCultivationTarget": "Cerberus", + "BlackFlowEnding": "Ending1", + "BlackFlowFastFlight": false, + "BlackFlowStartAnimal1": "Cerberus", + "BlackFlowStartAnimal2": "None", + "BlackFlowStartRewards": "None", + "BlackFlowStartRunFastFlight": false, "UseSupportNonFriend": false, "RefreshTraderWithDice": false, "SquadIsFoldartal": false, @@ -612,18 +523,29 @@ "EnableAdbLite": false, "KillAdbOnExit": false }, - "Performance": { - "UseGpu": true, - "GpuDescription": "NVIDIA GeForce RTX 3050 Laptop GPU", - "GpuInstancePath": "", - "AllowDeprecatedGpu": false + "Notification": { + "EnableStallTimeout": true, + "StallTimeoutMinutes": 25, + "ReminderIntervalMinutes": 30 }, "ExternalNotification": { "Configs": [], - "SendWhenComplete": true, - "ShowWhenCompleteWithDetails": false, - "SendWhenError": true, - "SendWhenStalled": false + "Delivery": { + "Enable": false, + "SendWhenComplete": true, + "SendWhenError": true, + "SendWhenStalled": false, + "SendBeforeScheduledStart": false, + "SendAfterLogCount": false, + "NewLogCount": 10, + "SendWhenContentMatches": false, + "Whitelist": "", + "IncludePreviousLogs": false, + "MaxEntries": 2, + "TimeMinutes": 60, + "FilterPreviousLogs": false, + "Blacklist": "" + } }, "RemoteControl": { "RemoteControlGetTaskEndpointUri": "", @@ -643,19 +565,17 @@ "ExecuteScriptOnManualStop": false, "BlockSleep": false, "BlockSleepWithScreenOn": true, - "EnableStallTimeout": true, - "StallTimeoutReminderIntervalMinutes": 30, - "StallTimeoutMinutes": 25, "EnableRunDurationLimit": false, "RunDurationLimitMinutes": 240, "RunDurationLimitExecutePostActions": true }, "ThirdParty": { "ReportToPenguin": true, - "PenguinId": "482853894", + "PenguinId": "", "ReportToYituliu": true, "YituliuOpenApiToken": "", - "OperBoxUseYituliuApi": false + "OperBoxUseYituliuApi": false, + "EnableOperBoxAssist": false }, "StartUpSettings": { "RunDirectly": false, @@ -664,7 +584,12 @@ "RestartEmulatorWhenAdbFailed": false, "EmulatorPath": "", "EmulatorAddCommand": "", - "EmulatorWaitSeconds": 60 + "EmulatorWaitSeconds": 60, + "StartPcClient": false, + "RestartPcClientWhenConnectFailed": false, + "PcClientPath": "", + "PcClientAddCommand": "", + "PcClientWaitSeconds": 60 }, "WindowTitlePrefix": "", "PostActions": "None", @@ -823,6 +748,7 @@ "EnableUserAdditional": false, "UserAdditional": [], "SupportMode": "Random", + "UseFormation": false, "SelectFormation": 1, "LoopTimes": 1 }, @@ -833,6 +759,9 @@ "ForceScheduledStart": false, "ShowWindowBeforeForceScheduledStart": false, "CustomConfig": false, + "NotifyBeforeScheduledStart": false, + "ScheduledStartNotificationMinutes": 5, + "ScheduledWakeUp": false, "List": [ { "Id": 0, @@ -893,9 +822,9 @@ ] }, "ConfigVersion": 1, - "Current": "Default", + "Current": "肉鸽", "Update": { - "Name": "v6.19.0-beta.1", + "Name": "v6.20.1", "UpdatePackage": "", "IsFirstBoot": false, "DoNotShowUpdate": false, @@ -916,7 +845,7 @@ "ShowUpdaterProgress": true }, "AnnouncementInfo": { - "DoNotShowAgain": false, + "DoNotShowAgain": true, "DoNotShow": false }, "Gui": { @@ -946,14 +875,15 @@ "Y": -1 }, "NormalPosition": { - "Left": 49, - "Top": 192, - "Right": 1489, - "Bottom": 1292 + "Left": -2916, + "Top": 397, + "Right": -1476, + "Bottom": 1497 } }, "LoadWindowPlacement": true, "SaveWindowPlacement": true, + "AddTaskMenuSeenVersion": "", "InverseClearMode": "Clear", "TransitionSpeed": "Normal", "TaskQueueInverseMode": false, @@ -963,7 +893,6 @@ "Hangover": false, "LastBuyWineTime": "2026-06-08T00:00:00+08:00", "CustomCulture": "", - "IgnoreBadModulesAndUseSoftwareRendering": false, "OverlayTarget": { "ProcessId": 0, "Hwnd": 0 @@ -977,8 +906,7 @@ "StartupSettings", "RemoteControlSettings", "UiSettings", - "BackgroundSettings", - "ExternalNotificationSettings", + "NotificationSettings", "HotKeySettings", "UpdateSettings", "AchievementSettings", @@ -1004,10 +932,17 @@ "Opacity": 50, "BlurEffectRadius": 5 }, + "Performance": { + "UseGpu": true, + "GpuDescription": "", + "GpuInstancePath": "", + "AllowDeprecatedGpu": false, + "IgnoreBadModulesAndUseSoftwareRendering": false + }, "BackgroundMonetEnabled": false, "BackgroundMonetMode": "Auto", "BackgroundMonetCustomColor": "#326CF3", "BackgroundMonetCachedColor": "", "BackgroundMonetKeepMaskNeutral": false } -} +} \ 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 index 6f194794..1a1d3b4e 100644 --- a/crates/maa-cli/config_examples/wpf/gui.new.toml +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -98,7 +98,7 @@ facility = [ "Processing", "Training", ] -filename = "/Users/nan/Library/Application Support/com.loong.maa/config/tasks/一图流-153-一天两换-MAA.json" +filename = "一图流-153-一天两换-MAA.json" mode = 10000 reception_clue_exchange = true reception_message_board = true diff --git "a/crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" "b/crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" new file mode 100644 index 00000000..99c6ea82 --- /dev/null +++ "b/crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" @@ -0,0 +1,38 @@ +{ + "title": "一图流排班表-153两换", + "description": "示例排班,只保留时段和菲亚梅塔,供 maa migrate wpf 读取。", + "plans": [ + { + "name": "B班", + "period": [ + [ + "09:00", + "21:00" + ] + ], + "Fiammetta": { + "enable": true, + "target": "但书", + "order": "pre" + } + }, + { + "name": "A班", + "period": [ + [ + "21:00", + "23:59" + ], + [ + "00:00", + "09:00" + ] + ], + "Fiammetta": { + "enable": true, + "target": "但书", + "order": "pre" + } + } + ] +} diff --git a/crates/maa-cli/src/config/migrate/wpf/infrast.rs b/crates/maa-cli/src/config/migrate/wpf/infrast.rs index 32e497fe..8197f997 100644 --- a/crates/maa-cli/src/config/migrate/wpf/infrast.rs +++ b/crates/maa-cli/src/config/migrate/wpf/infrast.rs @@ -29,6 +29,14 @@ const HANDLED: &[&str] = &[ "CustomFileType", "Filename", "PlanSelect", + "FiammettaTarget1", + "FiammettaTarget2", + "FiammettaTarget3", + "FiammettaRecoveryEnabled", + "UsePinusSylvestris", + "UsePerceptionInformation", + "UseWorldlyPlight", + "UseAbyssalHunter", ]; const MINUTES_PER_DAY: u32 = 24 * 60; @@ -80,6 +88,18 @@ struct CliInfrastParams { reception_send_clue: Option, #[serde(skip_serializing_if = "Option::is_none")] continue_training: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + fiammetta_targets: Vec, + #[serde(skip_serializing_if = "is_false")] + fiammetta_recovery_enabled: bool, + #[serde(skip_serializing_if = "is_false")] + use_pinus_sylvestris: bool, + #[serde(skip_serializing_if = "is_false")] + use_perception_information: bool, + #[serde(skip_serializing_if = "is_false")] + use_worldly_plight: bool, + #[serde(skip_serializing_if = "is_false")] + use_abyssal_hunter: bool, #[serde(skip_serializing_if = "Option::is_none")] filename: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -116,6 +136,22 @@ pub(super) struct WpfInfrastTask { reception_message_board: bool, reception_clue_exchange: bool, send_clue: bool, + #[serde(default)] + fiammetta_target1: String, + #[serde(default)] + fiammetta_target2: String, + #[serde(default)] + fiammetta_target3: String, + #[serde(default)] + fiammetta_recovery_enabled: bool, + #[serde(default)] + use_pinus_sylvestris: bool, + #[serde(default)] + use_perception_information: bool, + #[serde(default)] + use_worldly_plight: bool, + #[serde(default)] + use_abyssal_hunter: bool, #[serde(flatten)] unknown: Map, } @@ -124,6 +160,10 @@ fn default_plan_select() -> i32 { -1 } +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Deserialize)] struct CustomInfrastFile { plans: Vec, @@ -177,16 +217,17 @@ impl WpfInfrastTask { summary: &mut MigrationSummary, custom_schedule: Option<&Path>, schedule_applied: &mut bool, + schedule_base: Option<&Path>, ) -> Result { if let Some(path) = custom_schedule && self.wants_custom() { *schedule_applied = true; - return self.try_custom_task(&path.to_string_lossy()); + return self.try_custom_task(&path.to_string_lossy(), None); } if self.wants_custom() { - match self.try_custom_task(&self.filename) { + match self.try_custom_task(&self.filename, schedule_base) { Ok(task) => return Ok(task), Err(err) => { warn!( @@ -236,15 +277,37 @@ impl WpfInfrastTask { reception_clue_exchange: Some(self.reception_clue_exchange), reception_send_clue: Some(self.send_clue), continue_training: Some(self.continue_training), + fiammetta_targets: self.fiammetta_targets(), + fiammetta_recovery_enabled: self.fiammetta_recovery_enabled, + use_pinus_sylvestris: self.use_pinus_sylvestris, + use_perception_information: self.use_perception_information, + use_worldly_plight: self.use_worldly_plight, + use_abyssal_hunter: self.use_abyssal_hunter, filename, plan_index, enable: (!self.is_enable).then_some(false), } } - fn try_custom_task(&self, filename: &str) -> Result { + fn fiammetta_targets(&self) -> Vec { + [ + &self.fiammetta_target1, + &self.fiammetta_target2, + &self.fiammetta_target3, + ] + .into_iter() + .filter(|name| !name.is_empty()) + .cloned() + .collect() + } + + fn try_custom_task( + &self, + filename: &str, + schedule_base: Option<&Path>, + ) -> Result { ensure!(!filename.is_empty(), "custom mode has empty Filename"); - let filename = resolve_infrast_filename(filename); + let filename = resolve_infrast_filename(filename, schedule_base); let schedule = load_custom_schedule(&filename)?; let n_plans = i32::try_from(schedule.plans.len()).context("too many plans in custom infrast file")?; @@ -320,8 +383,15 @@ impl WpfInfrastTask { } } -fn resolve_infrast_filename(path: &str) -> PathBuf { - expand_tilde(Path::new(path)).into_owned() +fn resolve_infrast_filename(path: &str, schedule_base: Option<&Path>) -> PathBuf { + let expanded = expand_tilde(Path::new(path)); + if expanded.is_absolute() { + return expanded.into_owned(); + } + match schedule_base { + Some(dir) => dir.join(expanded), + None => expanded.into_owned(), + } } fn load_custom_schedule(path: &Path) -> Result { @@ -538,7 +608,7 @@ fn span_condition(span: MergedSpan, days: u32, remainder: u32) -> Condition { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - super::{WpfConfig, migrate}, + super::{WpfConfig, migrate, migrate_with_base}, *, }; use crate::config::migrate::MigrationSummary; @@ -571,7 +641,7 @@ mod tests { } 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() + serde_json::to_value(task.to_cli_task(summary, None, &mut false, None).unwrap()).unwrap() } fn infrast_profile(mode: &str, filename: &str) -> WpfConfig { @@ -622,9 +692,66 @@ mod tests { assert!(summary.skipped_fields.iter().any(|f| f.field == "Mode")); } + #[test] + fn relative_filename_resolves_beside_the_profile() { + let dir = tempfile::tempdir().unwrap(); + let schedule_path = dir.path().join("plan.json"); + std::fs::write( + &schedule_path, + r#"{"plans":[{"period":[["09:00","21:00"]]}]}"#, + ) + .unwrap(); + let (value, summary) = migrate_with_base( + infrast_profile("Custom", "plan.json"), + None, + Some(dir.path()), + ) + .unwrap(); + assert!(summary.is_empty()); + let json = serde_json::to_value(value).unwrap(); + assert_eq!( + json["tasks"][0]["params"]["filename"], + schedule_path.to_str().unwrap() + ); + } + + #[test] + fn keeps_fiammetta_targets_for_default_mode() { + let task = serde_json::from_value(serde_json::json!({ + "Name": "", + "IsEnable": true, + "Mode": "Normal", + "Filename": "", + "PlanSelect": -1, + "ContinueTraining": false, + "RoomList": [{"Room": "Dorm"}], + "UsesOfDrones": "_NotUse", + "DormThreshold": 30, + "OriginiumShardAutoReplenishment": false, + "DormFilterNotStationed": false, + "DormTrustEnabled": false, + "ReceptionMessageBoard": false, + "ReceptionClueExchange": false, + "SendClue": false, + "FiammettaTarget1": "清流", + "FiammettaTarget2": "可露希尔", + "FiammettaTarget3": "但书", + "FiammettaRecoveryEnabled": true, + })) + .unwrap(); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert!(summary.is_empty()); + assert_eq!( + json["params"]["fiammetta_targets"], + serde_json::json!(["清流", "可露希尔", "但书"]) + ); + assert_eq!(json["params"]["fiammetta_recovery_enabled"], true); + } + #[test] fn tilde_in_filename_expands_to_home() { - let expanded = resolve_infrast_filename("~/一图流-153-一天两换-MAA.json"); + let expanded = resolve_infrast_filename("~/一图流-153-一天两换-MAA.json", None); assert_eq!( expanded, expand_tilde(Path::new("~/一图流-153-一天两换-MAA.json")).into_owned() @@ -721,7 +848,7 @@ mod tests { assert_eq!(task["params"]["mode"], CUSTOM_MODE); assert_eq!( task["params"]["filename"], - resolve_infrast_filename(file.path().to_str().unwrap()) + resolve_infrast_filename(file.path().to_str().unwrap(), None) .to_string_lossy() .as_ref() ); diff --git a/crates/maa-cli/src/config/migrate/wpf/mod.rs b/crates/maa-cli/src/config/migrate/wpf/mod.rs index 2d93ef2b..ec685313 100644 --- a/crates/maa-cli/src/config/migrate/wpf/mod.rs +++ b/crates/maa-cli/src/config/migrate/wpf/mod.rs @@ -41,13 +41,18 @@ impl WpfConfig { &self, summary: &mut MigrationSummary, custom_schedule: Option<&Path>, + schedule_base: 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)? - { + if let Some(item) = task.migrate_task( + &self.gui, + summary, + custom_schedule, + &mut schedule_applied, + schedule_base, + )? { tasks.push(item); } } @@ -68,7 +73,7 @@ enum WpfTask { RecruitTask(recruit::WpfRecruitTask), MallTask(mall::WpfMallTask), AwardTask(award::WpfAwardTask), - RoguelikeTask(roguelike::WpfRoguelikeTask), + RoguelikeTask(Box), ReclamationTask(reclamation::WpfReclamationTask), /// Unknown `$type` values are skipped during migration. Unsupported { @@ -101,7 +106,7 @@ pub(crate) fn wpf( profile.config_version ); let configuration = select_configuration(profile, profile_name)?; - let (value, summary) = migrate(configuration, custom_schedule)?; + let (value, summary) = migrate_with_base(configuration, custom_schedule, file.parent())?; let mut temp_path = Default::default(); let out = out.unwrap_or_else(|| { @@ -167,13 +172,22 @@ fn resolve_configuration_name( } /// Migrate a GUI configuration into maa-cli task config shape. +#[cfg(test)] pub(super) fn migrate( config: WpfConfig, custom_schedule: Option<&Path>, +) -> Result<(CliConfig, MigrationSummary)> { + migrate_with_base(config, custom_schedule, None) +} + +pub(super) fn migrate_with_base( + config: WpfConfig, + custom_schedule: Option<&Path>, + schedule_base: Option<&Path>, ) -> Result<(CliConfig, MigrationSummary)> { let mut summary = MigrationSummary::default(); let value = config - .migrate_tasks(&mut summary, custom_schedule)? + .migrate_tasks(&mut summary, custom_schedule, schedule_base)? .context("GUI configuration produced no CLI config")?; Ok((value, summary)) } @@ -253,7 +267,9 @@ fn parse_wpf_task(value: Value) -> Result { "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)?)), + "RoguelikeTask" => Ok(WpfTask::RoguelikeTask(Box::new(serde_json::from_value( + value, + )?))), "ReclamationTask" => Ok(WpfTask::ReclamationTask(serde_json::from_value(value)?)), other => Ok(WpfTask::Unsupported { type_tag: other.to_string(), @@ -269,6 +285,7 @@ impl WpfTask { summary: &mut MigrationSummary, custom_schedule: Option<&Path>, schedule_applied: &mut bool, + schedule_base: Option<&Path>, ) -> Result> { match self { Self::StartUpTask(start_up) => { @@ -285,6 +302,7 @@ impl WpfTask { summary, custom_schedule, schedule_applied, + schedule_base, )?)?)) } Self::RecruitTask(task) => { @@ -301,7 +319,7 @@ impl WpfTask { } Self::RoguelikeTask(task) => { task.report_to(summary); - Ok(Some(Value::try_from(task)?)) + Ok(Some(Value::try_from(task.as_ref())?)) } Self::ReclamationTask(task) => { task.report_to(summary); From d2d76a76e6bae6a6d9febb20b457385c5dcf565c Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 23 Oct 2026 00:22:38 +0800 Subject: [PATCH 26/29] =?UTF-8?q?=E2=9C=A8=20feat:=20map=20WPF=20roguelike?= =?UTF-8?q?=20fields=20from=20the=20integration=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlackFlow and the other roguelike options were reported as skipped even though the protocol defines them. Map the modes, the full starting-operator list, cultivation targets, and the related start options. Leave reward bitmasks and fast-flight flags unmapped until their GUI names are known. 按集成文档映射肉鸽字段,不再把已有协议项当成未知设置丢掉。 Co-authored-by: Cursor --- .../src/config/migrate/wpf/roguelike.rs | 348 +++++++++++++++++- 1 file changed, 338 insertions(+), 10 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs index c3819657..33115220 100644 --- a/crates/maa-cli/src/config/migrate/wpf/roguelike.rs +++ b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs @@ -12,6 +12,7 @@ const HANDLED: &[&str] = &[ "Roles", "CoreChar", "StartingOpers", + "UseAdditionalStartingOpers", "StartCount", "Difficulty", "Investment", @@ -25,6 +26,25 @@ const HANDLED: &[&str] = &[ "RefreshTraderWithDice", "StartWithEliteTwo", "StartWithEliteTwoOnly", + "SquadCollectible", + "CollectibleStartAwards", + "CollectibleShopping", + "ExpectedCollapsalParadigms", + "MonthlySquadAutoIterate", + "MonthlySquadCheckComms", + "DeepExplorationAutoIterate", + "FindPlaytimeTarget", + "BlackFlowCultivationTarget", + "BlackFlowEnding", + "BlackFlowStartAnimal1", + "BlackFlowStartAnimal2", + "BlackFlowStartRewards", + "SamiFirstFloorFoldartal", + "SamiFirstFloorFoldartals", + "SamiNewSquad2StartingFoldartal", + "SamiNewSquad2StartingFoldartals", + "StartWithSeed", + "Seed", ]; #[derive(Debug, Serialize)] @@ -75,11 +95,51 @@ struct CliRoguelikeParams { #[serde(skip_serializing_if = "Option::is_none")] only_start_with_elite_two: Option, #[serde(skip_serializing_if = "Option::is_none")] + core_char_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + collectible_mode_squad: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collectible_mode_shopping: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collectible_mode_start_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + monthly_squad_auto_iterate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + monthly_squad_check_comms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + deep_exploration_auto_iterate: Option, + #[serde( + rename = "find_playTime_target", + skip_serializing_if = "Option::is_none" + )] + find_play_time_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blackflow_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blackflow_cultivation_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blackflow_start_animals: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + expected_collapsal_paradigms: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + first_floor_foldartal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + start_foldartal_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + start_with_seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] enable: Option, } +#[derive(Debug, Serialize)] +struct CliStartingOper { + name: String, + use_support: bool, +} + /// Newer GUI exports list starting operators here instead of flat `CoreChar` / -/// `UseSupport`. Only the first entry maps to maa-cli / MaaCore params. +/// `UseSupport`. The full list maps to `core_char_list`; the first entry also +/// maps to `core_char` and `use_support`. #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct WpfStartingOper { @@ -118,6 +178,44 @@ pub(super) struct WpfRoguelikeTask { refresh_trader_with_dice: bool, start_with_elite_two: bool, start_with_elite_two_only: bool, + #[serde(default)] + squad_collectible: String, + #[serde(default)] + collectible_start_awards: String, + #[serde(default)] + collectible_shopping: bool, + #[serde(default)] + expected_collapsal_paradigms: String, + #[serde(default)] + monthly_squad_auto_iterate: bool, + #[serde(default)] + monthly_squad_check_comms: bool, + #[serde(default)] + deep_exploration_auto_iterate: bool, + #[serde(default)] + find_playtime_target: String, + #[serde(default)] + black_flow_cultivation_target: String, + #[serde(default)] + black_flow_ending: String, + #[serde(default)] + black_flow_start_animal1: String, + #[serde(default)] + black_flow_start_animal2: String, + #[serde(default)] + black_flow_start_rewards: String, + #[serde(default)] + sami_first_floor_foldartal: bool, + #[serde(default)] + sami_first_floor_foldartals: String, + #[serde(default)] + sami_new_squad2_starting_foldartal: bool, + #[serde(default)] + sami_new_squad2_starting_foldartals: String, + #[serde(default)] + start_with_seed: bool, + #[serde(default)] + seed: String, #[serde(flatten)] unknown: Map, } @@ -131,6 +229,8 @@ impl WpfRoguelikeTask { "CollapsalParadigms" => Some(5), "MonthlySquad" => Some(6), "DeepExploration" => Some(7), + "FindPlaytime" => Some(20001), + "BlackFlowStartRun" => Some(30003), _ => None, } } @@ -150,6 +250,89 @@ impl WpfRoguelikeTask { ) } + fn named_or_skip( + &self, + summary: &mut MigrationSummary, + field: &str, + raw: &str, + mapped: Option, + ) { + if !raw.is_empty() && raw != "None" && mapped.is_none() { + summary.skip_field("RoguelikeTask", Some(self.name.clone()), field); + } + } + + fn report_unmapped(&self, summary: &mut MigrationSummary) { + self.named_or_skip( + summary, + "FindPlaytimeTarget", + &self.find_playtime_target, + playtime_target(&self.find_playtime_target).map(|n| n.to_string()), + ); + self.named_or_skip( + summary, + "BlackFlowCultivationTarget", + &self.black_flow_cultivation_target, + swaddled(&self.black_flow_cultivation_target).map(str::to_string), + ); + self.named_or_skip( + summary, + "BlackFlowEnding", + &self.black_flow_ending, + ending_strategy(&self.black_flow_ending).map(str::to_string), + ); + for (field, raw) in [ + ( + "BlackFlowStartAnimal1", + self.black_flow_start_animal1.as_str(), + ), + ( + "BlackFlowStartAnimal2", + self.black_flow_start_animal2.as_str(), + ), + ] { + self.named_or_skip(summary, field, raw, swaddled(raw).map(str::to_string)); + } + if !self.black_flow_start_rewards.is_empty() && self.black_flow_start_rewards != "None" { + summary.skip_field( + "RoguelikeTask", + Some(self.name.clone()), + "BlackFlowStartRewards", + ); + } + self.named_or_skip( + summary, + "CollectibleStartAwards", + &self.collectible_start_awards, + award_key(&self.collectible_start_awards).map(str::to_string), + ); + } + + fn core_char_list(&self) -> Option> { + let list: Vec = self + .starting_opers + .iter() + .filter(|oper| !oper.name.is_empty()) + .map(|oper| CliStartingOper { + name: oper.name.clone(), + use_support: oper.use_support, + }) + .collect(); + (!list.is_empty()).then_some(list) + } + + fn start_animals(&self) -> Option> { + let animals: Vec = [ + &self.black_flow_start_animal1, + &self.black_flow_start_animal2, + ] + .into_iter() + .filter_map(|name| swaddled(name)) + .map(str::to_string) + .collect(); + (!animals.is_empty()).then_some(animals) + } + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { if !self.is_enable { summary.disable_task("RoguelikeTask", Some(self.name.clone())); @@ -157,9 +340,7 @@ impl WpfRoguelikeTask { if self.mapped_mode().is_none() { summary.skip_field("RoguelikeTask", Some(self.name.clone()), "Mode"); } - if self.starting_opers.len() > 1 { - summary.skip_field("RoguelikeTask", Some(self.name.clone()), "StartingOpers"); - } + self.report_unmapped(summary); report_unknown_fields( summary, "RoguelikeTask", @@ -205,12 +386,90 @@ impl TryFrom<&WpfRoguelikeTask> for CliRoguelikeTask { 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), + core_char_list: task.core_char_list(), + collectible_mode_squad: (!task.squad_collectible.is_empty()) + .then(|| task.squad_collectible.clone()), + collectible_mode_shopping: task.collectible_shopping.then_some(true), + collectible_mode_start_list: award_key(&task.collectible_start_awards).map(|key| { + let mut list = Map::new(); + list.insert(key.to_string(), Value::Bool(true)); + list + }), + monthly_squad_auto_iterate: task.monthly_squad_auto_iterate.then_some(true), + monthly_squad_check_comms: task.monthly_squad_check_comms.then_some(true), + deep_exploration_auto_iterate: task.deep_exploration_auto_iterate.then_some(true), + find_play_time_target: playtime_target(&task.find_playtime_target), + blackflow_strategy: ending_strategy(&task.black_flow_ending).map(str::to_string), + blackflow_cultivation_target: swaddled(&task.black_flow_cultivation_target) + .map(str::to_string), + blackflow_start_animals: task.start_animals(), + expected_collapsal_paradigms: split_list(&task.expected_collapsal_paradigms), + first_floor_foldartal: (task.sami_first_floor_foldartal + && !task.sami_first_floor_foldartals.is_empty()) + .then(|| task.sami_first_floor_foldartals.clone()), + start_foldartal_list: (task.sami_new_squad2_starting_foldartal) + .then(|| split_list(&task.sami_new_squad2_starting_foldartals)) + .flatten(), + start_with_seed: (task.start_with_seed && !task.seed.is_empty()) + .then(|| task.seed.clone()), enable: (!task.is_enable).then_some(false), }, }) } } +fn playtime_target(name: &str) -> Option { + match name { + "Ling" => Some(1), + "Shu" => Some(2), + "Nian" => Some(3), + _ => None, + } +} + +fn swaddled(name: &str) -> Option<&'static str> { + match name { + "Cat" => Some("swaddled_cat"), + "Dog" => Some("swaddled_dog"), + "Cerberus" => Some("swaddled_cerberus"), + "FeatheredSerpent" => Some("swaddled_feathered_serpent"), + _ => None, + } +} + +fn ending_strategy(name: &str) -> Option<&'static str> { + match name { + "Ending1" => Some("ending1"), + "Ending2" => Some("ending2"), + "Ending3" => Some("ending3"), + _ => None, + } +} + +fn award_key(name: &str) -> Option<&'static str> { + match name { + "HotWater" => Some("hot_water"), + "Shield" => Some("shield"), + "Ingot" => Some("ingot"), + "Hope" => Some("hope"), + "Random" => Some("random"), + "Key" => Some("key"), + "Dice" => Some("dice"), + "Ideas" | "Idea" => Some("ideas"), + _ => None, + } +} + +fn split_list(raw: &str) -> Option> { + let items: Vec = raw + .split([';', ',', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(); + (!items.is_empty()).then_some(items) +} + #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { @@ -325,13 +584,13 @@ mod tests { ) .unwrap(); + assert!(summary.skipped_fields.is_empty()); assert_eq!( - summary - .skipped_fields - .iter() - .map(|f| f.field.as_str()) - .collect::>(), - ["StartingOpers"] + config.tasks[0].pointer("/params/core_char_list"), + Some(&json!([ + { "name": "机械师", "use_support": true }, + { "name": "额外干员", "use_support": false }, + ])) ); assert_eq!( config.tasks[0].pointer("/params/core_char"), @@ -377,6 +636,8 @@ mod tests { ("CollapsalParadigms", 5), ("MonthlySquad", 6), ("DeepExploration", 7), + ("FindPlaytime", 20001), + ("BlackFlowStartRun", 30003), ] { let (config, summary) = migrate( serde_json::from_value(json!({ @@ -401,6 +662,73 @@ mod tests { } } + #[test] + fn maps_blackflow_protocol_fields() { + let mut task = roguelike_task("BlackFlowStartRun", i32::MAX, true); + let obj = task.as_object_mut().unwrap(); + obj.insert("Theme".into(), json!("BlackFlow")); + obj.insert("SquadCollectible".into(), json!("指挥分队")); + obj.insert("CollectibleStartAwards".into(), json!("HotWater")); + obj.insert("FindPlaytimeTarget".into(), json!("Ling")); + obj.insert("BlackFlowCultivationTarget".into(), json!("Cerberus")); + obj.insert("BlackFlowEnding".into(), json!("Ending1")); + obj.insert("BlackFlowStartAnimal1".into(), json!("Cerberus")); + obj.insert("BlackFlowStartAnimal2".into(), json!("None")); + obj.insert("BlackFlowStartRewards".into(), json!("None")); + obj.insert("MonthlySquadAutoIterate".into(), json!(true)); + obj.remove("CoreChar"); + obj.insert( + "StartingOpers".into(), + json!([{ "Name": "机械师", "UseSupport": false }]), + ); + + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [task], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.skipped_fields.is_empty()); + assert_eq!(config.tasks[0].pointer("/params/mode"), Some(&json!(30003))); + assert!(config.tasks[0].pointer("/params/difficulty").is_none()); + assert_eq!( + config.tasks[0].pointer("/params/blackflow_start_animals"), + Some(&json!(["swaddled_cerberus"])) + ); + assert_eq!( + config.tasks[0].pointer("/params/blackflow_cultivation_target"), + Some(&json!("swaddled_cerberus")) + ); + assert_eq!( + config.tasks[0].pointer("/params/blackflow_strategy"), + Some(&json!("ending1")) + ); + assert_eq!( + config.tasks[0].pointer("/params/find_playTime_target"), + Some(&json!(1)) + ); + assert_eq!( + config.tasks[0].pointer("/params/collectible_mode_squad"), + Some(&json!("指挥分队")) + ); + assert_eq!( + config.tasks[0].pointer("/params/collectible_mode_start_list/hot_water"), + Some(&json!(true)) + ); + assert_eq!( + config.tasks[0].pointer("/params/monthly_squad_auto_iterate"), + Some(&json!(true)) + ); + assert_eq!( + config.tasks[0].pointer("/params/core_char_list/0/name"), + Some(&json!("机械师")) + ); + } + #[test] fn unknown_roguelike_mode_is_skipped() { let (config, summary) = migrate( From 5f3e328f42bb1b6af7e6228932d92fdfe1d5323d Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 23 Oct 2026 00:22:51 +0800 Subject: [PATCH 27/29] =?UTF-8?q?=E2=9C=A8=20feat:=20map=20the=20remaining?= =?UTF-8?q?=20small=20WPF=20protocol=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fight, recruit, and award still dropped settings that the integration document names. Write DrGrandet, the level-3 permit reserve, and signinevent when they are actually set. Treat mall timestamps and availability flags as GUI bookkeeping instead of lost settings. 补上作战、招募、领奖的协议字段,信用商店的本地记账不再报丢失。 Co-authored-by: Cursor --- crates/maa-cli/src/config/migrate/wpf/award.rs | 6 ++++++ crates/maa-cli/src/config/migrate/wpf/fight.rs | 7 +++++++ crates/maa-cli/src/config/migrate/wpf/mall.rs | 16 ++++++++-------- crates/maa-cli/src/config/migrate/wpf/recruit.rs | 11 +++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/crates/maa-cli/src/config/migrate/wpf/award.rs b/crates/maa-cli/src/config/migrate/wpf/award.rs index 78404414..f4a3b498 100644 --- a/crates/maa-cli/src/config/migrate/wpf/award.rs +++ b/crates/maa-cli/src/config/migrate/wpf/award.rs @@ -12,6 +12,7 @@ const HANDLED: &[&str] = &[ "Orundum", "Mining", "SpecialAccess", + "SignInEvent", ]; #[derive(Debug, Serialize)] @@ -38,6 +39,8 @@ struct CliAwardParams { #[serde(skip_serializing_if = "Option::is_none")] specialaccess: Option, #[serde(skip_serializing_if = "Option::is_none")] + signinevent: Option, + #[serde(skip_serializing_if = "Option::is_none")] enable: Option, } @@ -53,6 +56,8 @@ pub(super) struct WpfAwardTask { orundum: bool, mining: bool, special_access: bool, + #[serde(default)] + sign_in_event: bool, #[serde(flatten)] unknown: Map, } @@ -94,6 +99,7 @@ impl TryFrom<&WpfAwardTask> for CliAwardTask { orundum: Some(task.orundum), mining: Some(task.mining), specialaccess: Some(task.special_access), + signinevent: task.sign_in_event.then_some(true), enable: (!task.is_enable).then_some(false), }, }) diff --git a/crates/maa-cli/src/config/migrate/wpf/fight.rs b/crates/maa-cli/src/config/migrate/wpf/fight.rs index c865a90c..dd0efbd5 100644 --- a/crates/maa-cli/src/config/migrate/wpf/fight.rs +++ b/crates/maa-cli/src/config/migrate/wpf/fight.rs @@ -27,6 +27,7 @@ const HANDLED: &[&str] = &[ "Series", "UseExpiringMedicine", "MedicineExpireDays", + "IsDrGrandet", ]; /// maa-cli Fight task shape written by migration. @@ -63,6 +64,8 @@ struct CliFightParams { medicine_expire_days: Option, #[serde(skip_serializing_if = "Option::is_none")] stage: Option, + #[serde(rename = "DrGrandet", skip_serializing_if = "Option::is_none")] + dr_grandet: Option, #[serde(skip_serializing_if = "Option::is_none")] enable: Option, } @@ -76,6 +79,7 @@ impl CliFightParams { && self.series == i32::default() && self.medicine_expire_days.is_none() && self.stage.is_none() + && self.dr_grandet.is_none() && self.enable.is_none() } @@ -123,6 +127,8 @@ pub(super) struct WpfFightTask { series: i32, use_expiring_medicine: bool, medicine_expire_days: i32, + #[serde(default)] + is_dr_grandet: bool, #[serde(flatten)] #[allow(dead_code)] @@ -315,6 +321,7 @@ impl From<&WpfFightTask> for CliFightParams { .use_expiring_medicine .then_some(task.medicine_expire_days), stage: None, + dr_grandet: task.is_dr_grandet.then_some(true), enable: None, } } diff --git a/crates/maa-cli/src/config/migrate/wpf/mall.rs b/crates/maa-cli/src/config/migrate/wpf/mall.rs index 961fa357..c30a054c 100644 --- a/crates/maa-cli/src/config/migrate/wpf/mall.rs +++ b/crates/maa-cli/src/config/migrate/wpf/mall.rs @@ -15,6 +15,13 @@ const HANDLED: &[&str] = &[ "ShoppingIgnoreBlackListWhenFull", "OnlyBuyDiscount", "ReserveMaxCredit", + // GUI bookkeeping. MaaCore mall params have no matching fields. + "CreditFightLastTime", + "CreditFightOnceADay", + "VisitFriendsLastTime", + "VisitFriendsOnceADay", + "IsCreditFightAvailable", + "IsVisitFriendsAvailable", ]; #[derive(Debug, Serialize)] @@ -149,14 +156,7 @@ mod tests { ) .unwrap(); - assert_eq!( - summary - .skipped_fields - .iter() - .map(|f| f.field.as_str()) - .collect::>(), - ["CreditFightLastTime"] - ); + assert!(summary.is_empty()); assert_eq!( serde_json::to_value(config).unwrap(), json!({ diff --git a/crates/maa-cli/src/config/migrate/wpf/recruit.rs b/crates/maa-cli/src/config/migrate/wpf/recruit.rs index f0530d42..d3380b06 100644 --- a/crates/maa-cli/src/config/migrate/wpf/recruit.rs +++ b/crates/maa-cli/src/config/migrate/wpf/recruit.rs @@ -23,6 +23,8 @@ const HANDLED: &[&str] = &[ "Level3PreferTags", "PreserveTagEnabled", "PreserveTagList", + "Level3RecruitmentPermitReserveEnabled", + "Level3RecruitmentPermitReserve", ]; #[derive(Debug, Serialize)] @@ -53,6 +55,8 @@ struct CliRecruitParams { #[serde(skip_serializing_if = "Option::is_none")] preserve_tags: Option>, #[serde(skip_serializing_if = "Option::is_none")] + level3_recruitment_permit_reserve: Option, + #[serde(skip_serializing_if = "Option::is_none")] enable: Option, } @@ -84,6 +88,10 @@ pub(super) struct WpfRecruitTask { preserve_tag_enabled: bool, #[serde(default)] preserve_tag_list: Vec, + #[serde(default)] + level3_recruitment_permit_reserve_enabled: bool, + #[serde(default)] + level3_recruitment_permit_reserve: i32, #[serde(flatten)] unknown: Map, } @@ -164,6 +172,9 @@ impl TryFrom<&WpfRecruitTask> for CliRecruitTask { recruitment_time: (!recruitment_time.is_empty()).then_some(recruitment_time), first_tags, preserve_tags, + level3_recruitment_permit_reserve: (task.level3_recruitment_permit_reserve_enabled + && task.level3_recruitment_permit_reserve > 0) + .then_some(task.level3_recruitment_permit_reserve), enable: (!task.is_enable).then_some(false), }, }) From ef626e7e050140890249c9fe07059ee005644552 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 23 Oct 2026 00:22:58 +0800 Subject: [PATCH 28/29] =?UTF-8?q?=F0=9F=93=9D=20docs:=20describe=20the=20n?= =?UTF-8?q?ewly=20mapped=20WPF=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration docs still said these protocol fields were skipped. Document relative schedules, Fiammetta targets, the roguelike mapping, and the smaller fields. 文档补上相对排班和这次新映射的 WPF 字段。 Co-authored-by: Cursor --- crates/maa-cli/docs/zh-CN/wpf.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md index 2976e6e7..4c4ec880 100644 --- a/crates/maa-cli/docs/zh-CN/wpf.md +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -34,6 +34,7 @@ maa migrate wpf gui.new.json --custom-schedule ~/一图流-153-一天两换-MAA. GUI 里 `IsEnable = false` 的任务仍会写出,但会带上 `params.enable = false`,因此默认不会执行;需要时再手动改回 `true` 或删掉该字段。 示例输入与输出可以参考仓库中的 [`config_examples/wpf`](../../config_examples/wpf)。 +示例输出里的排班 `filename` 仍是相对名,方便对照;实际迁移会写成输入 JSON 所在目录下的绝对路径。 ## 任务映射说明 @@ -77,6 +78,7 @@ params = { stage = "LS-6" } ``` 这里的 `medicine_expire_days` 对应 GUI 的 `MedicineExpireDays`(在 `UseExpiringMedicine = true` 时写入),表示使用多少天内将过期的理智药。 +`IsDrGrandet` 为 true 时写成 `DrGrandet`。 活动关卡的 `start` 和 `end` 来自迁移时本地的 `StageActivityV2.json`,之后不会随热更新自动刷新;活动换档后需重新迁移或手动改条件。 若同时启用 `UseWeeklySchedule`,周计划会与关卡开放条件用 `And` 组合。 @@ -91,6 +93,7 @@ params = { stage = "LS-6" } - `ExtraTagMode` → `extra_tags_mode` - `LevelNChoose` / `LevelNTime` → `select` / `confirm` / `recruitment_time` - 偏好标签、保留标签 → `first_tags` / `preserve_tags` +- `Level3RecruitmentPermitReserve` 在开关打开且数量大于 0 时写成 `level3_recruitment_permit_reserve` 以下行为需要特别注意: @@ -104,16 +107,19 @@ params = { stage = "LS-6" } - 读到 `plans` 且各班有 `period`、GUI 为时间轮换(`PlanSelect = -1`)时,写出 `mode = 10000`、`filename`,以及按时段(跨天时加上 `DayMod`)选择 `plan_index` 的变体。 - 读到排班但没有可用时段(或 GUI 选了固定班次)时,写出自定义模式并带上 `plan_index`。 -- `Filename` 里的 `~` 会展开为用户主目录;写出的 `filename` 是展开后的绝对路径。 +- `Filename` 里的 `~` 会展开为用户主目录。相对路径按输入 JSON 所在目录解析。写出的 `filename` 是展开后的绝对路径。 +- 示例输入旁的 `一图流-153-一天两换-MAA.json` 是一份只含时段和菲亚梅塔的排班,供上面的 `gui.new.json` 引用。 +- `FiammettaTarget1/2/3` 写成 `fiammetta_targets`。`FiammettaRecoveryEnabled` 为 true 时写成 `fiammetta_recovery_enabled`。集成文档规定这两项只在 `mode = 0` 时生效;自定义排班里的菲亚梅塔仍由排班文件自己描述,MaaCore 读取该文件。 - 可用 `--custom-schedule ` 覆盖 GUI 中的排班文件路径;仅当 GUI 已是自定义排班时有效,否则报错退出。覆盖后若文件读失败,也会报错退出,而不会回退到默认基建。 - 未指定 `--custom-schedule` 时,文件不存在、无法解析或没有 `plans` 会跳过 `Mode` / `Filename`,按默认基建模式(`mode = 0`)写出其余参数,并在迁移摘要中说明。 ### 其他任务 - `Mall`:信用商店、信用作战、访问好友、黑白名单等会映射到对应参数。 -- `Award`:日常奖励、邮件、源石锭等;GUI 的 `FreeGacha` 对应 `recruit`。 -- `Roguelike` / `Reclamation`:主题、模式与常见选项会迁移;无法识别的模式字段会记入跳过摘要。 -- 较新的 GUI 用 `StartingOpers` 代替 `CoreChar` / `UseSupport` 时,会取列表首项映射为 `core_char` 与 `use_support`;多余开局干员会记入跳过摘要。 +- `Award`:日常奖励、邮件、源石锭等;GUI 的 `FreeGacha` 对应 `recruit`,`SignInEvent` 对应 `signinevent`。 +- `Roguelike` / `Reclamation`:主题、模式与协议里有对应项的参数会迁移,包括黑流树海的开局模式、培育目标和结局策略。无法识别的模式或取值会记入跳过摘要。 +- 较新的 GUI 用 `StartingOpers` 代替 `CoreChar` / `UseSupport` 时,整份列表写成 `core_char_list`,第一项同时保留为 `core_char` 与 `use_support`。 +- `UserDataUpdate`(仓库识别、干员 box)没有对应的 maa-cli 任务,整项跳过。 - 未识别的 `$type` 会整项跳过,并出现在迁移摘要的「跳过任务」列表中。 各任务类型的完整参数含义请参考 [MAA 集成文档][task-types]。迁移后若要继续用条件变体精细控制执行逻辑,请参阅 [任务条件](config.md#任务条件)。 From 87cde4d90e77c946a3d8e242db6d32f01ca0c8e7 Mon Sep 17 00:00:00 2001 From: nan-mu Date: Fri, 23 Oct 2026 01:10:47 +0800 Subject: [PATCH 29/29] =?UTF-8?q?=F0=9F=93=9D=20docs:=20explain=20WPF=20mi?= =?UTF-8?q?gration=20for=20CLI=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readers know maa-cli config keys, not GUI export keys or undocumented MaaCore names. Put passthrough parameters in their own section, and show a real summary with an explanation. Note that roguelike and reclamation settings are unverified, and add the other languages. 面向命令行用户重写 WPF 迁移说明,并补上其他语言。 Co-authored-by: Cursor --- crates/maa-cli/docs/en-US/intro.md | 1 + crates/maa-cli/docs/en-US/wpf.md | 291 ++++++++++++++++++++++++++ crates/maa-cli/docs/ja-JP/intro.md | 1 + crates/maa-cli/docs/ja-JP/wpf.md | 292 ++++++++++++++++++++++++++ crates/maa-cli/docs/ko-KR/intro.md | 1 + crates/maa-cli/docs/ko-KR/wpf.md | 292 ++++++++++++++++++++++++++ crates/maa-cli/docs/zh-CN/wpf.md | 323 +++++++++++++++++++++-------- crates/maa-cli/docs/zh-TW/intro.md | 1 + crates/maa-cli/docs/zh-TW/wpf.md | 292 ++++++++++++++++++++++++++ 9 files changed, 1406 insertions(+), 88 deletions(-) create mode 100644 crates/maa-cli/docs/en-US/wpf.md create mode 100644 crates/maa-cli/docs/ja-JP/wpf.md create mode 100644 crates/maa-cli/docs/ko-KR/wpf.md create mode 100644 crates/maa-cli/docs/zh-TW/wpf.md diff --git a/crates/maa-cli/docs/en-US/intro.md b/crates/maa-cli/docs/en-US/intro.md index b58687d8..a0ffb0b9 100644 --- a/crates/maa-cli/docs/en-US/intro.md +++ b/crates/maa-cli/docs/en-US/intro.md @@ -13,6 +13,7 @@ A simple CLI for [MAA][maa-home] written in Rust. - [Installation and Compilation](install.md) - [Usage](usage.md) - [Configuration](config.md) +- [Migrate a WPF GUI profile](wpf.md) - [FAQ](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights diff --git a/crates/maa-cli/docs/en-US/wpf.md b/crates/maa-cli/docs/en-US/wpf.md new file mode 100644 index 00000000..82c7c243 --- /dev/null +++ b/crates/maa-cli/docs/en-US/wpf.md @@ -0,0 +1,291 @@ +# Migrate a WPF GUI profile + +`maa migrate wpf` turns a JSON export from the MAA Windows GUI into a maa-cli task file. +Unsupported tasks and settings are skipped. +When the command finishes, it writes a summary to stderr. +Check the result against [Configuration](config.md) before you use it for daily runs. + +## Migrate one profile + +The input is the JSON file exported from the GUI. + +```bash +# Write a .toml file with the same name as the input +maa migrate wpf gui.json + +# The extension picks the format: toml, yaml, or json +maa migrate wpf gui.json tasks/daily.toml + +# Pick one profile by name when the export contains several +maa migrate wpf gui.json --profile-name Default + +# Use your schedule file when the GUI profile already uses a custom base schedule +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +If the export contains one profile, the command uses it. +If it contains several and you omit `--profile-name`, the command asks you to choose. + +`--custom-schedule` works only when that infrast task is already a custom schedule in the GUI. +If it is not, the option makes the command exit with an error. + +Put the generated file in `$MAA_CONFIG_DIR/tasks`, then run it with `maa run `. +Find the config directory with `maa dir config`. + +## Settings already described in the configuration docs + +The output has the same shape as a [custom task](config.md#custom-tasks). +The top level is `[[tasks]]`. +Each task has `type`, `name`, and `params`. +It also has `variants` when a condition selects among stages or shifts. +Condition syntax is in [task variants and conditions](config.md#task-variants-and-conditions). + +The names below are ones the configuration docs already use. +Names the configuration docs do not explain, which MaaCore reads at run time, are in the next section. + +### Start up + +`client_type` and `start_game_enabled` are the two fields in the start-up example in the configuration docs. +If the GUI has no recognized client, `client_type` becomes a prompt you answer when the task runs. +That prompt is the user-input form in the configuration docs. +If account switching is on and the account name is empty, that setting is not written. +The summary then has one skipped field. + +### Fight + +Each stage is a variant. +The stage name is `stage`. + +The condition follows the stage kind. + +- A resource or chip stage uses `Weekday` in the official server timezone. +- An event stage uses `DateTime` when this machine's hot-update event table has that event. The condition has a start, an end, and a timezone. Otherwise it uses `Always`, and the command warns. +- A permanent stage, such as a main story stage, annihilation, or `LS-6`, uses `Always`. + +`DateTime`, `Weekday`, and `DayMod` are in the configuration docs. +`Always` is also a maa-cli condition. It ignores the clock, so the variant can always run. +`start` and `end` for an event stage come from the event table on this machine at migration time. +They do not follow a later hot update. +Migrate again, or edit the condition, after the event changes. +When the GUI also has a weekly plan, that plan and the stage condition are combined with `And`. + +The example below is a fight task with several stage choices. + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +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" } +``` + +If the result will spend Originite Prime, the command warns. +Medicine, Originite Prime, times, and expiring medicine use names that are not in the configuration docs. Those names are in the next section. + +### Base rotation + +Facility order is `facility`. +Whether the dorm fills operators whose trust is not full is `dorm_trust_enabled`. +Both names are in the infrast example in the configuration docs. + +When the GUI rotates shifts by time of day, and every shift in the schedule has a period, `mode` is `10000`. +Variants pick `plan_index` by time of day. +A shift that crosses midnight also gets `DayMod`. +That is the same arrangement as the infrast example in the configuration docs. +When the schedule has no usable period, or the GUI selects a fixed shift, the result stays a custom base plan and includes `plan_index`. + +The written `filename` is absolute. +`~` in the schedule path expands to your home directory. +A relative path is resolved from the directory that contains the JSON you passed to the command. +The configuration docs say MaaCore reads that schedule JSON, not maa-cli. +Fiammetta inside the schedule file stays in that file. It is not copied into the task parameters. + +To use a different schedule, pass its JSON path with `--custom-schedule`. +If the GUI profile is not already a custom schedule, the command exits with an error. +If that file cannot be read, the command also exits with an error. It does not fall back to the default base plan. + +Without that option, a missing file, a file that cannot be parsed, or a file with no shifts writes `mode` as `0`. +The summary says the schedule was skipped. + +Drone use, the mood threshold, and the Fiammetta list use names that are not in the configuration docs. Those names are in the next section. + +## Parameters passed to MaaCore + +The [basic structure](config.md#basic-structure) says the concrete names inside task `params` follow the MAA integration document. +maa-cli does not check those names. +MaaCore reads them when the task runs. +The names below are written into `params` and are not explained in the configuration docs. +Edit these names when you change a value. +Allowed values for each name are in the [MAA integration document][task-types]. + +### Whether the task runs + +When `enable` is `false`, the task does not run. +A task that is off in the GUI is still written, with `enable = false`. +To run it, set that field to `true` or delete it. + +### Fight parameters + +- `medicine` is how many sanity potions to use. The configuration docs use this name only in a user-input example, and do not say that it is sanity medicine. +- `stone` is how many Originite Prime to use. +- `times` is the battle limit. +- `series` is the number of battles in a row. +- `drops` is the target drop. +- `medicine_expire_days` is how many days of expiring sanity medicine to use. The configuration docs show a different name, `expiring_medicine`. Migration does not write that name. +- When `DrGrandet` is `true`, the task waits on the Originite confirmation until 1 sanity is restored, then spends the Originite. + +### Recruitment + +- `times` is how many times to recruit. +- `refresh` is whether to refresh a 3-star tag set. +- `extra_tags_mode` is how extra tags are handled. The meaning of the number is in the integration document. +- `select` and `confirm` are the star levels to select and confirm, such as `3`, `4`, and `5`. +- `recruitment_time` is the recruitment minutes for each star level. +- `first_tags` is the preferred tags. +- `preserve_tags` is the tags to keep. +- `level3_recruitment_permit_reserve` is how many 3-star recruitment permits to keep. The name is omitted when that switch is off or the count is not greater than 0. + +Immediate refresh from the GUI is not written. +If it is on, the summary has one skipped field and a warning. +If automatic confirmation of a 6-star operator is on, the command warns. + +### Base rotation parameters + +- `drones` is what drones are used for. +- `threshold` is the dorm mood threshold, from 0 to 1. A percentage in the GUI becomes this fraction. +- `replenish` is whether the trading post restocks Originium Shard. +- `dorm_notstationed_enabled` is whether the dorm uses the "not stationed" option. +- `reception_message_board`, `reception_clue_exchange`, and `reception_send_clue` are the reception room message board, clue exchange, and sending clues. +- `continue_training` is whether the training room keeps training. +- `fiammetta_targets` is the operators Fiammetta should restore mood for. +- `fiammetta_recovery_enabled` is whether to use Fiammetta to restore mood. +- `use_pinus_sylvestris`, `use_perception_information`, `use_worldly_plight`, and `use_abyssal_hunter` are the Pinus Sylvestris, Perception Information, Worldly Plight, and Abyssal Hunter facility sets. A value of `false` is not written. + +`fiammetta_targets` applies only when `mode` is 0 and `fiammetta_recovery_enabled` is true. +`fiammetta_recovery_enabled` and those four facility sets apply only when `mode` is 0. + +### Credit store + +- `shopping` is whether to buy from the credit store. +- `credit_fight` is whether to run the credit fight. +- `formation_index` is the squad used for the credit fight. +- `visit_friends` is whether to visit friends. +- `buy_first` is the items to buy first. +- `blacklist` is the items not to buy. +- `force_shopping_if_credit_full` is whether to buy items outside the blacklist when credit is full. +- `only_buy_discount` is whether to buy only discounted items. +- `reserve_max_credit` is whether to keep credit near the cap. + +Last-run times, and whether the credit fight or a friend visit can still be done today, are records kept by the GUI. +They are not written. + +### Awards + +- `award` collects daily or weekly mission rewards. +- `mail` collects mail rewards. +- `recruit` collects the daily free pull from a limited banner. +- `orundum` collects Orundum from the lucky wall. +- `mining` collects Orundum from a limited mining permit. +- `specialaccess` collects the monthly card given for the 5th anniversary. +- `signinevent` collects a limited-time sign-in reward. + +### Roguelike + +These settings have not been verified on a real device. +What each parameter does is in the [MAA integration document][task-types]. + +These names matter only for the matching theme and mode. +They are written so a choice you made in the GUI is not dropped. +MaaCore ignores them in a mode that does not use them. + +- `theme` is the theme. +- `mode` is the mode number. Farming score is `0`. Farming Originite Ingot is `1`. Fishing for a start reward is `4`. Collapsal paradigms are `5`. Monthly squad is `6`. Deep exploration is `7`. Finding a Playtime node is `20001`. A BlackFlow start run is `30003`. An unrecognized mode omits `mode` and is recorded in the summary. +- `squad` is the starting squad. `roles` is the starting role set. +- `core_char` is the first starting operator. `core_char_list` is every starting operator. Each item has `name` and `use_support`. +- `use_support` is whether the first operator is a support operator. `use_nonfriend_support` is whether a non-friend support operator is allowed. +- `starts_count` is how many runs to start. `difficulty` is the difficulty. A placeholder that is a very large number is not written. +- `investment_enabled` is whether to invest. `investments_count` is how many investments to make. `investment_with_more_score` is whether to invest for a higher score. `stop_when_investment_full` is whether to stop when the deposit is full. +- `stop_at_final_boss` is whether to stop before the ending. `stop_at_max_level` is whether to stop at max level. +- `start_with_elite_two` and `only_start_with_elite_two` are elite-2 starts. +- `refresh_trader_with_dice` is whether to refresh the trader with dice. +- `collectible_mode_squad` is the squad used while fishing for a start, or for the preparation run in BlackFlow. `collectible_mode_shopping` is whether that run shops. `collectible_mode_start_list` is the start reward to look for, such as `hot_water`. +- `monthly_squad_auto_iterate` is whether the monthly squad switches automatically. `monthly_squad_check_comms` is whether that switch also looks at communications. +- `deep_exploration_auto_iterate` is whether deep exploration switches automatically. +- `find_playTime_target` is the Playtime node. `1` is Ling, `2` is Shu, and `3` is Nian. +- `blackflow_strategy` is the BlackFlow ending strategy, such as `ending1`. `blackflow_cultivation_target` is the swaddled animal to raise. `blackflow_start_animals` is the swaddled animals the preparation run must collect. +- `expected_collapsal_paradigms` is the collapsal paradigms to look for. +- `first_floor_foldartal` and `start_foldartal_list` are foldartal. +- `start_with_seed` is a fixed seed. + +The BlackFlow start-reward bitmask and fast flight are not written. +If either one is on in the GUI, and it is not left unset, the summary has a skipped field for it. + +### Reclamation + +These settings have not been verified on a real device. +What each parameter does is in the [MAA integration document][task-types]. + +- `theme` is the theme. +- `mode` `0` is prosperity without saving. `mode` `1` is prosperity written into the save. An unrecognized mode is omitted and recorded in the summary. +- `tools_to_craft` is the tools to craft. +- `increment_mode` is the increment mode. +- `num_craft_batches` is how many craft batches to run each round. + +Depot recognition and the operator box are not written as tasks. +An unknown task is not written either. +Both appear only in the summary. + +## Summary + +When something is skipped or turned off, the summary goes to stderr. +When nothing was lost, the command prints nothing. +The text is English. +The three groups below do not all have to appear. + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +The first line means this conversion dropped something. + +`Skipped tasks` are tasks that were not written at all. +The text in quotes is the task name from the GUI. +`UserDataUpdateTask` is depot recognition and the operator box. + +`Disabled tasks` were written, but they do not run. +`params.enable=false` in the parentheses is the `enable` field from the previous section. +The text in quotes is the task name. +`FightTask` is a fight, `RecruitTask` is recruitment, and `InfrastTask` is base rotation. + +`Skipped fields` is one setting inside a task that was not written. +The text in quotes is the task name. +The word after the colon is the original field name from the exported file. +You do not need to know that name. +It only means that setting was dropped. +The last line above is what you see when recruitment has immediate refresh turned on. + +Read the summary before you use the output for daily runs. + +## See also + +- [Configuration](config.md) +- [Usage](usage.md) +- [Task types in the MAA integration document][task-types] + +[task-types]: https://maa.plus/docs/en-us/protocol/integration.html diff --git a/crates/maa-cli/docs/ja-JP/intro.md b/crates/maa-cli/docs/ja-JP/intro.md index 921815d2..dae52cdc 100644 --- a/crates/maa-cli/docs/ja-JP/intro.md +++ b/crates/maa-cli/docs/ja-JP/intro.md @@ -17,6 +17,7 @@ This page is outdated and maybe still in Simplified Chinese. Translation is need - [安装及编译](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/ja-JP/wpf.md b/crates/maa-cli/docs/ja-JP/wpf.md new file mode 100644 index 00000000..9ca51dee --- /dev/null +++ b/crates/maa-cli/docs/ja-JP/wpf.md @@ -0,0 +1,292 @@ +# WPF GUI から設定を移す + +`maa migrate wpf` は、MAA の Windows GUI が書き出した JSON を maa-cli のタスクファイルに変換します。 +対応していないタスクと設定は飛ばします。 +終了時、コマンドは要約を標準エラーへ書きます。 +日常の実行に使う前に、結果を [設定](config.md) と照合してください。 + +## 設定を 1 つ移す + +入力は GUI が書き出した JSON ファイルです。 + +```bash +# 入力と同じ名前の .toml を書く +maa migrate wpf gui.json + +# 拡張子が形式を決める。toml、yaml、json のいずれか +maa migrate wpf gui.json tasks/daily.toml + +# 書き出しに設定が複数あるときは、名前で 1 つ選ぶ +maa migrate wpf gui.json --profile-name Default + +# GUI がすでにカスタム基建スケジュールのとき、指定したスケジュールファイルを使う +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +書き出しに設定が 1 つだけのとき、コマンドはそれを使います。 +複数あり、`--profile-name` がないとき、コマンドは選択を尋ねます。 + +`--custom-schedule` は、その基建が GUI 上ですでにカスタムスケジュールのときだけ有効です。 +まだカスタムスケジュールでないとき、このオプションを付けるとコマンドはエラーで終了します。 + +生成したファイルを `$MAA_CONFIG_DIR/tasks` に置き、`maa run ` で実行します。 +設定ディレクトリは `maa dir config` で確認します。 + +## 設定ドキュメントに既にある項目 + +出力の形は [カスタムタスク](config.md#自定义任务) と同じです。 +最上段は `[[tasks]]` です。 +各タスクには `type`、`name`、`params` があります。 +条件で切り替えるときは `variants` もあります。 +条件の書き方は [タスク条件](config.md#任务条件) を参照してください。 + +ここから先で挙げる名前は、設定ドキュメントに既にあるものだけです。 +設定ドキュメントに無く、実行時に MaaCore が読む名前は次の節です。 + +### 起動 + +`client_type` と `start_game_enabled` は、設定ドキュメントの起動例にある 2 つの項目です。 +GUI に判別できるクライアントがないとき、`client_type` は実行時に選ぶ入力になります。 +書き方は設定ドキュメントのユーザー入力を参照してください。 +アカウント切り替えが有効でアカウント名が空のとき、その項目は書き込まれません。 +要約に、飛ばしたフィールドが 1 行出ます。 + +### 理性作戦 + +打つステージはそれぞれ 1 つのバリアントです。 +ステージ名は `stage` です。 + +条件はステージの種類に従います。 + +- 資源ステージまたはチップステージは、公式サーバーのタイムゾーンで `Weekday` になります。 +- イベントステージは、このマシンのホットアップデートにあるイベント表にそのイベントがあるとき `DateTime` になります。開始時刻、終了時刻、タイムゾーンが入ります。無いときは `Always` になり、警告が出ます。 +- メインストーリー、殲滅、`LS-6` のような常設ステージは `Always` です。 + +`DateTime`、`Weekday`、`DayMod` は設定ドキュメントのタスク条件にあります。 +`Always` も maa-cli の条件です。時刻を見ないので、そのバリアントは常に実行できます。 +イベントステージの `start` と `end` は、移行した時点のこのマシン上のイベント表から取ります。 +その後のホットアップデートには追従しません。 +イベントが変わったら、再度移行するか、条件を手で直します。 +GUI に週間予定もあるとき、週間予定とステージ条件は `And` でまとまります。 + +次の例は、候補ステージが複数あるときの結果です。 + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +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" } +``` + +結果が純正源石を使うとき、コマンドは警告します。 +この設定は純正源石を消費することがあります。 +理性薬、純正源石、回数、期限切れ理性薬のパラメータ名は設定ドキュメントにありません。次の節を参照してください。 + +### 基建の交代 + +施設の順序は `facility` です。 +信頼度が最大でないオペレーターを宿舎に入れるかは `dorm_trust_enabled` です。 +この 2 つの名前は、設定ドキュメントの基建の例にあります。 + +GUI が時刻で交代し、スケジュールの各シフトに時間帯があるとき、`mode` は `10000` です。 +バリアントは時間帯で `plan_index` を選びます。 +日付をまたぐ時間帯には `DayMod` が付きます。 +これは設定ドキュメントの基建の例と同じ組み立てです。 +スケジュールに使える時間帯がないとき、または GUI が固定シフトを選んでいるとき、結果はカスタム基建のまま `plan_index` を含みます。 + +書き出される `filename` は絶対パスです。 +スケジュールパスの `~` はホームディレクトリに展開されます。 +相対パスは、コマンドに渡した JSON があるディレクトリから解決されます。 +設定ドキュメントにあるとおり、このスケジュール JSON を読むのは maa-cli ではなく MaaCore です。 +スケジュールファイル内のフィアメッタもそのファイルに残り、タスクパラメータには写しません。 + +別のスケジュールを使うには、`--custom-schedule` でその JSON のパスを渡します。 +GUI がまだカスタムスケジュールでないとき、コマンドはエラーで終了します。 +指定したファイルを読めないときもエラーで終了し、既定の基建には戻りません。 + +このオプションが無く、スケジュールが無い、解析できない、またはシフトが無いとき、`mode` は `0` と書かれます。 +要約には、スケジュールを飛ばしたと出ます。 + +ドローン、気分のしきい値、フィアメッタの名簿は、設定ドキュメントに無い名前です。次の節を参照してください。 + +## MaaCore に渡すパラメータ + +[基本構造](config.md#基本结构) にあるとおり、タスク `params` の具体的な名前は MAA 統合ドキュメントに従います。 +maa-cli はその名前が正しいかを検査しません。 +実行時に MaaCore が読みます。 +以下は、移行が `params` に書くが、設定ドキュメントが説明していない名前です。 +値を変えるときは、これらの名前を変えます。 +各名前の取りうる値は [MAA 統合ドキュメント][task-types] を参照してください。 + +### 実行するかどうか + +`enable` が `false` のとき、そのタスクは実行されません。 +GUI でオフのタスクもファイルには残り、`enable = false` が付きます。 +実行するには、そのフィールドを `true` にするか、削除します。 + +### 理性作戦のパラメータ + +- `medicine` は理性薬の個数です。設定ドキュメントでは、ユーザー入力の例にこの名前が出るだけで、理性薬だとは書いてありません。 +- `stone` は純正源石の個数です。 +- `times` は回数の上限です。 +- `series` は連戦の回数です。 +- `drops` は目標ドロップです。 +- `medicine_expire_days` は、期限まで何日以内の理性薬を使うかです。設定ドキュメントの例にある `expiring_medicine` は別の名前で、移行はそちらを書きません。 +- `DrGrandet` が `true` のとき、純正源石の確認画面で理性が 1 回復するまで待ち、すぐに使用します。 + +### 公開求人 + +- `times` は求人の回数です。 +- `refresh` は星 3 タグを更新するかどうかです。 +- `extra_tags_mode` は追加タグの扱いです。数値の意味は統合ドキュメントを参照してください。 +- `select` と `confirm` は、自動で選び、自動で確定する星の数です。例は `3`、`4`、`5` です。 +- `recruitment_time` は星ごとの求人分数です。 +- `first_tags` は優先タグです。 +- `preserve_tags` は残すタグです。 +- `level3_recruitment_permit_reserve` は残しておく星 3 求人許可証の数です。この項目がオフ、または個数が 0 より大きくないとき、この名前は書きません。 + +GUI の即時更新は書き込まれません。 +オンのとき、要約に飛ばしたフィールドが 1 行出て、警告も出ます。 +星 6 の自動確定がオンのとき、コマンドは警告します。 + +### 基建交代のパラメータ + +- `drones` はドローンの用途です。 +- `threshold` は宿舎の気分しきい値で、範囲は 0 から 1 です。GUI の百分率はこの小数になります。 +- `replenish` は貿易所の源石の欠片を自動補充するかどうかです。 +- `dorm_notstationed_enabled` は宿舎で「未配置」を使うかどうかです。 +- `reception_message_board`、`reception_clue_exchange`、`reception_send_clue` は応接室の伝言板、手がかり交換、手がかりの送付です。 +- `continue_training` は訓練室で訓練を続けるかどうかです。 +- `fiammetta_targets` は、フィアメッタが気分を回復するオペレーターの名簿です。 +- `fiammetta_recovery_enabled` は、フィアメッタで気分を回復するかどうかです。 +- `use_pinus_sylvestris`、`use_perception_information`、`use_worldly_plight`、`use_abyssal_hunter` は、レッドパイン、知覚、塵世、アビサルの 4 つの施設組み合わせです。`false` のときは書きません。 + +`fiammetta_targets` が効くのは、`mode` が 0 かつ `fiammetta_recovery_enabled` が true のときだけです。 +`fiammetta_recovery_enabled` と上の 4 つの組み合わせが効くのは、`mode` が 0 のときだけです。 + +### 信用交換 + +- `shopping` は信用商品を買うかどうかです。 +- `credit_fight` は信用戦闘を行うかどうかです。 +- `formation_index` は信用戦闘で使う編成です。 +- `visit_friends` はフレンドを訪問するかどうかです。 +- `buy_first` は優先して買う商品です。 +- `blacklist` は買わない商品です。 +- `force_shopping_if_credit_full` は、信用が満杯のときブラックリスト以外をまだ買うかどうかです。 +- `only_buy_discount` は割引商品だけ買うかどうかです。 +- `reserve_max_credit` は信用を上限付近まで残すかどうかです。 + +信用戦闘とフレンド訪問の前回時刻、今日まだできるかは、GUI 自身の記録です。 +結果には書きません。 + +### 報酬 + +- `award` はデイリーまたはウィークリー任務の報酬を受け取ります。 +- `mail` はメール報酬を受け取ります。 +- `recruit` は限定ガチャの毎日の無料 1 回を受け取ります。 +- `orundum` はラッキーウォールの合成玉を受け取ります。 +- `mining` は期間限定の採掘許可証の合成玉を受け取ります。 +- `specialaccess` は 5 周年でもらえる月間パスを受け取ります。 +- `signinevent` は期間限定のサインイン報酬を受け取ります。 + +### ローグライク + +これらの設定は実機では確認していません。 +各パラメータの働きは [MAA 統合ドキュメント][task-types] を参照してください。 + +これらの名前が意味を持つのは、対応するテーマとモードだけです。 +GUI で選んだ内容を残すために書き込みます。 +使わないモードでは、MaaCore はそれらを無視します。 + +- `theme` はテーマです。 +- `mode` はモード番号です。スコア周回は `0`、源石錠周回は `1`、開始報酬狙いは `4`、崩壊パラダイムは `5`、月次分隊は `6`、深層調査は `7`、常楽ノード狙いは `20001`、黒流樹海の開始周回は `30003` です。判別できないモードは `mode` を書かず、要約に記録します。 +- `squad` は開始分隊です。`roles` は開始の職分の組み合わせです。 +- `core_char` は最初の開始オペレーターです。`core_char_list` は開始オペレーター全員で、各項目に `name` と `use_support` があります。 +- `use_support` は最初の 1 人をサポートにするかどうかです。`use_nonfriend_support` はフレンド以外のサポートを使うかどうかです。 +- `starts_count` は開始回数です。`difficulty` は難易度です。難易度がプレースホルダの非常に大きい数なら書きません。 +- `investment_enabled` は投資するかどうかです。`investments_count` は投資回数です。`investment_with_more_score` はより高いスコアで投資するかどうかです。`stop_when_investment_full` は預金が満杯なら止めるかどうかです。 +- `stop_at_final_boss` は結末の前で止めるかどうかです。`stop_at_max_level` はレベルが最大なら止めるかどうかです。 +- `start_with_elite_two` と `only_start_with_elite_two` はエリート 2 での開始です。 +- `refresh_trader_with_dice` はサイコロで商店を更新するかどうかです。 +- `collectible_mode_squad` は開始報酬狙い、または黒流樹海の準備局で使う分隊です。`collectible_mode_shopping` は開始報酬狙いで買い物するかどうかです。`collectible_mode_start_list` は欲しい開始報酬で、例は `hot_water` です。 +- `monthly_squad_auto_iterate` は月次分隊を自動で切り替えるかどうかです。`monthly_squad_check_comms` は切り替え時に通信を見るかどうかです。 +- `deep_exploration_auto_iterate` は深層調査を自動で切り替えるかどうかです。 +- `find_playTime_target` は常楽ノードです。`1` はリィン、`2` はシュー、`3` はニェンです。 +- `blackflow_strategy` は黒流樹海の結末方針で、例は `ending1` です。`blackflow_cultivation_target` は育てる襁褓の動物です。`blackflow_start_animals` は準備局で揃える襁褓の動物です。 +- `expected_collapsal_paradigms` は欲しい崩壊パラダイムです。 +- `first_floor_foldartal` と `start_foldartal_list` は密文板です。 +- `start_with_seed` は固定シードです。 + +黒流樹海の開始報酬のビット組み合わせと、快速飛行は書き込まれません。 +GUI でこの 2 つがオフでも「なし」でもないとき、要約に飛ばしたフィールドがそれぞれ 1 行出ます。 + +### 生息演算 + +これらの設定は実機では確認していません。 +各パラメータの働きは [MAA 統合ドキュメント][task-types] を参照してください。 + +- `theme` はテーマです。 +- `mode` が `0` ならセーブしない繁栄、`1` ならセーブへ書く繁栄です。判別できないモードは書かず、要約に記録します。 +- `tools_to_craft` は作る道具です。 +- `increment_mode` は増分モードです。 +- `num_craft_batches` は 1 周あたりの製作バッチ数です。 + +倉庫認識とオペレーター box はタスクになりません。 +未知のタスクもタスクになりません。 +この 2 つは要約にだけ出ます。 + +## 要約 + +飛ばした項目やオフのタスクがあるとき、要約は標準エラーへ書かれます。 +何も失っていないとき、何も印刷しません。 +コマンドが印刷するのは英語です。 +次の 3 項目は、必ず同時に出るわけではありません。 + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +1 行目は、この変換が一部を落としたことを表します。 + +`Skipped tasks` は、ファイルにまったく書かれなかったタスクです。 +引用符の中は、GUI でのそのタスクの名前です。 +`UserDataUpdateTask` は倉庫認識とオペレーター box です。 + +`Disabled tasks` は、ファイルには書いたが、既定では実行しないタスクです。 +括弧内の `params.enable=false` が、前の節の `enable` です。 +引用符の中はタスク名です。 +`FightTask` は作戦、`RecruitTask` は公開求人、`InfrastTask` は基建です。 + +`Skipped fields` は、あるタスクの中で書き込まれなかった設定 1 つです。 +引用符の中はタスク名です。 +コロンの後は、GUI の書き出しファイルにあった元のフィールド名です。 +その名前を知る必要はありません。 +その設定が落とされたことだけを表します。 +公開求人で即時更新がオンのとき、上の最後の行になります。 + +日常の実行に使う前に、要約を読んでください。 + +## 参考 + +- [設定](config.md) +- [使い方](usage.md) +- [MAA 統合ドキュメントのタスクタイプ][task-types] + +[task-types]: https://maa.plus/docs/ja-jp/protocol/integration.html diff --git a/crates/maa-cli/docs/ko-KR/intro.md b/crates/maa-cli/docs/ko-KR/intro.md index e4f9fcae..53ffd238 100644 --- a/crates/maa-cli/docs/ko-KR/intro.md +++ b/crates/maa-cli/docs/ko-KR/intro.md @@ -13,6 +13,7 @@ Rust로 작성된 간단한 [MAA][maa-home] 명령줄 도구입니다. - [설치 및 빌드](install.md) - [사용법](usage.md) - [설정](config.md) +- [WPF GUI 설정 옮기기](wpf.md) - [FAQ](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights/ diff --git a/crates/maa-cli/docs/ko-KR/wpf.md b/crates/maa-cli/docs/ko-KR/wpf.md new file mode 100644 index 00000000..e7988222 --- /dev/null +++ b/crates/maa-cli/docs/ko-KR/wpf.md @@ -0,0 +1,292 @@ +# WPF GUI 설정 옮기기 + +`maa migrate wpf`는 MAA Windows GUI가 내보낸 JSON을 maa-cli 작업 파일로 바꿉니다. +지원하지 않는 작업과 설정은 건너뜁니다. +명령이 끝나면 요약을 표준 오류로 씁니다. +매일 실행에 쓰기 전에 결과를 [설정](config.md)과 맞춰 확인합니다. + +## 설정 하나 옮기기 + +입력은 GUI가 내보낸 JSON 파일입니다. + +```bash +# 입력과 같은 이름의 .toml을 쓴다 +maa migrate wpf gui.json + +# 확장자가 형식을 정한다. toml, yaml, json 중 하나 +maa migrate wpf gui.json tasks/daily.toml + +# 내보내기에 설정이 여러 개이면 이름으로 하나를 고른다 +maa migrate wpf gui.json --profile-name Default + +# GUI에서 이미 사용자 기지 일정이면, 지정한 일정 파일을 쓴다 +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +내보내기에 설정이 하나이면 명령은 그것을 사용합니다. +여러 개이고 `--profile-name`이 없으면 명령이 고르라고 묻습니다. + +`--custom-schedule`은 그 기반 시설이 GUI에서 이미 사용자 일정일 때만 동작합니다. +아직 사용자 일정이 아니면 이 옵션을 주면 명령은 오류로 끝납니다. + +만든 파일을 `$MAA_CONFIG_DIR/tasks`에 두고 `maa run `로 실행합니다. +설정 디렉터리는 `maa dir config`로 확인합니다. + +## 설정 문서에 이미 있는 항목 + +출력 구조는 [사용자 정의 작업](config.md#사용자-정의-작업)과 같습니다. +맨 위는 `[[tasks]]`입니다. +각 작업에는 `type`, `name`, `params`가 있습니다. +조건으로 바꿀 때는 `variants`도 있습니다. +조건 작성은 [작업 조건](config.md#작업-조건)을 봅니다. + +아래에서 말하는 이름은 설정 문서에 이미 있는 이름입니다. +설정 문서에 없고, 실행할 때 MaaCore가 읽는 이름은 다음 절에 있습니다. + +### 시작 + +`client_type`과 `start_game_enabled`는 설정 문서의 시작 예시에 있는 두 항목입니다. +GUI에 알아볼 수 있는 클라이언트가 없으면 `client_type`은 실행 중에 고르는 입력이 됩니다. +작성법은 설정 문서의 사용자 입력을 봅니다. +계정 전환이 켜져 있고 계정 이름이 비어 있으면 그 항목은 쓰지 않습니다. +요약에 건너뛴 필드가 한 줄 나옵니다. + +### 이성 작전 + +칠 스테이지는 각각 변형 하나입니다. +스테이지 이름은 `stage`입니다. + +조건은 스테이지 종류를 따릅니다. + +- 자원이나 칩 스테이지는 오피셜 서버 시간대의 `Weekday`입니다. +- 이벤트 스테이지는 이 기기의 핫업데이트 이벤트 표에 그 이벤트가 있으면 `DateTime`입니다. 시작 시각, 종료 시각, 시간대가 들어갑니다. 없으면 `Always`가 되고 경고가 납니다. +- 메인, 섬멸, `LS-6` 같은 상설 스테이지는 `Always`입니다. + +`DateTime`, `Weekday`, `DayMod`는 설정 문서의 작업 조건에 있습니다. +`Always`도 maa-cli 조건입니다. 시각을 보지 않으므로 그 변형은 항상 실행할 수 있습니다. +이벤트 스테이지의 `start`와 `end`는 옮길 당시 이 기기의 이벤트 표에서 가져옵니다. +이후 핫업데이트에는 따라가지 않습니다. +이벤트가 바뀌면 다시 옮기거나 조건을 직접 고칩니다. +GUI에 주간 계획도 있으면 주간 계획과 스테이지 조건을 `And`로 묶습니다. + +아래는 후보 스테이지가 여러 개일 때의 결과입니다. + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +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" } +``` + +결과에 순오리지늄을 쓰면 명령이 경고합니다. +이 설정은 순오리지늄을 쓸 수 있습니다. +이성 물약, 순오리지늄, 횟수, 만료 임박 물약의 매개변수 이름은 설정 문서에 없습니다. 다음 절을 봅니다. + +### 기반 시설 교대 + +시설 순서는 `facility`입니다. +신뢰도가 가득 차지 않은 오퍼레이터를 숙소에 넣을지는 `dorm_trust_enabled`입니다. +이 두 이름은 설정 문서의 기반 시설 예시에 있습니다. + +GUI가 시각으로 교대하고 일정의 각 교대조에 시간대가 있으면 `mode`는 `10000`입니다. +변형은 시간대에 따라 `plan_index`를 고릅니다. +자정을 넘는 시간대에는 `DayMod`가 붙습니다. +이것은 설정 문서의 기반 시설 예시와 같은 구성입니다. +일정에 쓸 수 있는 시간대가 없거나 GUI가 고정 교대조를 고르면, 결과는 사용자 기지로 남고 `plan_index`를 포함합니다. + +써 넣는 `filename`은 절대 경로입니다. +일정 경로의 `~`는 홈 디렉터리로 펼칩니다. +상대 경로는 명령에 넘긴 JSON이 있는 디렉터리에서 해석합니다. +설정 문서에 있듯이 이 일정 JSON은 maa-cli가 아니라 MaaCore가 읽습니다. +일정 파일 안의 피아메타도 그 파일에 남고, 작업 매개변수로 복사하지 않습니다. + +다른 일정을 쓰려면 `--custom-schedule`로 그 JSON 경로를 줍니다. +GUI가 아직 사용자 일정이 아니면 명령은 오류로 끝납니다. +그 파일을 읽지 못해도 오류로 끝나며, 기본 기반 시설로 돌아가지 않습니다. + +이 옵션이 없고, 일정이 없거나, 해석할 수 없거나, 교대조가 없으면 `mode`는 `0`으로 씁니다. +요약에는 일정을 건너뛰었다고 나옵니다. + +드론, 기분 임계값, 피아메타 명단은 설정 문서에 없는 이름입니다. 다음 절을 봅니다. + +## MaaCore에 넘기는 매개변수 + +[기본 구조](config.md#기본-구조)에 있듯이 작업 `params`의 구체적인 이름은 MAA 통합 문서를 따릅니다. +maa-cli는 그 이름이 맞는지 검사하지 않습니다. +실행할 때 MaaCore가 읽습니다. +아래는 옮길 때 `params`에 쓰지만 설정 문서가 설명하지 않는 이름입니다. +값을 바꿀 때는 이 이름을 바꿉니다. +각 이름의 허용 값은 [MAA 통합 문서][task-types]를 봅니다. + +### 실행 여부 + +`enable`이 `false`이면 그 작업은 실행되지 않습니다. +GUI에서 끈 작업도 파일에는 남고 `enable = false`가 붙습니다. +실행하려면 그 필드를 `true`로 바꾸거나 지웁니다. + +### 이성 작전 매개변수 + +- `medicine`은 이성 물약 개수입니다. 설정 문서는 사용자 입력 예시에만 이 이름이 있고, 이성 물약이라고 쓰지 않습니다. +- `stone`은 순오리지늄 개수입니다. +- `times`는 횟수 상한입니다. +- `series`는 연속 전투 횟수입니다. +- `drops`는 목표 드롭입니다. +- `medicine_expire_days`는 며칠 안에 만료되는 이성 물약을 쓸지입니다. 설정 문서 예시의 `expiring_medicine`은 다른 이름이며, 옮길 때 그 이름을 쓰지 않습니다. +- `DrGrandet`이 `true`이면 순오리지늄 확인 화면에서 이성이 1 회복될 때까지 기다렸다가 바로 사용합니다. + +### 공개모집 + +- `times`는 모집 횟수입니다. +- `refresh`는 3성 태그를 새로고침할지입니다. +- `extra_tags_mode`는 추가 태그를 다루는 방식입니다. 숫자의 의미는 통합 문서를 봅니다. +- `select`와 `confirm`은 자동으로 고르고 자동으로 확정할 성급입니다. 예는 `3`, `4`, `5`입니다. +- `recruitment_time`은 성급별 모집 분입니다. +- `first_tags`는 선호 태그입니다. +- `preserve_tags`는 남길 태그입니다. +- `level3_recruitment_permit_reserve`는 남겨 둘 3성 모집 허가증 수입니다. 이 항목이 꺼져 있거나 수량이 0보다 크지 않으면 이 이름을 쓰지 않습니다. + +GUI의 즉시 새로고침은 쓰지 않습니다. +켜져 있으면 요약에 건너뛴 필드가 한 줄 나오고 경고도 납니다. +6성 자동 확정이 켜져 있으면 명령이 경고합니다. + +### 기반 시설 교대 매개변수 + +- `drones`는 드론 용도입니다. +- `threshold`는 숙소 기분 임계값이며 범위는 0에서 1입니다. GUI의 백분율은 이 소수가 됩니다. +- `replenish`는 무역소의 오리지늄 파편을 자동 보충할지입니다. +- `dorm_notstationed_enabled`는 숙소에서 「미배치」를 쓸지입니다. +- `reception_message_board`, `reception_clue_exchange`, `reception_send_clue`는 응접실 메시지 보드, 단서 교환, 단서 보내기입니다. +- `continue_training`은 훈련실에서 훈련을 계속할지입니다. +- `fiammetta_targets`는 피아메타가 기분을 회복할 오퍼레이터 명단입니다. +- `fiammetta_recovery_enabled`는 피아메타로 기분을 회복할지입니다. +- `use_pinus_sylvestris`, `use_perception_information`, `use_worldly_plight`, `use_abyssal_hunter`는 홍송, 지각, 진세, 심해 네 가지 시설 조합입니다. `false`이면 쓰지 않습니다. + +`fiammetta_targets`는 `mode`가 0이고 `fiammetta_recovery_enabled`가 true일 때만 적용됩니다. +`fiammetta_recovery_enabled`와 위 네 조합은 `mode`가 0일 때만 적용됩니다. + +### 크레딧 상점 + +- `shopping`은 크레딧 상품을 살지입니다. +- `credit_fight`는 크레딧 전투를 할지입니다. +- `formation_index`는 크레딧 전투에 쓸 편성입니다. +- `visit_friends`는 친구를 방문할지입니다. +- `buy_first`는 먼저 살 상품입니다. +- `blacklist`는 사지 않을 상품입니다. +- `force_shopping_if_credit_full`은 크레딧이 가득 찼을 때 차단 목록 밖을 아직 살지입니다. +- `only_buy_discount`는 할인 상품만 살지입니다. +- `reserve_max_credit`은 크레딧을 상한 근처에 남길지입니다. + +크레딧 전투와 친구 방문의 마지막 시각, 오늘 아직 할 수 있는지는 GUI 자신의 기록입니다. +결과에는 쓰지 않습니다. + +### 보상 + +- `award`는 일일 또는 주간 임무 보상을 받습니다. +- `mail`은 우편 보상을 받습니다. +- `recruit`는 한정 모집에서 주는 매일 무료 1회를 받습니다. +- `orundum`은 행운의 벽 합성옥을 받습니다. +- `mining`은 기간 한정 채굴 허가증의 합성옥을 받습니다. +- `specialaccess`는 5주년 월간권을 받습니다. +- `signinevent`는 기간 한정 출석 보상을 받습니다. + +### 로그라이크 + +이 설정은 실기에서 확인하지 않았습니다. +각 매개변수의 작용은 [MAA 통합 문서][task-types]를 봅니다. + +이 이름들은 해당하는 테마와 모드에서만 의미가 있습니다. +GUI에서 고른 내용을 남기려고 씁니다. +쓰지 않는 모드에서 MaaCore는 이들을 무시합니다. + +- `theme`는 테마입니다. +- `mode`는 모드 번호입니다. 점수 파밍은 `0`, 원석정 파밍은 `1`, 시작 보상 맞추기는 `4`, 붕괴 패러다임은 `5`, 월간 분대는 `6`, 심층 조사는 `7`, 상락 노드 찾기는 `20001`, 흑류수해 시작 런은 `30003`입니다. 알아보지 못한 모드는 `mode`를 쓰지 않고 요약에 기록합니다. +- `squad`는 시작 분대입니다. `roles`는 시작 직업 조합입니다. +- `core_char`는 첫 시작 오퍼레이터입니다. `core_char_list`는 시작 오퍼레이터 전체이며 각 항목에 `name`과 `use_support`가 있습니다. +- `use_support`는 첫 오퍼레이터를 서포트로 쓸지입니다. `use_nonfriend_support`는 친구가 아닌 서포트를 쓸지입니다. +- `starts_count`는 시작 횟수입니다. `difficulty`는 난이도입니다. 난이도가 자리 표시용인 매우 큰 수이면 쓰지 않습니다. +- `investment_enabled`는 투자할지입니다. `investments_count`는 투자 횟수입니다. `investment_with_more_score`는 더 높은 점수로 투자할지입니다. `stop_when_investment_full`은 예금이 가득 차면 멈출지입니다. +- `stop_at_final_boss`는 엔딩 전에 멈출지입니다. `stop_at_max_level`은 레벨이 최대이면 멈출지입니다. +- `start_with_elite_two`와 `only_start_with_elite_two`는 정예 2 시작입니다. +- `refresh_trader_with_dice`는 주사위로 상점을 새로고침할지입니다. +- `collectible_mode_squad`는 시작 보상 맞추기 또는 흑류수해 준비 판에서 쓰는 분대입니다. `collectible_mode_shopping`은 시작 보상 맞추기에서 쇼핑할지입니다. `collectible_mode_start_list`는 원하는 시작 보상이며 예는 `hot_water`입니다. +- `monthly_squad_auto_iterate`는 월간 분대를 자동으로 바꿀지입니다. `monthly_squad_check_comms`는 바꿀 때 통신을 볼지입니다. +- `deep_exploration_auto_iterate`는 심층 조사를 자동으로 바꿀지입니다. +- `find_playTime_target`은 상락 노드입니다. `1`은 링, `2`는 슈, `3`은 니엔입니다. +- `blackflow_strategy`는 흑류수해 엔딩 전략이며 예는 `ending1`입니다. `blackflow_cultivation_target`은 키울 강보 동물입니다. `blackflow_start_animals`는 준비 판에서 모을 강보 동물입니다. +- `expected_collapsal_paradigms`는 원하는 붕괴 패러다임입니다. +- `first_floor_foldartal`과 `start_foldartal_list`는 밀문판입니다. +- `start_with_seed`는 고정 시드입니다. + +흑류수해 시작 보상의 비트 조합과 빠른 비행은 쓰지 않습니다. +GUI에서 이 둘이 꺼짐이나 「없음」이 아니면 요약에 건너뛴 필드가 각각 한 줄 나옵니다. + +### 생존 연산 + +이 설정은 실기에서 확인하지 않았습니다. +각 매개변수의 작용은 [MAA 통합 문서][task-types]를 봅니다. + +- `theme`는 테마입니다. +- `mode` `0`은 저장하지 않는 번영, `1`은 세이브에 쓰는 번영입니다. 알아보지 못한 모드는 쓰지 않고 요약에 기록합니다. +- `tools_to_craft`는 만들 도구입니다. +- `increment_mode`는 증분 모드입니다. +- `num_craft_batches`는 한 라운드의 제작 배치 수입니다. + +창고 인식과 오퍼레이터 box는 작업이 되지 않습니다. +알 수 없는 작업도 작업이 되지 않습니다. +둘 다 요약에만 나옵니다. + +## 요약 + +건너뛰거나 끈 작업이 있으면 요약은 표준 오류로 갑니다. +잃은 것이 없으면 아무것도 인쇄하지 않습니다. +명령이 인쇄하는 글은 영어입니다. +아래 세 항목이 항상 함께 나오지는 않습니다. + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +첫 줄은 이번 변환이 일부를 버렸다는 뜻입니다. + +`Skipped tasks`는 파일에 아예 쓰지 않은 작업입니다. +따옴표 안은 GUI에 있는 그 작업의 이름입니다. +`UserDataUpdateTask`는 창고 인식과 오퍼레이터 box입니다. + +`Disabled tasks`는 파일에는 썼지만 기본적으로 실행하지 않는 작업입니다. +괄호 안의 `params.enable=false`가 앞 절의 `enable`입니다. +따옴표 안은 작업 이름입니다. +`FightTask`는 작전, `RecruitTask`는 공개모집, `InfrastTask`는 기반 시설입니다. + +`Skipped fields`는 어떤 작업 안에서 쓰지 않은 설정 하나입니다. +따옴표 안은 작업 이름입니다. +콜론 뒤는 GUI 내보내기 파일에 있던 원래 필드 이름입니다. +그 이름을 알 필요는 없습니다. +그 설정이 버려졌다는 뜻만 있습니다. +공개모집에서 즉시 새로고침이 켜져 있으면 위 마지막 줄이 됩니다. + +매일 실행에 쓰기 전에 요약을 읽습니다. + +## 참고 + +- [설정](config.md) +- [사용법](usage.md) +- [MAA 통합 문서의 작업 유형][task-types] + +[task-types]: https://maa.plus/docs/ko-kr/protocol/integration.html diff --git a/crates/maa-cli/docs/zh-CN/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md index 4c4ec880..e3832fc2 100644 --- a/crates/maa-cli/docs/zh-CN/wpf.md +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -1,69 +1,81 @@ # 从 WPF GUI 迁移配置 -`maa migrate wpf` 可以把 MAA Windows WPF GUI 导出的配置文件,转换为 maa-cli 的自定义任务配置。 -迁移是有损的:不支持的任务类型与字段会被跳过,并在结束后输出摘要;转换完成后,你通常还需要对照 [配置文档](config.md) 做一次检查与微调。 +`maa migrate wpf` 把 MAA Windows 图形界面导出的 JSON 转成 maa-cli 任务文件。 +不支持的任务和设置会被跳过。 +结束时,命令把摘要写到标准错误。 +转完后对照 [配置](config.md) 检查一遍,再用于日常任务。 -## 使用方法 +## 迁移一份配置 -输入一般是 GUI 导出的 JSON 配置文件(包含 `Configurations` / `TaskQueue`)。 +输入是图形界面导出的 JSON 文件。 ```bash -# 输出默认写到与输入同名的 .toml 文件 -maa migrate wpf gui.new.json +# 默认写到与输入同名的 .toml +maa migrate wpf gui.json -# 指定输出路径与格式(由扩展名决定,支持 toml / yaml / json) -maa migrate wpf gui.new.json tasks/daily.toml +# 扩展名决定格式。可以是 toml、yaml 或 json +maa migrate wpf gui.json tasks/daily.toml -# 多配置导出时,用 --profile-name 选择配置名 -maa migrate wpf gui.new.json --profile-name Default +# 导出里有多份配置时,用名字选一份 +maa migrate wpf gui.json --profile-name Default -# GUI 是自定义排班时,用本机排班 JSON 覆盖其中的 Filename -maa migrate wpf gui.new.json --custom-schedule ~/一图流-153-一天两换-MAA.json +# 图形界面里已经是自定义排班时,改用你给出的排班文件 +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json ``` -如果导出文件里只有一个配置,会自动选择该配置;有多个配置且未指定 `--profile-name` 时,会交互式提示你选择。 +导出里只有一份配置时,命令直接用它。 +有多份,并且没有 `--profile-name` 时,命令会让你选。 -`--custom-schedule` 只在 GUI 基建任务已经是自定义排班(`Mode = Custom` 或填写了 `Filename`)时生效,用来覆盖排班文件路径。若 GUI 不是自定义排班,传入该选项会报错退出。 +`--custom-schedule` 只在图形界面里这份基建已经是自定义排班时有效。 +还不是自定义排班时,加上这个选项会报错退出。 -迁移完成后,把生成的任务文件放到 `$MAA_CONFIG_DIR/tasks` 中,即可通过 `maa run ` 运行。配置目录可用 `maa dir config` 查看。 +把生成的文件放到 `$MAA_CONFIG_DIR/tasks`,再用 `maa run ` 运行。 +配置目录用 `maa dir config` 查看。 -## 迁移结果 +## 配置文档里已经有的设置 -输出是一份 maa-cli 自定义任务文件,结构与 [自定义任务](config.md#自定义任务) 相同:顶层是 `[[tasks]]` 列表,每个任务包含 `type`、`name`、`params`,必要时还有 `variants`。 +输出和 [自定义任务](config.md#自定义任务) 的结构相同。 +顶层是 `[[tasks]]`。 +每个任务有 `type`、`name` 和 `params`。 +需要按条件切换时还有 `variants`。 +条件的写法见 [任务条件](config.md#任务条件)。 -GUI 里 `IsEnable = false` 的任务仍会写出,但会带上 `params.enable = false`,因此默认不会执行;需要时再手动改回 `true` 或删掉该字段。 +下面只说明配置文档里已经出现过的名字。 +配置文档没写、运行时直接交给 MaaCore 的名字,在下一节。 -示例输入与输出可以参考仓库中的 [`config_examples/wpf`](../../config_examples/wpf)。 -示例输出里的排班 `filename` 仍是相对名,方便对照;实际迁移会写成输入 JSON 所在目录下的绝对路径。 +### 开始唤醒 -## 任务映射说明 +`client_type` 和 `start_game_enabled` 就是配置文档启动示例里的那两个。 +图形界面没有可识别的客户端时,`client_type` 会变成运行时再选的输入。 +写法见配置文档的用户输入。 +开了切换账号但没有账号名时,这项不写入。 +摘要里会有一行跳过的字段。 -### 开始唤醒(StartUp) +### 理智作战 -`client_type` 与 `start_game_enabled` 来自 GUI 的 `Gui.RuntimeSettings`,而不是 `StartUpTask` 本体。 -如果 GUI 未填写可识别的客户端类型,迁移结果会把 `client_type` 写成交互式选项,运行任务时再选择。 -若开启了账号切换但未填写账号名,该字段会被跳过并给出警告。 +每个要打的关卡是一个变体。 +关卡名写在 `stage`。 -### 理智作战(Fight) +条件按关卡种类写。 -药品、源石、次数、掉落、连战、过期理智药等共享参数写在任务级 `params` 中;关卡相关内容写在变体里。 +- 资源本或芯片本用 `Weekday`,时区是官服。 +- 活动关能在本机热更新的活动表里查到时,用 `DateTime`。里面有开始时间、结束时间和时区。查不到则用 `Always`,并告警。 +- 主线、剿灭和 `LS-6` 这类常驻关用 `Always`。 -当 GUI 开启 `UseOptionalStage` 时,`StagePlan` 中的每个关卡会生成一个变体,并按关卡开放规则附上条件: +`DateTime`、`Weekday` 和 `DayMod` 在配置文档的任务条件里。 +`Always` 也是 maa-cli 的条件,表示不看时间,这个变体总是可以执行。 +活动关的 `start` 和 `end` 取自迁移当时本机的活动表。 +活动换档后不会自动更新。 +需要重新迁移,或手改条件。 +图形界面里还有一份每周计划时,周计划和关卡条件用 `And` 合在一起。 -- 资源本 / 芯片本:`Weekday`(官服服务器时区) -- 永久关卡(如主线、剿灭、`LS-6`):`Always` -- 活动关卡:若能在热更新资源 `StageActivityV2.json` 中查到对应活动,则写入 `DateTime`(使用该活动的开始时间、截止时间与时区);查不到则回退为 `Always` 并告警 - -例如 GUI 中同时启用过期理智药与多关卡备选时,迁移结果类似: +下面是多个备选关卡时的结果。 ```toml [[tasks]] name = "理智作战" type = "Fight" -[tasks.params] -medicine_expire_days = 2 - [[tasks.variants]] condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } params = { stage = "TO-5" } @@ -77,69 +89,204 @@ condition = { type = "Always" } params = { stage = "LS-6" } ``` -这里的 `medicine_expire_days` 对应 GUI 的 `MedicineExpireDays`(在 `UseExpiringMedicine = true` 时写入),表示使用多少天内将过期的理智药。 -`IsDrGrandet` 为 true 时写成 `DrGrandet`。 -活动关卡的 `start` 和 `end` 来自迁移时本地的 `StageActivityV2.json`,之后不会随热更新自动刷新;活动换档后需重新迁移或手动改条件。 -若同时启用 `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` -- `Level3RecruitmentPermitReserve` 在开关打开且数量大于 0 时写成 `level3_recruitment_permit_reserve` - -以下行为需要特别注意: +如果结果里会使用源石,命令会警告。 +这个设置可能消耗源石。 +理智药、源石、次数和过期药的参数名不在配置文档里,见下一节。 + +### 基建换班 + +设施顺序写在 `facility`。 +宿舍是否填入信赖未满的干员写在 `dorm_trust_enabled`。 +这两个名字在配置文档的基建例子里。 + +图形界面按时间自动换班,并且排班里每个班次都有时段时,`mode` 是 `10000`。 +变体按时间段选择 `plan_index`。 +跨天时段会加上 `DayMod`。 +这和配置文档里的基建例子是同一套。 +排班没有可用时段,或图形界面选了固定班次时,结果仍是自定义基建,并带上 `plan_index`。 + +写出的 `filename` 是绝对路径。 +排班路径里的 `~` 展开成你的主目录。 +相对路径按你传给命令的那份 JSON 所在目录解析。 +配置文档说明,这份排班 JSON 由 MaaCore 读取,不由 maa-cli 读取。 +排班文件里的菲亚梅塔也留在那个文件里,不抄进任务参数。 + +要换成另一份排班,用 `--custom-schedule` 给出那份 JSON 的路径。 +图形界面还不是自定义排班时,命令报错退出。 +指定的文件读失败时也报错退出,不会退回默认基建。 + +没有这个选项,并且排班不存在、无法解析或没有班次时,`mode` 写成 `0`。 +摘要里会说明跳过了排班。 + +无人机、心情阈值、菲亚梅塔名单这些名字不在配置文档里,见下一节。 + +## 交给 MaaCore 的参数 + +[配置](config.md#基本结构) 写明,任务 `params` 里的具体名字以 MAA 集成文档为准。 +maa-cli 不检查这些名字对不对。 +运行时由 MaaCore 读取。 +下面这些就是迁移会写进 `params`、但配置文档没有解释的名字。 +要改数值,改这些名字。 +每个名字的全部可选取值见 [MAA 集成文档][task-types]。 + +### 是否执行 + +`enable` 为 `false` 时,这个任务不执行。 +图形界面里关掉的任务仍会写进文件,并带上 `enable = false`。 +要让它跑,把该字段改成 `true`,或删掉它。 + +### 理智作战参数 + +- `medicine` 是理智药个数。配置文档只在用户输入的例子里出现过这个名字,没有说明它是理智药。 +- `stone` 是源石个数。 +- `times` 是次数上限。 +- `series` 是连战次数。 +- `drops` 是目标掉落。 +- `medicine_expire_days` 是使用多少天内将过期的理智药。配置文档示例里的 `expiring_medicine` 是另一个名字,迁移不会写成那个名字。 +- `DrGrandet` 为 `true` 时,在使用源石的确认界面等到回复 1 点理智,再立刻使用。 + +### 公开招募 + +- `times` 是招募次数。 +- `refresh` 表示是否刷新三星标签。 +- `extra_tags_mode` 是额外标签的处理方式。数字的含义见集成文档。 +- `select` 和 `confirm` 是要自动选择、自动确认的星级,例如 `3`、`4`、`5`。 +- `recruitment_time` 是各星级的招募分钟数。 +- `first_tags` 是优先标签。 +- `preserve_tags` 是要保留的标签。 +- `level3_recruitment_permit_reserve` 是三星招募许可要保留的数量。这项没开,或数量不大于 0 时,不写这个名字。 + +图形界面里的立即刷新不会写入。 +它开着时,摘要里会有一行跳过的字段,并警告。 +开了自动确认六星时,命令会警告。 + +### 基建换班参数 + +- `drones` 是无人机用途。 +- `threshold` 是宿舍心情阈值,范围是 0 到 1。图形界面里的百分比会换成这个小数。 +- `replenish` 表示贸易站的源石碎片是否自动补货。 +- `dorm_notstationed_enabled` 表示宿舍是否启用「未进驻」。 +- `reception_message_board`、`reception_clue_exchange` 和 `reception_send_clue` 是会客室的留言板、线索交流和送出线索。 +- `continue_training` 表示训练室是否继续训练。 +- `fiammetta_targets` 是菲亚梅塔要换心情的干员名单。 +- `fiammetta_recovery_enabled` 表示是否用菲亚梅塔恢复心情。 +- `use_pinus_sylvestris`、`use_perception_information`、`use_worldly_plight` 和 `use_abyssal_hunter` 是红松、感知、尘世和深海这四组跨设施组合。为 `false` 时不写。 + +`fiammetta_targets` 只在 `mode` 为 0 且 `fiammetta_recovery_enabled` 为 true 时生效。 +`fiammetta_recovery_enabled` 和上面四组组合只在 `mode` 为 0 时生效。 + +### 信用商店 + +- `shopping` 表示是否购买信用商品。 +- `credit_fight` 表示是否进行信用作战。 +- `formation_index` 是信用作战使用的编队。 +- `visit_friends` 表示是否访问好友。 +- `buy_first` 是优先购买的商品。 +- `blacklist` 是不购买的商品。 +- `force_shopping_if_credit_full` 表示信用满了是否仍按黑名单以外的商品购买。 +- `only_buy_discount` 表示是否只买折扣商品。 +- `reserve_max_credit` 表示是否把信用留到上限附近。 + +信用作战和访问好友的上次时间、今天是否还能做,是图形界面自己的记录。 +这些不写入结果。 + +### 领奖 + +- `award` 是领取每日或每周任务奖励。 +- `mail` 是领取邮件奖励。 +- `recruit` 是领取限定池赠送的每日免费单抽。 +- `orundum` 是领取幸运墙的合成玉。 +- `mining` 是领取限时开采许可的合成玉。 +- `specialaccess` 是领取五周年赠送的月卡。 +- `signinevent` 是领取限时签到奖励。 + +### 肉鸽 + +这些配置未经实机验证。 +相关参数的作用见 [MAA 集成文档][task-types]。 + +这些名字只在对应的主题和模式里有用。 +写进去是为了留下你在图形界面里选过的内容。 +MaaCore 在用不到的模式下会忽略它们。 + +- `theme` 是主题。 +- `mode` 是模式编号。刷分是 `0`,刷源石锭是 `1`,凹开局是 `4`,坍缩范式是 `5`,月度小队是 `6`,深入调查是 `7`,刷常乐节点是 `20001`,黑流树海刷开局是 `30003`。认不出的模式不写 `mode`,并记入摘要。 +- `squad` 是开局分队。`roles` 是开局职业组合。 +- `core_char` 是第一名开局干员。`core_char_list` 是全部开局干员,每项有 `name` 和 `use_support`。 +- `use_support` 表示第一名是否使用助战。`use_nonfriend_support` 表示是否使用非好友助战。 +- `starts_count` 是开始次数。`difficulty` 是难度。难度如果是一个占位用的极大数字,则不写。 +- `investment_enabled` 表示是否投资。`investments_count` 是投资次数。`investment_with_more_score` 表示是否用更高分数投资。`stop_when_investment_full` 表示存满后是否停止。 +- `stop_at_final_boss` 表示是否在结局前停止。`stop_at_max_level` 表示等级满了是否停止。 +- `start_with_elite_two` 和 `only_start_with_elite_two` 是精二开局。 +- `refresh_trader_with_dice` 表示是否用骰子刷新商店。 +- `collectible_mode_squad` 是凹开局或黑流树海准备局使用的分队。`collectible_mode_shopping` 表示凹开局是否购物。`collectible_mode_start_list` 是希望碰到的开局奖励,例如 `hot_water`。 +- `monthly_squad_auto_iterate` 表示月度小队是否自动切换。`monthly_squad_check_comms` 表示切换时是否看通信。 +- `deep_exploration_auto_iterate` 表示深入调查是否自动切换。 +- `find_playTime_target` 是常乐节点。`1` 是令,`2` 是黍,`3` 是年。 +- `blackflow_strategy` 是黑流树海的结局策略,例如 `ending1`。`blackflow_cultivation_target` 是要培育的襁褓动物。`blackflow_start_animals` 是准备局要凑齐的襁褓动物。 +- `expected_collapsal_paradigms` 是希望碰到的坍缩范式。 +- `first_floor_foldartal` 和 `start_foldartal_list` 是密文板。 +- `start_with_seed` 是固定种子。 + +黑流树海的开局奖励位组合,以及快速飞行,没有写入。 +图形界面里这两项不是关闭或「无」时,摘要里会各有一行跳过的字段。 + +### 生息演算 + +这些配置未经实机验证。 +相关参数的作用见 [MAA 集成文档][task-types]。 + +- `theme` 是主题。 +- `mode` 为 `0` 是不存档的繁荣,为 `1` 是写入存档的繁荣。认不出的模式不写,并记入摘要。 +- `tools_to_craft` 是要制作的工具。 +- `increment_mode` 是增量模式。 +- `num_craft_batches` 是每轮制作批数。 + +仓库识别和干员 box 不会写成任务。 +不认识的任务也不会写成任务。 +这两类只出现在摘要里。 -- GUI 的 `ForceRefresh` **不是** MaaCore 的 `expedite`,maa-cli 也不支持该功能。迁移时不会写出对应参数;若 GUI 中为开启状态,会记入跳过字段并警告,该设置无效。 -- 若 `Level6Choose = true`,会警告即将自动确认 6 星干员,这是危险行为。 - -### 基建换班(Infrast) - -普通模式(`Normal` / `Rotation`)会迁移设施顺序、无人机用途、宿舍阈值等常见参数。 -自定义基建计划(`Mode = Custom` 或填写了 `Filename`)会尝试读取排班 JSON: - -- 读到 `plans` 且各班有 `period`、GUI 为时间轮换(`PlanSelect = -1`)时,写出 `mode = 10000`、`filename`,以及按时段(跨天时加上 `DayMod`)选择 `plan_index` 的变体。 -- 读到排班但没有可用时段(或 GUI 选了固定班次)时,写出自定义模式并带上 `plan_index`。 -- `Filename` 里的 `~` 会展开为用户主目录。相对路径按输入 JSON 所在目录解析。写出的 `filename` 是展开后的绝对路径。 -- 示例输入旁的 `一图流-153-一天两换-MAA.json` 是一份只含时段和菲亚梅塔的排班,供上面的 `gui.new.json` 引用。 -- `FiammettaTarget1/2/3` 写成 `fiammetta_targets`。`FiammettaRecoveryEnabled` 为 true 时写成 `fiammetta_recovery_enabled`。集成文档规定这两项只在 `mode = 0` 时生效;自定义排班里的菲亚梅塔仍由排班文件自己描述,MaaCore 读取该文件。 -- 可用 `--custom-schedule ` 覆盖 GUI 中的排班文件路径;仅当 GUI 已是自定义排班时有效,否则报错退出。覆盖后若文件读失败,也会报错退出,而不会回退到默认基建。 -- 未指定 `--custom-schedule` 时,文件不存在、无法解析或没有 `plans` 会跳过 `Mode` / `Filename`,按默认基建模式(`mode = 0`)写出其余参数,并在迁移摘要中说明。 - -### 其他任务 +## 迁移摘要 -- `Mall`:信用商店、信用作战、访问好友、黑白名单等会映射到对应参数。 -- `Award`:日常奖励、邮件、源石锭等;GUI 的 `FreeGacha` 对应 `recruit`,`SignInEvent` 对应 `signinevent`。 -- `Roguelike` / `Reclamation`:主题、模式与协议里有对应项的参数会迁移,包括黑流树海的开局模式、培育目标和结局策略。无法识别的模式或取值会记入跳过摘要。 -- 较新的 GUI 用 `StartingOpers` 代替 `CoreChar` / `UseSupport` 时,整份列表写成 `core_char_list`,第一项同时保留为 `core_char` 与 `use_support`。 -- `UserDataUpdate`(仓库识别、干员 box)没有对应的 maa-cli 任务,整项跳过。 -- 未识别的 `$type` 会整项跳过,并出现在迁移摘要的「跳过任务」列表中。 +有跳过或关掉的任务时,摘要写到标准错误。 +没有任何损失时,什么都不打印。 +命令打印的是英文。 +下面三项不一定同时出现。 + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` -各任务类型的完整参数含义请参考 [MAA 集成文档][task-types]。迁移后若要继续用条件变体精细控制执行逻辑,请参阅 [任务条件](config.md#任务条件)。 +第一行表示这次转换丢掉了一部分内容。 -## 迁移摘要 +`Skipped tasks` 是整项没有写进文件的任务。 +引号里是图形界面里该任务的名字。 +`UserDataUpdateTask` 是仓库识别和干员 box。 -迁移结束时,若存在有损处理,会在 stderr 打印摘要,例如: +`Disabled tasks` 是已经写进文件、但默认不执行的任务。 +括号里的 `params.enable=false` 就是上一节的 `enable`。 +引号里是任务名。 +`FightTask` 是作战,`RecruitTask` 是公开招募,`InfrastTask` 是基建。 -- 跳过的任务(不支持的 `$type`) -- 已禁用但仍写出的任务(`params.enable = false`) -- 跳过的字段(无法映射或 maa-cli 不支持) +`Skipped fields` 是某个任务里没写进去的一项设置。 +引号里是任务名。 +冒号后面是图形界面导出文件里的原字段名。 +你不需要认识这个名字。 +它只表示这项设置被丢掉了。 +公开招募打开了立即刷新时,就会看到上面最后一行。 -请根据摘要检查输出文件,确认行为符合预期后再用于日常任务。 +先看摘要,再把输出文件用于日常任务。 ## 参考 - [配置](config.md) - [使用](usage.md) -- [示例:WPF 迁移][wpf-example] -- [MAA 集成文档:任务类型][task-types] +- [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/docs/zh-TW/intro.md b/crates/maa-cli/docs/zh-TW/intro.md index 921815d2..102867a6 100644 --- a/crates/maa-cli/docs/zh-TW/intro.md +++ b/crates/maa-cli/docs/zh-TW/intro.md @@ -17,6 +17,7 @@ This page is outdated and maybe still in Simplified Chinese. Translation is need - [安装及编译](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-TW/wpf.md b/crates/maa-cli/docs/zh-TW/wpf.md new file mode 100644 index 00000000..d54d2b3d --- /dev/null +++ b/crates/maa-cli/docs/zh-TW/wpf.md @@ -0,0 +1,292 @@ +# 從 WPF GUI 遷移設定 + +`maa migrate wpf` 把 MAA Windows 圖形介面匯出的 JSON 轉成 maa-cli 任務檔。 +不支援的任務和設定會被跳過。 +結束時,命令把摘要寫到標準錯誤。 +轉完後對照 [設定](config.md) 檢查一遍,再用於日常任務。 + +## 遷移一份設定 + +輸入是圖形介面匯出的 JSON 檔。 + +```bash +# 預設寫到與輸入同名的 .toml +maa migrate wpf gui.json + +# 副檔名決定格式。可以是 toml、yaml 或 json +maa migrate wpf gui.json tasks/daily.toml + +# 匯出裡有多份設定時,用名字選一份 +maa migrate wpf gui.json --profile-name Default + +# 圖形介面裡已經是自訂排班時,改用你給出的排班檔 +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +匯出裡只有一份設定時,命令直接用它。 +有多份,並且沒有 `--profile-name` 時,命令會讓你選。 + +`--custom-schedule` 只在圖形介面裡這份基建已經是自訂排班時有效。 +還不是自訂排班時,加上這個選項會報錯退出。 + +把生成的檔案放到 `$MAA_CONFIG_DIR/tasks`,再用 `maa run ` 執行。 +設定目錄用 `maa dir config` 查看。 + +## 設定文件裡已經有的設定 + +輸出和 [自訂任務](config.md#自定义任务) 的結構相同。 +頂層是 `[[tasks]]`。 +每個任務有 `type`、`name` 和 `params`。 +需要按條件切換時還有 `variants`。 +條件的寫法見 [任務條件](config.md#任务条件)。 + +下面只說明設定文件裡已經出現過的名字。 +設定文件沒寫、執行時直接交給 MaaCore 的名字,在下一節。 + +### 開始喚醒 + +`client_type` 和 `start_game_enabled` 就是設定文件啟動示例裡的那兩個。 +圖形介面沒有可識別的客戶端時,`client_type` 會變成執行時再選的輸入。 +寫法見設定文件的使用者輸入。 +開了切換帳號但沒有帳號名時,這項不寫入。 +摘要裡會有一行跳過的欄位。 + +### 理智作戰 + +每個要打的關卡是一個變體。 +關卡名寫在 `stage`。 + +條件按關卡種類寫。 + +- 資源本或晶片本用 `Weekday`,時區是官服。 +- 活動關能在本機熱更新的活動表裡查到時,用 `DateTime`。裡面有開始時間、結束時間和時區。查不到則用 `Always`,並告警。 +- 主線、剿滅和 `LS-6` 這類常駐關用 `Always`。 + +`DateTime`、`Weekday` 和 `DayMod` 在設定文件的任務條件裡。 +`Always` 也是 maa-cli 的條件,表示不看時間,這個變體總是可以執行。 +活動關的 `start` 和 `end` 取自遷移當時本機的活動表。 +活動換檔後不會自動更新。 +需要重新遷移,或手改條件。 +圖形介面裡還有一份每週計畫時,週計畫和關卡條件用 `And` 合在一起。 + +下面是多個備選關卡時的結果。 + +```toml +[[tasks]] +name = "理智作戰" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +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" } +``` + +如果結果裡會使用源石,命令會警告。 +這個設定可能消耗源石。 +理智藥、源石、次數和過期藥的參數名不在設定文件裡,見下一節。 + +### 基建換班 + +設施順序寫在 `facility`。 +宿舍是否填入信賴未滿的幹員寫在 `dorm_trust_enabled`。 +這兩個名字在設定文件的基建例子裡。 + +圖形介面按時間自動換班,並且排班裡每個班次都有時段時,`mode` 是 `10000`。 +變體按時間段選擇 `plan_index`。 +跨天時段會加上 `DayMod`。 +這和設定文件裡的基建例子是同一套。 +排班沒有可用時段,或圖形介面選了固定班次時,結果仍是自訂基建,並帶上 `plan_index`。 + +寫出的 `filename` 是絕對路徑。 +排班路徑裡的 `~` 展開成你的主目錄。 +相對路徑按你傳給命令的那份 JSON 所在目錄解析。 +設定文件說明,這份排班 JSON 由 MaaCore 讀取,不由 maa-cli 讀取。 +排班檔裡的菲亞梅塔也留在那個檔案裡,不抄進任務參數。 + +要換成另一份排班,用 `--custom-schedule` 給出那份 JSON 的路徑。 +圖形介面還不是自訂排班時,命令報錯退出。 +指定的檔案讀失敗時也報錯退出,不會退回預設基建。 + +沒有這個選項,並且排班不存在、無法解析或沒有班次時,`mode` 寫成 `0`。 +摘要裡會說明跳過了排班。 + +無人機、心情閾值、菲亞梅塔名單這些名字不在設定文件裡,見下一節。 + +## 交給 MaaCore 的參數 + +[設定](config.md#基本结构) 寫明,任務 `params` 裡的具體名字以 MAA 整合文件為準。 +maa-cli 不檢查這些名字對不對。 +執行時由 MaaCore 讀取。 +下面這些就是遷移會寫進 `params`、但設定文件沒有解釋的名字。 +要改數值,改這些名字。 +每個名字的全部可選取值見 [MAA 整合文件][task-types]。 + +### 是否執行 + +`enable` 為 `false` 時,這個任務不執行。 +圖形介面裡關掉的任務仍會寫進檔案,並帶上 `enable = false`。 +要讓它跑,把該欄位改成 `true`,或刪掉它。 + +### 理智作戰參數 + +- `medicine` 是理智藥個數。設定文件只在使用者輸入的例子裡出現過這個名字,沒有說明它是理智藥。 +- `stone` 是源石個數。 +- `times` 是次數上限。 +- `series` 是連戰次數。 +- `drops` 是目標掉落。 +- `medicine_expire_days` 是使用多少天內將過期的理智藥。設定文件示例裡的 `expiring_medicine` 是另一個名字,遷移不會寫成那個名字。 +- `DrGrandet` 為 `true` 時,在使用源石的確認介面等到回復 1 點理智,再立刻使用。 + +### 公開招募 + +- `times` 是招募次數。 +- `refresh` 表示是否重新整理三星標籤。 +- `extra_tags_mode` 是額外標籤的處理方式。數字的含義見整合文件。 +- `select` 和 `confirm` 是要自動選擇、自動確認的星級,例如 `3`、`4`、`5`。 +- `recruitment_time` 是各星級的招募分鐘數。 +- `first_tags` 是優先標籤。 +- `preserve_tags` 是要保留的標籤。 +- `level3_recruitment_permit_reserve` 是三星招募許可要保留的數量。這項沒開,或數量不大於 0 時,不寫這個名字。 + +圖形介面裡的立即重新整理不會寫入。 +它開著時,摘要裡會有一行跳過的欄位,並警告。 +開了自動確認六星時,命令會警告。 + +### 基建換班參數 + +- `drones` 是無人機用途。 +- `threshold` 是宿舍心情閾值,範圍是 0 到 1。圖形介面裡的百分比會換成這個小數。 +- `replenish` 表示貿易站的源石碎片是否自動補貨。 +- `dorm_notstationed_enabled` 表示宿舍是否啟用「未進駐」。 +- `reception_message_board`、`reception_clue_exchange` 和 `reception_send_clue` 是會客室的留言板、線索交流和送出線索。 +- `continue_training` 表示訓練室是否繼續訓練。 +- `fiammetta_targets` 是菲亞梅塔要換心情的幹員名單。 +- `fiammetta_recovery_enabled` 表示是否用菲亞梅塔恢復心情。 +- `use_pinus_sylvestris`、`use_perception_information`、`use_worldly_plight` 和 `use_abyssal_hunter` 是紅松、感知、塵世和深海這四組跨設施組合。為 `false` 時不寫。 + +`fiammetta_targets` 只在 `mode` 為 0 且 `fiammetta_recovery_enabled` 為 true 時生效。 +`fiammetta_recovery_enabled` 和上面四組組合只在 `mode` 為 0 時生效。 + +### 信用商店 + +- `shopping` 表示是否購買信用商品。 +- `credit_fight` 表示是否進行信用作戰。 +- `formation_index` 是信用作戰使用的編隊。 +- `visit_friends` 表示是否訪問好友。 +- `buy_first` 是優先購買的商品。 +- `blacklist` 是不購買的商品。 +- `force_shopping_if_credit_full` 表示信用滿了是否仍按黑名單以外的商品購買。 +- `only_buy_discount` 表示是否只買折扣商品。 +- `reserve_max_credit` 表示是否把信用留到上限附近。 + +信用作戰和訪問好友的上次時間、今天是否還能做,是圖形介面自己的記錄。 +這些不寫入結果。 + +### 領獎 + +- `award` 是領取每日或每週任務獎勵。 +- `mail` 是領取郵件獎勵。 +- `recruit` 是領取限定池贈送的每日免費單抽。 +- `orundum` 是領取幸運牆的合成玉。 +- `mining` 是領取限時開採許可的合成玉。 +- `specialaccess` 是領取五週年贈送的月卡。 +- `signinevent` 是領取限時簽到獎勵。 + +### 肉鴿 + +這些設定未經實機驗證。 +相關參數的作用見 [MAA 整合文件][task-types]。 + +這些名字只在對應的主題和模式裡有用。 +寫進去是為了留下你在圖形介面裡選過的內容。 +MaaCore 在用不到的模式下會忽略它們。 + +- `theme` 是主題。 +- `mode` 是模式編號。刷分是 `0`,刷源石錠是 `1`,凹開局是 `4`,坍縮範式是 `5`,月度小隊是 `6`,深入調查是 `7`,刷常樂節點是 `20001`,黑流樹海刷開局是 `30003`。認不出的模式不寫 `mode`,並記入摘要。 +- `squad` 是開局分隊。`roles` 是開局職業組合。 +- `core_char` 是第一名開局幹員。`core_char_list` 是全部開局幹員,每項有 `name` 和 `use_support`。 +- `use_support` 表示第一名是否使用助戰。`use_nonfriend_support` 表示是否使用非好友助戰。 +- `starts_count` 是開始次數。`difficulty` 是難度。難度如果是一個佔位用的極大數字,則不寫。 +- `investment_enabled` 表示是否投資。`investments_count` 是投資次數。`investment_with_more_score` 表示是否用更高分數投資。`stop_when_investment_full` 表示存滿後是否停止。 +- `stop_at_final_boss` 表示是否在結局前停止。`stop_at_max_level` 表示等級滿了是否停止。 +- `start_with_elite_two` 和 `only_start_with_elite_two` 是精二開局。 +- `refresh_trader_with_dice` 表示是否用骰子重新整理商店。 +- `collectible_mode_squad` 是凹開局或黑流樹海準備局使用的分隊。`collectible_mode_shopping` 表示凹開局是否購物。`collectible_mode_start_list` 是希望碰到的開局獎勵,例如 `hot_water`。 +- `monthly_squad_auto_iterate` 表示月度小隊是否自動切換。`monthly_squad_check_comms` 表示切換時是否看通信。 +- `deep_exploration_auto_iterate` 表示深入調查是否自動切換。 +- `find_playTime_target` 是常樂節點。`1` 是令,`2` 是黍,`3` 是年。 +- `blackflow_strategy` 是黑流樹海的結局策略,例如 `ending1`。`blackflow_cultivation_target` 是要培育的襁褓動物。`blackflow_start_animals` 是準備局要湊齊的襁褓動物。 +- `expected_collapsal_paradigms` 是希望碰到的坍縮範式。 +- `first_floor_foldartal` 和 `start_foldartal_list` 是密文板。 +- `start_with_seed` 是固定種子。 + +黑流樹海的開局獎勵位組合,以及快速飛行,沒有寫入。 +圖形介面裡這兩項不是關閉或「無」時,摘要裡會各有一行跳過的欄位。 + +### 生息演算 + +這些設定未經實機驗證。 +相關參數的作用見 [MAA 整合文件][task-types]。 + +- `theme` 是主題。 +- `mode` 為 `0` 是不存檔的繁榮,為 `1` 是寫入存檔的繁榮。認不出的模式不寫,並記入摘要。 +- `tools_to_craft` 是要製作的工具。 +- `increment_mode` 是增量模式。 +- `num_craft_batches` 是每輪製作批數。 + +倉庫識別和幹員 box 不會寫成任務。 +不認識的任務也不會寫成任務。 +這兩類只出現在摘要裡。 + +## 遷移摘要 + +有跳過或關掉的任務時,摘要寫到標準錯誤。 +沒有任何損失時,什麼都不列印。 +命令列印的是英文。 +下面三項不一定同時出現。 + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +第一行表示這次轉換丟掉了一部分內容。 + +`Skipped tasks` 是整項沒有寫進檔案的任務。 +引號裡是圖形介面裡該任務的名字。 +`UserDataUpdateTask` 是倉庫識別和幹員 box。 + +`Disabled tasks` 是已經寫進檔案、但預設不執行的任務。 +括號裡的 `params.enable=false` 就是上一節的 `enable`。 +引號裡是任務名。 +`FightTask` 是作戰,`RecruitTask` 是公開招募,`InfrastTask` 是基建。 + +`Skipped fields` 是某個任務裡沒寫進去的一項設定。 +引號裡是任務名。 +冒號後面是圖形介面匯出檔裡的原欄位名。 +你不需要認識這個名字。 +它只表示這項設定被丟掉了。 +公開招募打開了立即重新整理時,就會看到上面最後一行。 + +先看摘要,再把輸出檔用於日常任務。 + +## 參考 + +- [設定](config.md) +- [使用](usage.md) +- [MAA 整合文件裡的任務類型][task-types] + +[task-types]: https://maa.plus/docs/zh-tw/protocol/integration.html