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/config_examples/wpf/gui.new.json b/crates/maa-cli/config_examples/wpf/gui.new.json new file mode 100644 index 00000000..bb9f7d6b --- /dev/null +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -0,0 +1,622 @@ +{ + "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": [ + "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", + "LS-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": "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": "Custom", + "UsesOfDrones": "Money", + "DormThreshold": 30, + "DormTrustEnabled": true, + "OriginiumShardAutoReplenishment": true, + "DormFilterNotStationed": true, + "ReceptionMessageBoard": true, + "ReceptionClueExchange": true, + "SendClue": true, + "ContinueTraining": true, + "CustomFileType": "user_defined", + "Filename": "~/一图流-153-一天两换-MAA.json", + "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": "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": 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": 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": "ExitArknights, 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": 2 + } + }, + "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.5", + "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": 442, + "Top": 229, + "Right": 1882, + "Bottom": 1329 + } + }, + "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..21fdf35c --- /dev/null +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -0,0 +1,190 @@ +[[tasks]] +type = "StartUp" + +[tasks.params] +client_type = "Official" +start_game_enabled = true + +[[tasks]] +name = "剿灭" +type = "Fight" + +[tasks.params] +series = 0 +stage = "LungmenDowntown@Annihilation" + +[[tasks]] +name = "理智作战" +type = "Fight" + +[tasks.params] +medicine_expire_days = 2 +series = 0 + +[[tasks.variants]] + +[tasks.variants.condition] +end = "2026-08-22T03:59:59" +timezone = 8 +type = "DateTime" + +[tasks.variants.params] +series = 0 +stage = "TO-5" + +[[tasks.variants]] + +[tasks.variants.condition] +timezone = "Official" +type = "Weekday" +weekdays = [ + "Tue", + "Thu", + "Sat", + "Sun", +] + +[tasks.variants.params] +series = 0 +stage = "CE-6" + +[[tasks.variants]] + +[tasks.variants.condition] +type = "Always" + +[tasks.variants.params] +series = 0 +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" + +[tasks.params] +continue_training = true +dorm_notstationed_enabled = true +dorm_trust_enabled = true +facility = [ + "Mfg", + "Trade", + "Control", + "Power", + "Reception", + "Office", + "Dorm", + "Processing", + "Training", +] +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" + +[tasks.params] +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" + +[tasks.params] +award = true +mail = true +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/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/wpf.md b/crates/maa-cli/docs/zh-CN/wpf.md new file mode 100644 index 00000000..44ceffe3 --- /dev/null +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -0,0 +1,138 @@ +# 从 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 + +# 多配置导出时,用 --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 +``` + +如果导出文件里只有一个配置,会自动选择该配置;有多个配置且未指定 `--profile-name` 时,会交互式提示你选择。 + +`--custom-schedule` 只在 GUI 基建任务已经是自定义排班(`Mode = Custom` 或填写了 `Filename`)时生效,用来覆盖排班文件路径。若 GUI 不是自定义排班,传入该选项会报错退出。 + +迁移完成后,把生成的任务文件放到 `$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` +- 活动关卡:若能在热更新资源 `StageActivityV2.json` 中查到对应活动,则写入 `DateTime`(使用该活动的截止时间与时区);查不到则回退为 `Always` 并告警 + +例如 GUI 中同时启用过期理智药与多关卡备选时,迁移结果类似: + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[tasks.params] +medicine_expire_days = 2 + +[[tasks.variants]] +condition = { type = "DateTime", 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" } +``` + +这里的 `medicine_expire_days` 对应 GUI 的 `MedicineExpireDays`(在 `UseExpiringMedicine = true` 时写入),表示使用多少天内将过期的理智药。 +活动关卡的 `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` + +以下行为需要特别注意: + +- 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` 里的 `~` 会展开为用户主目录;写出的 `filename` 是展开后的绝对路径。 +- 可用 `--custom-schedule ` 覆盖 GUI 中的排班文件路径;仅当 GUI 已是自定义排班时有效,否则报错退出。覆盖后若文件读失败,也会报错退出,而不会回退到默认基建。 +- 未指定 `--custom-schedule` 时,文件不存在、无法解析或没有 `plans` 会跳过 `Mode` / `Filename`,按默认基建模式(`mode = 0`)写出其余参数,并在迁移摘要中说明。 + +### 其他任务 + +- `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/activity.rs b/crates/maa-cli/src/activity.rs index ad20d389..a35fc814 100644 --- a/crates/maa-cli/src/activity.rs +++ b/crates/maa-cli/src/activity.rs @@ -19,6 +19,31 @@ pub fn has_side_story_open(client: ClientType) -> bool { .unwrap_or(false) } +/// Load a side-story stage schedule directly from disk (bypasses the process cache). +/// +/// 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<()> { if let Some(stage_activity) = STAGE_ACTIVITY.as_ref() { stage_activity.display(std::io::stdout(), client)?; @@ -116,6 +141,33 @@ impl StageActivityContent { .values() .any(|activity| activity.activity.is_active()) } + + /// Look up the open window for a fight stage code. + /// + /// Prefers `Value` when present, otherwise falls back to `Display`. + /// 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() { + let matched = activity.stages.iter().any(|s| { + let code = if s.value.is_empty() { + s.display.as_str() + } else { + s.value.as_str() + }; + 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, + }); + } + best + } } #[cfg_attr(test, derive(Debug, PartialEq, Clone))] @@ -147,7 +199,8 @@ impl ActivityInfo { #[serde(rename_all = "PascalCase")] struct StageInfo { display: String, - // value: String, + #[serde(default)] + value: String, drop: String, } @@ -207,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 { @@ -578,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 0ddbd80e..a4e85515 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, @@ -189,6 +191,14 @@ pub(crate) enum Command { #[arg(short, long)] format: 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)] @@ -246,6 +256,31 @@ 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 `--profile-name` to select one. + /// 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 + input: PathBuf, + /// 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 = "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` + /// or a non-empty `Filename`). Otherwise the command exits with an error. + #[arg(long, value_name = "JSON")] + custom_schedule: Option, + }, +} + #[cfg(feature = "cli_installer")] #[derive(Subcommand)] #[command(name = "self")] @@ -581,6 +616,56 @@ mod test { ); } + #[test] + fn migrate() { + assert_matches!( + parse_from(["maa", "migrate", "wpf", "profile.json"]).command, + Command::Migrate(MigrateCommand::Wpf { + input, + output: None, + profile_name: None, + custom_schedule: None, + }) if input == Path::new("profile.json") + ); + + assert_matches!( + parse_from([ + "maa", + "migrate", + "wpf", + "profile.json", + "tasks.toml", + "--profile-name", + "Default" + ]) + .command, + Command::Migrate(MigrateCommand::Wpf { + input, + output: Some(output), + profile_name: 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] fn activity() { assert_matches!(parse_from(["maa", "activity"]).command, Command::Activity { 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..e59384dc --- /dev/null +++ b/crates/maa-cli/src/config/migrate/mod.rs @@ -0,0 +1,145 @@ +//! 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. + +use std::fmt; + +mod wpf; + +pub(crate) use wpf::wpf; + +/// 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`, written with `params.enable = false`. + 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() + } + + pub(super) fn skip_task(&mut self, type_tag: impl Into, name: Option) { + self.skipped_tasks.push(SkippedTask { + type_tag: type_tag.into(), + name, + }); + } + + pub(super) fn disable_task(&mut self, type_tag: impl Into, name: Option) { + self.disabled_tasks.push(SkippedTask { + type_tag: type_tag.into(), + name, + }); + } + + pub(super) 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(), + }); + } +} + +impl fmt::Display for MigrationSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_empty() { + return Ok(()); + } + + writeln!(f, "Migration summary (lossy):")?; + + if !self.skipped_tasks.is_empty() { + writeln!(f, " Skipped tasks (unsupported type):")?; + for task in &self.skipped_tasks { + writeln!(f, " - {}", format_task_ref(task))?; + } + } + + if !self.disabled_tasks.is_empty() { + writeln!( + f, + " Disabled tasks (kept with params.enable=false; will not run):" + )?; + for task in &self.disabled_tasks { + writeln!(f, " - {}", format_task_ref(task))?; + } + } + + if !self.skipped_fields.is_empty() { + writeln!(f, " Skipped fields (not migrated):")?; + for field in &self.skipped_fields { + match &field.task_name { + Some(name) if !name.is_empty() => { + writeln!(f, " - {} \"{name}\": {}", field.task_type, field.field)?; + } + _ => { + writeln!(f, " - {}: {}", field.task_type, field.field)?; + } + } + } + } + + Ok(()) + } +} + +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(), + } +} + +#[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/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..b7d99f40 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/fight.rs @@ -0,0 +1,786 @@ +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}" + ); + } + + #[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/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..961fa357 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/mall.rs @@ -0,0 +1,228 @@ +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), + }, + }) + } +} + +#[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 new file mode 100644 index 00000000..1a89c3f6 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/mod.rs @@ -0,0 +1,491 @@ +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}" + ); + } + + #[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 new file mode 100644 index 00000000..3a44939c --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/reclamation.rs @@ -0,0 +1,236 @@ +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), + }, + }) + } +} + +#[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 new file mode 100644 index 00000000..f0530d42 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/recruit.rs @@ -0,0 +1,331 @@ +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), + }, + }) + } +} + +#[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 new file mode 100644 index 00000000..3f125d1e --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs @@ -0,0 +1,310 @@ +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), + }, + }) + } +} + +#[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 new file mode 100644 index 00000000..60f2c6e7 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/start_up.rs @@ -0,0 +1,247 @@ +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), + }, + }) + } +} + +#[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)) + ); + } +} diff --git a/crates/maa-cli/src/config/mod.rs b/crates/maa-cli/src/config/mod.rs index 29c89c83..7a63f514 100644 --- a/crates/maa-cli/src/config/mod.rs +++ b/crates/maa-cli/src/config/mod.rs @@ -173,6 +173,8 @@ pub fn convert(file: &Path, out: Option<&Path>, ft: Option) -> Result< pub mod import; +pub mod migrate; + pub mod asst; pub mod cli; diff --git a/crates/maa-cli/src/config/task/client_type.rs b/crates/maa-cli/src/config/task/client_type.rs index 99799fa1..bbcdfea5 100644 --- a/crates/maa-cli/src/config/task/client_type.rs +++ b/crates/maa-cli/src/config/task/client_type.rs @@ -1,9 +1,8 @@ use clap::ValueEnum; -use serde::Deserialize; +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] @@ -38,6 +37,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 +127,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..1fd1a202 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, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq))] #[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 }, } -#[cfg_attr(test, derive(PartialEq, Debug))] -#[derive(Clone, Copy, Default, Deserialize)] +fn is_zero_u32(value: &u32) -> bool { + *value == 0 +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq))] #[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; diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 89becacf..0181fe38 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -108,6 +108,17 @@ fn main() -> Result<()> { output, format, } => config::convert(&input, output.as_deref(), format)?, + Command::Migrate(command::MigrateCommand::Wpf { + input, + output, + profile_name, + custom_schedule, + }) => config::migrate::wpf( + &input, + output.as_deref(), + profile_name, + 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};