diff --git a/Cargo.toml b/Cargo.toml index e7168b0e..85148328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,7 +78,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..6cb017ab --- /dev/null +++ b/crates/maa-cli/config_examples/wpf/gui.new.json @@ -0,0 +1,948 @@ +{ + "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": false, + "AnnihilationStage": "Chernobog@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": [ + "YW-8", + "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": false, + "PreserveTagList": [ + "支援机械" + ], + "PreserveTagEnabled": false, + "RefreshLevel3": true, + "ForceRefresh": true, + "Level3Choose": true, + "Level3RecruitmentPermitReserveEnabled": false, + "Level3RecruitmentPermitReserve": 8, + "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, + "FiammettaTarget1": "清流", + "FiammettaTarget2": "可露希尔", + "FiammettaTarget3": "但书", + "FiammettaRecoveryEnabled": false, + "UsePinusSylvestris": false, + "UsePerceptionInformation": false, + "UseWorldlyPlight": false, + "UseAbyssalHunter": false, + "UseWeeklySchedule": false, + "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, + "SignInEvent": false, + "Name": "", + "IsEnable": true, + "TaskType": "Award" + }, + { + "$type": "UserDataUpdateTask", + "UpdateOperBox": true, + "UpdateDepot": true, + "TriggerInterval": "EveryTime", + "IsTriggered": true, + "Name": "", + "IsEnable": true, + "TaskType": "UserDataUpdate" + } + ], + "Gui": { + "ConnectSettings": { + "AutoDetect": false, + "AlwaysAutoDetect": false, + "Config": "MuMuEmulator12", + "AdbPath": "adb/platform-tools/adb.exe", + "AdbReplaced": true, + "Address": "127.0.0.1:16384", + "AddressHistory": [ + "127.0.0.1:16384" + ], + "Extras": { + "LDPlayer": { + "IsEnabled": false, + "EmulatorPath": "", + "ManualSetIndex": false, + "InstanceIndex": 0 + }, + "MuMuEmulator12": { + "IsEnabled": true, + "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", + "EnableBridgeConnection": false, + "InstanceIndex": 0 + }, + "Win32Extra": { + "ScreencapMethod": "FramePool", + "MouseMethod": "PostMessageWithCursorPos", + "KeyboardMethod": "SendMessage", + "MuteWhileRunning": false + }, + "BluestacksExtra": { + "ConfigKeyword": "", + "ConfigPath": "" + } + }, + "AllowAdbRestart": true, + "AllowAdbHardRestart": true, + "TouchMode": "MaaTouch", + "EnableAdbLite": false, + "KillAdbOnExit": false + }, + "Notification": { + "EnableStallTimeout": true, + "StallTimeoutMinutes": 25, + "ReminderIntervalMinutes": 30 + }, + "ExternalNotification": { + "Configs": [], + "Delivery": { + "Enable": false, + "SendWhenComplete": true, + "SendWhenError": true, + "SendWhenStalled": false, + "SendBeforeScheduledStart": false, + "SendAfterLogCount": false, + "NewLogCount": 10, + "SendWhenContentMatches": false, + "Whitelist": "", + "IncludePreviousLogs": false, + "MaxEntries": 2, + "TimeMinutes": 60, + "FilterPreviousLogs": false, + "Blacklist": "" + } + }, + "RemoteControl": { + "RemoteControlGetTaskEndpointUri": "", + "RemoteControlReportStatusUri": "", + "RemoteControlUserIdentity": "", + "RemoteControlDeviceIdentity": "", + "RemoteControlPollIntervalMs": 1000 + }, + "RuntimeSettings": { + "ClientType": "Official", + "StartGame": true, + "DeployWithPause": false, + "RoguelikeDelayAbortUntilCombatComplete": false, + "PreRunScript": "", + "PostRunScript": "", + "ExecuteScriptOnCopilot": false, + "ExecuteScriptOnManualStop": false, + "BlockSleep": false, + "BlockSleepWithScreenOn": true, + "EnableRunDurationLimit": false, + "RunDurationLimitMinutes": 240, + "RunDurationLimitExecutePostActions": true + }, + "ThirdParty": { + "ReportToPenguin": true, + "PenguinId": "", + "ReportToYituliu": true, + "YituliuOpenApiToken": "", + "OperBoxUseYituliuApi": false, + "EnableOperBoxAssist": false + }, + "StartUpSettings": { + "RunDirectly": false, + "SkipStartupAutoRunAfterUpdate": true, + "StartEmulator": false, + "RestartEmulatorWhenAdbFailed": false, + "EmulatorPath": "", + "EmulatorAddCommand": "", + "EmulatorWaitSeconds": 60, + "StartPcClient": false, + "RestartPcClientWhenConnectFailed": false, + "PcClientPath": "", + "PcClientAddCommand": "", + "PcClientWaitSeconds": 60 + }, + "WindowTitlePrefix": "", + "PostActions": "None", + "AchievementPopupDisabled": false, + "AchievementPopupAutoClose": false + }, + "Toolbox": { + "OperBoxExportFormat": "Json", + "GachaShowDisclaimerNoMore": false, + "PeepTargetFps": 20, + "ChooseLevel3": true, + "ChooseLevel3Time": 540, + "ChooseLevel4": true, + "ChooseLevel4Time": 540, + "ChooseLevel5": true, + "ChooseLevel6": true, + "AutoSetTime": true, + "ShowPotential": true, + "OperBoxAvatarMode": true, + "EventShopBlackListDataSupplementInstrument": false, + "EventShopBlackListDataSupplementStick": false, + "EventShopBlackListLmd": false, + "EventShopBlackListFurniturePart": false, + "EventShopBlackListOther": true, + "EventShopBlackList": "" + }, + "Copilot": { + "TaskList": [ + { + "Name": "YW-EX-8", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\YW-EX-8_1792346947472.json", + "CopilotId": 106975, + "IsRaid": true, + "IsChecked": true, + "Index": 0 + } + ], + "EnableUserAdditional": false, + "UserAdditional": [], + "SupportMode": "WhenNeeded", + "UseFormation": false, + "SelectFormation": 1, + "LoopTimes": 1 + }, + "TaskSelectedIndex": 2 + }, + "肉鸽": { + "TaskQueue": [ + { + "$type": "StartUpTask", + "AccountName": "", + "AccountSwitchEnabled": false, + "Name": "", + "IsEnable": false, + "TaskType": "StartUp" + }, + { + "$type": "RoguelikeTask", + "Theme": "BlackFlow", + "Difficulty": 5, + "Mode": "Investment", + "Squad": "特勤分队", + "SquadCollectible": "指挥分队", + "Roles": "稳扎稳打", + "StartCount": 99999, + "StartingOpers": [ + { + "Name": "机械师", + "UseSupport": false + } + ], + "UseAdditionalStartingOpers": false, + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "BlackFlowCultivationTarget": "Cat", + "BlackFlowEnding": "Ending1", + "BlackFlowFastFlight": false, + "BlackFlowStartAnimal1": "None", + "BlackFlowStartAnimal2": "None", + "BlackFlowStartRewards": "None", + "BlackFlowStartRunFastFlight": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "黑流树海-刷源石锭", + "IsEnable": true, + "TaskType": "Roguelike" + }, + { + "$type": "RoguelikeTask", + "Theme": "BlackFlow", + "Difficulty": 2147483647, + "Mode": "BlackFlowStartRun", + "Squad": "堡垒战术分队", + "SquadCollectible": "指挥分队", + "Roles": "坚不可摧", + "StartCount": 99999, + "StartingOpers": [ + { + "Name": "机械师", + "UseSupport": false + } + ], + "UseAdditionalStartingOpers": false, + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "CollectibleStartAwards": "HotWater", + "CollectibleShopping": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + "ExpectedCollapsalParadigms": "", + "MonthlySquadAutoIterate": true, + "MonthlySquadCheckComms": true, + "DeepExplorationAutoIterate": true, + "FindPlaytimeTarget": "Ling", + "BlackFlowCultivationTarget": "Cerberus", + "BlackFlowEnding": "Ending1", + "BlackFlowFastFlight": false, + "BlackFlowStartAnimal1": "Cerberus", + "BlackFlowStartAnimal2": "None", + "BlackFlowStartRewards": "None", + "BlackFlowStartRunFastFlight": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "SquadIsFoldartal": false, + "SamiFirstFloorFoldartal": false, + "SamiFirstFloorFoldartals": "", + "SamiNewSquad2StartingFoldartal": false, + "SamiNewSquad2StartingFoldartals": "", + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "StartWithSeed": false, + "Seed": "", + "Name": "黑流树海-刷襁褓", + "IsEnable": true, + "TaskType": "Roguelike" + }, + { + "$type": "UserDataUpdateTask", + "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": { + "IsEnabled": false, + "EmulatorPath": "", + "ManualSetIndex": false, + "InstanceIndex": 0 + }, + "MuMuEmulator12": { + "IsEnabled": true, + "EmulatorPath": "C:\\Program Files\\NetEase\\MuMuPlayer", + "EnableBridgeConnection": false, + "InstanceIndex": 0 + }, + "Win32Extra": { + "ScreencapMethod": "FramePool", + "MouseMethod": "PostMessageWithCursorPos", + "KeyboardMethod": "SendMessage", + "MuteWhileRunning": false + }, + "BluestacksExtra": { + "ConfigKeyword": "", + "ConfigPath": "" + } + }, + "AllowAdbRestart": true, + "AllowAdbHardRestart": true, + "TouchMode": "MumuExtras", + "EnableAdbLite": false, + "KillAdbOnExit": false + }, + "Notification": { + "EnableStallTimeout": true, + "StallTimeoutMinutes": 25, + "ReminderIntervalMinutes": 30 + }, + "ExternalNotification": { + "Configs": [], + "Delivery": { + "Enable": false, + "SendWhenComplete": true, + "SendWhenError": true, + "SendWhenStalled": false, + "SendBeforeScheduledStart": false, + "SendAfterLogCount": false, + "NewLogCount": 10, + "SendWhenContentMatches": false, + "Whitelist": "", + "IncludePreviousLogs": false, + "MaxEntries": 2, + "TimeMinutes": 60, + "FilterPreviousLogs": false, + "Blacklist": "" + } + }, + "RemoteControl": { + "RemoteControlGetTaskEndpointUri": "", + "RemoteControlReportStatusUri": "", + "RemoteControlUserIdentity": "", + "RemoteControlDeviceIdentity": "", + "RemoteControlPollIntervalMs": 1000 + }, + "RuntimeSettings": { + "ClientType": "Official", + "StartGame": true, + "DeployWithPause": false, + "RoguelikeDelayAbortUntilCombatComplete": false, + "PreRunScript": "", + "PostRunScript": "", + "ExecuteScriptOnCopilot": false, + "ExecuteScriptOnManualStop": false, + "BlockSleep": false, + "BlockSleepWithScreenOn": true, + "EnableRunDurationLimit": false, + "RunDurationLimitMinutes": 240, + "RunDurationLimitExecutePostActions": true + }, + "ThirdParty": { + "ReportToPenguin": true, + "PenguinId": "", + "ReportToYituliu": true, + "YituliuOpenApiToken": "", + "OperBoxUseYituliuApi": false, + "EnableOperBoxAssist": false + }, + "StartUpSettings": { + "RunDirectly": false, + "SkipStartupAutoRunAfterUpdate": true, + "StartEmulator": false, + "RestartEmulatorWhenAdbFailed": false, + "EmulatorPath": "", + "EmulatorAddCommand": "", + "EmulatorWaitSeconds": 60, + "StartPcClient": false, + "RestartPcClientWhenConnectFailed": false, + "PcClientPath": "", + "PcClientAddCommand": "", + "PcClientWaitSeconds": 60 + }, + "WindowTitlePrefix": "", + "PostActions": "None", + "AchievementPopupDisabled": false, + "AchievementPopupAutoClose": false + }, + "Toolbox": { + "OperBoxExportFormat": "Json", + "GachaShowDisclaimerNoMore": false, + "PeepTargetFps": 20, + "ChooseLevel3": true, + "ChooseLevel3Time": 540, + "ChooseLevel4": true, + "ChooseLevel4Time": 540, + "ChooseLevel5": true, + "ChooseLevel6": true, + "AutoSetTime": true, + "ShowPotential": true, + "OperBoxAvatarMode": false, + "EventShopBlackListDataSupplementInstrument": false, + "EventShopBlackListDataSupplementStick": false, + "EventShopBlackListLmd": false, + "EventShopBlackListFurniturePart": false, + "EventShopBlackListOther": true, + "EventShopBlackList": "" + }, + "Copilot": { + "TaskList": [ + { + "Name": "TO-EX-1", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-1.json", + "CopilotId": 99410, + "IsRaid": false, + "IsChecked": false, + "Index": 0 + }, + { + "Name": "TO-EX-1", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-1.json", + "CopilotId": 99410, + "IsRaid": true, + "IsChecked": false, + "Index": 1 + }, + { + "Name": "TO-EX-2", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-2.json", + "CopilotId": 99425, + "IsRaid": false, + "IsChecked": true, + "Index": 2 + }, + { + "Name": "TO-EX-2", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-2.json", + "CopilotId": 99425, + "IsRaid": true, + "IsChecked": true, + "Index": 3 + }, + { + "Name": "TO-EX-3", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-3.json", + "CopilotId": 99448, + "IsRaid": false, + "IsChecked": true, + "Index": 4 + }, + { + "Name": "TO-EX-3", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-3.json", + "CopilotId": 99448, + "IsRaid": true, + "IsChecked": true, + "Index": 5 + }, + { + "Name": "TO-EX-4", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-4.json", + "CopilotId": 99485, + "IsRaid": false, + "IsChecked": true, + "Index": 6 + }, + { + "Name": "TO-EX-4", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-4.json", + "CopilotId": 99485, + "IsRaid": true, + "IsChecked": true, + "Index": 7 + }, + { + "Name": "TO-EX-5", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-5.json", + "CopilotId": 99487, + "IsRaid": false, + "IsChecked": true, + "Index": 8 + }, + { + "Name": "TO-EX-5", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-5.json", + "CopilotId": 99487, + "IsRaid": true, + "IsChecked": true, + "Index": 9 + }, + { + "Name": "TO-EX-6", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-6.json", + "CopilotId": 99491, + "IsRaid": false, + "IsChecked": true, + "Index": 10 + }, + { + "Name": "TO-EX-6", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-6.json", + "CopilotId": 99491, + "IsRaid": true, + "IsChecked": true, + "Index": 11 + }, + { + "Name": "TO-EX-7", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-7.json", + "CopilotId": 99505, + "IsRaid": false, + "IsChecked": true, + "Index": 12 + }, + { + "Name": "TO-EX-7", + "IsNavNameOverride": false, + "FilePath": "config\\copilot\\TO-EX-7.json", + "CopilotId": 99505, + "IsRaid": true, + "IsChecked": true, + "Index": 13 + } + ], + "EnableUserAdditional": false, + "UserAdditional": [], + "SupportMode": "Random", + "UseFormation": false, + "SelectFormation": 1, + "LoopTimes": 1 + }, + "TaskSelectedIndex": 2 + } + }, + "Timers": { + "ForceScheduledStart": false, + "ShowWindowBeforeForceScheduledStart": false, + "CustomConfig": false, + "NotifyBeforeScheduledStart": false, + "ScheduledStartNotificationMinutes": 5, + "ScheduledWakeUp": 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": "肉鸽", + "Update": { + "Name": "v6.20.1", + "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, + "HideHoverActionButtons": 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": -2916, + "Top": 397, + "Right": -1476, + "Bottom": 1497 + } + }, + "LoadWindowPlacement": true, + "SaveWindowPlacement": true, + "AddTaskMenuSeenVersion": "", + "InverseClearMode": "Clear", + "TransitionSpeed": "Normal", + "TaskQueueInverseMode": false, + "UseCardLog": true, + "MaxNumberOfLogThumbnails": 100, + "SoberLanguage": "zh-cn", + "Hangover": false, + "LastBuyWineTime": "2026-06-08T00:00:00+08:00", + "CustomCulture": "", + "OverlayTarget": { + "ProcessId": 0, + "Hwnd": 0 + }, + "SettingOrders": [ + "SwitchConfiguration", + "ScheduleSettings", + "PerformanceSettings", + "GameSettings", + "ConnectionSettings", + "StartupSettings", + "RemoteControlSettings", + "UiSettings", + "NotificationSettings", + "HotKeySettings", + "UpdateSettings", + "AchievementSettings", + "IssueReport", + "AboutUs", + "ThirdPartyServiceSettings" + ], + "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 + }, + "Performance": { + "UseGpu": true, + "GpuDescription": "", + "GpuInstancePath": "", + "AllowDeprecatedGpu": false, + "IgnoreBadModulesAndUseSoftwareRendering": false + }, + "BackgroundMonetEnabled": false, + "BackgroundMonetMode": "Auto", + "BackgroundMonetCustomColor": "#326CF3", + "BackgroundMonetCachedColor": "", + "BackgroundMonetKeepMaskNeutral": false + } +} \ No newline at end of file diff --git a/crates/maa-cli/config_examples/wpf/gui.new.toml b/crates/maa-cli/config_examples/wpf/gui.new.toml new file mode 100644 index 00000000..1a1d3b4e --- /dev/null +++ b/crates/maa-cli/config_examples/wpf/gui.new.toml @@ -0,0 +1,158 @@ +[[tasks]] +type = "StartUp" + +[tasks.params] +client_type = "Official" +start_game_enabled = true + +[[tasks]] +name = "剿灭" +type = "Fight" + +[tasks.params] +series = 0 +stage = "Annihilation" + +[[tasks]] +name = "理智作战" +type = "Fight" + +[tasks.params] +medicine_expire_days = 2 +series = 0 + +[[tasks.variants]] + +[tasks.variants.condition] +end = "2026-10-23T03:59:59" +start = "2026-10-09T12:00:00" +timezone = 8 +type = "DateTime" + +[tasks.variants.params] +series = 0 +stage = "YW-8" + +[[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 = "一图流-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 diff --git "a/crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" "b/crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" new file mode 100644 index 00000000..99c6ea82 --- /dev/null +++ "b/crates/maa-cli/config_examples/wpf/\344\270\200\345\233\276\346\265\201-153-\344\270\200\345\244\251\344\270\244\346\215\242-MAA.json" @@ -0,0 +1,38 @@ +{ + "title": "一图流排班表-153两换", + "description": "示例排班,只保留时段和菲亚梅塔,供 maa migrate wpf 读取。", + "plans": [ + { + "name": "B班", + "period": [ + [ + "09:00", + "21:00" + ] + ], + "Fiammetta": { + "enable": true, + "target": "但书", + "order": "pre" + } + }, + { + "name": "A班", + "period": [ + [ + "21:00", + "23:59" + ], + [ + "00:00", + "09:00" + ] + ], + "Fiammetta": { + "enable": true, + "target": "但书", + "order": "pre" + } + } + ] +} diff --git a/crates/maa-cli/docs/en-US/intro.md b/crates/maa-cli/docs/en-US/intro.md index b58687d8..a0ffb0b9 100644 --- a/crates/maa-cli/docs/en-US/intro.md +++ b/crates/maa-cli/docs/en-US/intro.md @@ -13,6 +13,7 @@ A simple CLI for [MAA][maa-home] written in Rust. - [Installation and Compilation](install.md) - [Usage](usage.md) - [Configuration](config.md) +- [Migrate a WPF GUI profile](wpf.md) - [FAQ](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights diff --git a/crates/maa-cli/docs/en-US/wpf.md b/crates/maa-cli/docs/en-US/wpf.md new file mode 100644 index 00000000..82c7c243 --- /dev/null +++ b/crates/maa-cli/docs/en-US/wpf.md @@ -0,0 +1,291 @@ +# Migrate a WPF GUI profile + +`maa migrate wpf` turns a JSON export from the MAA Windows GUI into a maa-cli task file. +Unsupported tasks and settings are skipped. +When the command finishes, it writes a summary to stderr. +Check the result against [Configuration](config.md) before you use it for daily runs. + +## Migrate one profile + +The input is the JSON file exported from the GUI. + +```bash +# Write a .toml file with the same name as the input +maa migrate wpf gui.json + +# The extension picks the format: toml, yaml, or json +maa migrate wpf gui.json tasks/daily.toml + +# Pick one profile by name when the export contains several +maa migrate wpf gui.json --profile-name Default + +# Use your schedule file when the GUI profile already uses a custom base schedule +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +If the export contains one profile, the command uses it. +If it contains several and you omit `--profile-name`, the command asks you to choose. + +`--custom-schedule` works only when that infrast task is already a custom schedule in the GUI. +If it is not, the option makes the command exit with an error. + +Put the generated file in `$MAA_CONFIG_DIR/tasks`, then run it with `maa run `. +Find the config directory with `maa dir config`. + +## Settings already described in the configuration docs + +The output has the same shape as a [custom task](config.md#custom-tasks). +The top level is `[[tasks]]`. +Each task has `type`, `name`, and `params`. +It also has `variants` when a condition selects among stages or shifts. +Condition syntax is in [task variants and conditions](config.md#task-variants-and-conditions). + +The names below are ones the configuration docs already use. +Names the configuration docs do not explain, which MaaCore reads at run time, are in the next section. + +### Start up + +`client_type` and `start_game_enabled` are the two fields in the start-up example in the configuration docs. +If the GUI has no recognized client, `client_type` becomes a prompt you answer when the task runs. +That prompt is the user-input form in the configuration docs. +If account switching is on and the account name is empty, that setting is not written. +The summary then has one skipped field. + +### Fight + +Each stage is a variant. +The stage name is `stage`. + +The condition follows the stage kind. + +- A resource or chip stage uses `Weekday` in the official server timezone. +- An event stage uses `DateTime` when this machine's hot-update event table has that event. The condition has a start, an end, and a timezone. Otherwise it uses `Always`, and the command warns. +- A permanent stage, such as a main story stage, annihilation, or `LS-6`, uses `Always`. + +`DateTime`, `Weekday`, and `DayMod` are in the configuration docs. +`Always` is also a maa-cli condition. It ignores the clock, so the variant can always run. +`start` and `end` for an event stage come from the event table on this machine at migration time. +They do not follow a later hot update. +Migrate again, or edit the condition, after the event changes. +When the GUI also has a weekly plan, that plan and the stage condition are combined with `And`. + +The example below is a fight task with several stage choices. + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +params = { stage = "TO-5" } + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { stage = "LS-6" } +``` + +If the result will spend Originite Prime, the command warns. +Medicine, Originite Prime, times, and expiring medicine use names that are not in the configuration docs. Those names are in the next section. + +### Base rotation + +Facility order is `facility`. +Whether the dorm fills operators whose trust is not full is `dorm_trust_enabled`. +Both names are in the infrast example in the configuration docs. + +When the GUI rotates shifts by time of day, and every shift in the schedule has a period, `mode` is `10000`. +Variants pick `plan_index` by time of day. +A shift that crosses midnight also gets `DayMod`. +That is the same arrangement as the infrast example in the configuration docs. +When the schedule has no usable period, or the GUI selects a fixed shift, the result stays a custom base plan and includes `plan_index`. + +The written `filename` is absolute. +`~` in the schedule path expands to your home directory. +A relative path is resolved from the directory that contains the JSON you passed to the command. +The configuration docs say MaaCore reads that schedule JSON, not maa-cli. +Fiammetta inside the schedule file stays in that file. It is not copied into the task parameters. + +To use a different schedule, pass its JSON path with `--custom-schedule`. +If the GUI profile is not already a custom schedule, the command exits with an error. +If that file cannot be read, the command also exits with an error. It does not fall back to the default base plan. + +Without that option, a missing file, a file that cannot be parsed, or a file with no shifts writes `mode` as `0`. +The summary says the schedule was skipped. + +Drone use, the mood threshold, and the Fiammetta list use names that are not in the configuration docs. Those names are in the next section. + +## Parameters passed to MaaCore + +The [basic structure](config.md#basic-structure) says the concrete names inside task `params` follow the MAA integration document. +maa-cli does not check those names. +MaaCore reads them when the task runs. +The names below are written into `params` and are not explained in the configuration docs. +Edit these names when you change a value. +Allowed values for each name are in the [MAA integration document][task-types]. + +### Whether the task runs + +When `enable` is `false`, the task does not run. +A task that is off in the GUI is still written, with `enable = false`. +To run it, set that field to `true` or delete it. + +### Fight parameters + +- `medicine` is how many sanity potions to use. The configuration docs use this name only in a user-input example, and do not say that it is sanity medicine. +- `stone` is how many Originite Prime to use. +- `times` is the battle limit. +- `series` is the number of battles in a row. +- `drops` is the target drop. +- `medicine_expire_days` is how many days of expiring sanity medicine to use. The configuration docs show a different name, `expiring_medicine`. Migration does not write that name. +- When `DrGrandet` is `true`, the task waits on the Originite confirmation until 1 sanity is restored, then spends the Originite. + +### Recruitment + +- `times` is how many times to recruit. +- `refresh` is whether to refresh a 3-star tag set. +- `extra_tags_mode` is how extra tags are handled. The meaning of the number is in the integration document. +- `select` and `confirm` are the star levels to select and confirm, such as `3`, `4`, and `5`. +- `recruitment_time` is the recruitment minutes for each star level. +- `first_tags` is the preferred tags. +- `preserve_tags` is the tags to keep. +- `level3_recruitment_permit_reserve` is how many 3-star recruitment permits to keep. The name is omitted when that switch is off or the count is not greater than 0. + +Immediate refresh from the GUI is not written. +If it is on, the summary has one skipped field and a warning. +If automatic confirmation of a 6-star operator is on, the command warns. + +### Base rotation parameters + +- `drones` is what drones are used for. +- `threshold` is the dorm mood threshold, from 0 to 1. A percentage in the GUI becomes this fraction. +- `replenish` is whether the trading post restocks Originium Shard. +- `dorm_notstationed_enabled` is whether the dorm uses the "not stationed" option. +- `reception_message_board`, `reception_clue_exchange`, and `reception_send_clue` are the reception room message board, clue exchange, and sending clues. +- `continue_training` is whether the training room keeps training. +- `fiammetta_targets` is the operators Fiammetta should restore mood for. +- `fiammetta_recovery_enabled` is whether to use Fiammetta to restore mood. +- `use_pinus_sylvestris`, `use_perception_information`, `use_worldly_plight`, and `use_abyssal_hunter` are the Pinus Sylvestris, Perception Information, Worldly Plight, and Abyssal Hunter facility sets. A value of `false` is not written. + +`fiammetta_targets` applies only when `mode` is 0 and `fiammetta_recovery_enabled` is true. +`fiammetta_recovery_enabled` and those four facility sets apply only when `mode` is 0. + +### Credit store + +- `shopping` is whether to buy from the credit store. +- `credit_fight` is whether to run the credit fight. +- `formation_index` is the squad used for the credit fight. +- `visit_friends` is whether to visit friends. +- `buy_first` is the items to buy first. +- `blacklist` is the items not to buy. +- `force_shopping_if_credit_full` is whether to buy items outside the blacklist when credit is full. +- `only_buy_discount` is whether to buy only discounted items. +- `reserve_max_credit` is whether to keep credit near the cap. + +Last-run times, and whether the credit fight or a friend visit can still be done today, are records kept by the GUI. +They are not written. + +### Awards + +- `award` collects daily or weekly mission rewards. +- `mail` collects mail rewards. +- `recruit` collects the daily free pull from a limited banner. +- `orundum` collects Orundum from the lucky wall. +- `mining` collects Orundum from a limited mining permit. +- `specialaccess` collects the monthly card given for the 5th anniversary. +- `signinevent` collects a limited-time sign-in reward. + +### Roguelike + +These settings have not been verified on a real device. +What each parameter does is in the [MAA integration document][task-types]. + +These names matter only for the matching theme and mode. +They are written so a choice you made in the GUI is not dropped. +MaaCore ignores them in a mode that does not use them. + +- `theme` is the theme. +- `mode` is the mode number. Farming score is `0`. Farming Originite Ingot is `1`. Fishing for a start reward is `4`. Collapsal paradigms are `5`. Monthly squad is `6`. Deep exploration is `7`. Finding a Playtime node is `20001`. A BlackFlow start run is `30003`. An unrecognized mode omits `mode` and is recorded in the summary. +- `squad` is the starting squad. `roles` is the starting role set. +- `core_char` is the first starting operator. `core_char_list` is every starting operator. Each item has `name` and `use_support`. +- `use_support` is whether the first operator is a support operator. `use_nonfriend_support` is whether a non-friend support operator is allowed. +- `starts_count` is how many runs to start. `difficulty` is the difficulty. A placeholder that is a very large number is not written. +- `investment_enabled` is whether to invest. `investments_count` is how many investments to make. `investment_with_more_score` is whether to invest for a higher score. `stop_when_investment_full` is whether to stop when the deposit is full. +- `stop_at_final_boss` is whether to stop before the ending. `stop_at_max_level` is whether to stop at max level. +- `start_with_elite_two` and `only_start_with_elite_two` are elite-2 starts. +- `refresh_trader_with_dice` is whether to refresh the trader with dice. +- `collectible_mode_squad` is the squad used while fishing for a start, or for the preparation run in BlackFlow. `collectible_mode_shopping` is whether that run shops. `collectible_mode_start_list` is the start reward to look for, such as `hot_water`. +- `monthly_squad_auto_iterate` is whether the monthly squad switches automatically. `monthly_squad_check_comms` is whether that switch also looks at communications. +- `deep_exploration_auto_iterate` is whether deep exploration switches automatically. +- `find_playTime_target` is the Playtime node. `1` is Ling, `2` is Shu, and `3` is Nian. +- `blackflow_strategy` is the BlackFlow ending strategy, such as `ending1`. `blackflow_cultivation_target` is the swaddled animal to raise. `blackflow_start_animals` is the swaddled animals the preparation run must collect. +- `expected_collapsal_paradigms` is the collapsal paradigms to look for. +- `first_floor_foldartal` and `start_foldartal_list` are foldartal. +- `start_with_seed` is a fixed seed. + +The BlackFlow start-reward bitmask and fast flight are not written. +If either one is on in the GUI, and it is not left unset, the summary has a skipped field for it. + +### Reclamation + +These settings have not been verified on a real device. +What each parameter does is in the [MAA integration document][task-types]. + +- `theme` is the theme. +- `mode` `0` is prosperity without saving. `mode` `1` is prosperity written into the save. An unrecognized mode is omitted and recorded in the summary. +- `tools_to_craft` is the tools to craft. +- `increment_mode` is the increment mode. +- `num_craft_batches` is how many craft batches to run each round. + +Depot recognition and the operator box are not written as tasks. +An unknown task is not written either. +Both appear only in the summary. + +## Summary + +When something is skipped or turned off, the summary goes to stderr. +When nothing was lost, the command prints nothing. +The text is English. +The three groups below do not all have to appear. + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +The first line means this conversion dropped something. + +`Skipped tasks` are tasks that were not written at all. +The text in quotes is the task name from the GUI. +`UserDataUpdateTask` is depot recognition and the operator box. + +`Disabled tasks` were written, but they do not run. +`params.enable=false` in the parentheses is the `enable` field from the previous section. +The text in quotes is the task name. +`FightTask` is a fight, `RecruitTask` is recruitment, and `InfrastTask` is base rotation. + +`Skipped fields` is one setting inside a task that was not written. +The text in quotes is the task name. +The word after the colon is the original field name from the exported file. +You do not need to know that name. +It only means that setting was dropped. +The last line above is what you see when recruitment has immediate refresh turned on. + +Read the summary before you use the output for daily runs. + +## See also + +- [Configuration](config.md) +- [Usage](usage.md) +- [Task types in the MAA integration document][task-types] + +[task-types]: https://maa.plus/docs/en-us/protocol/integration.html diff --git a/crates/maa-cli/docs/ja-JP/intro.md b/crates/maa-cli/docs/ja-JP/intro.md index 921815d2..dae52cdc 100644 --- a/crates/maa-cli/docs/ja-JP/intro.md +++ b/crates/maa-cli/docs/ja-JP/intro.md @@ -17,6 +17,7 @@ This page is outdated and maybe still in Simplified Chinese. Translation is need - [安装及编译](install.md) - [使用](usage.md) - [配置](config.md) +- [WPF GUI から設定を移す](wpf.md) - [常见问题](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights/ diff --git a/crates/maa-cli/docs/ja-JP/wpf.md b/crates/maa-cli/docs/ja-JP/wpf.md new file mode 100644 index 00000000..9ca51dee --- /dev/null +++ b/crates/maa-cli/docs/ja-JP/wpf.md @@ -0,0 +1,292 @@ +# WPF GUI から設定を移す + +`maa migrate wpf` は、MAA の Windows GUI が書き出した JSON を maa-cli のタスクファイルに変換します。 +対応していないタスクと設定は飛ばします。 +終了時、コマンドは要約を標準エラーへ書きます。 +日常の実行に使う前に、結果を [設定](config.md) と照合してください。 + +## 設定を 1 つ移す + +入力は GUI が書き出した JSON ファイルです。 + +```bash +# 入力と同じ名前の .toml を書く +maa migrate wpf gui.json + +# 拡張子が形式を決める。toml、yaml、json のいずれか +maa migrate wpf gui.json tasks/daily.toml + +# 書き出しに設定が複数あるときは、名前で 1 つ選ぶ +maa migrate wpf gui.json --profile-name Default + +# GUI がすでにカスタム基建スケジュールのとき、指定したスケジュールファイルを使う +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +書き出しに設定が 1 つだけのとき、コマンドはそれを使います。 +複数あり、`--profile-name` がないとき、コマンドは選択を尋ねます。 + +`--custom-schedule` は、その基建が GUI 上ですでにカスタムスケジュールのときだけ有効です。 +まだカスタムスケジュールでないとき、このオプションを付けるとコマンドはエラーで終了します。 + +生成したファイルを `$MAA_CONFIG_DIR/tasks` に置き、`maa run ` で実行します。 +設定ディレクトリは `maa dir config` で確認します。 + +## 設定ドキュメントに既にある項目 + +出力の形は [カスタムタスク](config.md#自定义任务) と同じです。 +最上段は `[[tasks]]` です。 +各タスクには `type`、`name`、`params` があります。 +条件で切り替えるときは `variants` もあります。 +条件の書き方は [タスク条件](config.md#任务条件) を参照してください。 + +ここから先で挙げる名前は、設定ドキュメントに既にあるものだけです。 +設定ドキュメントに無く、実行時に MaaCore が読む名前は次の節です。 + +### 起動 + +`client_type` と `start_game_enabled` は、設定ドキュメントの起動例にある 2 つの項目です。 +GUI に判別できるクライアントがないとき、`client_type` は実行時に選ぶ入力になります。 +書き方は設定ドキュメントのユーザー入力を参照してください。 +アカウント切り替えが有効でアカウント名が空のとき、その項目は書き込まれません。 +要約に、飛ばしたフィールドが 1 行出ます。 + +### 理性作戦 + +打つステージはそれぞれ 1 つのバリアントです。 +ステージ名は `stage` です。 + +条件はステージの種類に従います。 + +- 資源ステージまたはチップステージは、公式サーバーのタイムゾーンで `Weekday` になります。 +- イベントステージは、このマシンのホットアップデートにあるイベント表にそのイベントがあるとき `DateTime` になります。開始時刻、終了時刻、タイムゾーンが入ります。無いときは `Always` になり、警告が出ます。 +- メインストーリー、殲滅、`LS-6` のような常設ステージは `Always` です。 + +`DateTime`、`Weekday`、`DayMod` は設定ドキュメントのタスク条件にあります。 +`Always` も maa-cli の条件です。時刻を見ないので、そのバリアントは常に実行できます。 +イベントステージの `start` と `end` は、移行した時点のこのマシン上のイベント表から取ります。 +その後のホットアップデートには追従しません。 +イベントが変わったら、再度移行するか、条件を手で直します。 +GUI に週間予定もあるとき、週間予定とステージ条件は `And` でまとまります。 + +次の例は、候補ステージが複数あるときの結果です。 + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +params = { stage = "TO-5" } + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { stage = "LS-6" } +``` + +結果が純正源石を使うとき、コマンドは警告します。 +この設定は純正源石を消費することがあります。 +理性薬、純正源石、回数、期限切れ理性薬のパラメータ名は設定ドキュメントにありません。次の節を参照してください。 + +### 基建の交代 + +施設の順序は `facility` です。 +信頼度が最大でないオペレーターを宿舎に入れるかは `dorm_trust_enabled` です。 +この 2 つの名前は、設定ドキュメントの基建の例にあります。 + +GUI が時刻で交代し、スケジュールの各シフトに時間帯があるとき、`mode` は `10000` です。 +バリアントは時間帯で `plan_index` を選びます。 +日付をまたぐ時間帯には `DayMod` が付きます。 +これは設定ドキュメントの基建の例と同じ組み立てです。 +スケジュールに使える時間帯がないとき、または GUI が固定シフトを選んでいるとき、結果はカスタム基建のまま `plan_index` を含みます。 + +書き出される `filename` は絶対パスです。 +スケジュールパスの `~` はホームディレクトリに展開されます。 +相対パスは、コマンドに渡した JSON があるディレクトリから解決されます。 +設定ドキュメントにあるとおり、このスケジュール JSON を読むのは maa-cli ではなく MaaCore です。 +スケジュールファイル内のフィアメッタもそのファイルに残り、タスクパラメータには写しません。 + +別のスケジュールを使うには、`--custom-schedule` でその JSON のパスを渡します。 +GUI がまだカスタムスケジュールでないとき、コマンドはエラーで終了します。 +指定したファイルを読めないときもエラーで終了し、既定の基建には戻りません。 + +このオプションが無く、スケジュールが無い、解析できない、またはシフトが無いとき、`mode` は `0` と書かれます。 +要約には、スケジュールを飛ばしたと出ます。 + +ドローン、気分のしきい値、フィアメッタの名簿は、設定ドキュメントに無い名前です。次の節を参照してください。 + +## MaaCore に渡すパラメータ + +[基本構造](config.md#基本结构) にあるとおり、タスク `params` の具体的な名前は MAA 統合ドキュメントに従います。 +maa-cli はその名前が正しいかを検査しません。 +実行時に MaaCore が読みます。 +以下は、移行が `params` に書くが、設定ドキュメントが説明していない名前です。 +値を変えるときは、これらの名前を変えます。 +各名前の取りうる値は [MAA 統合ドキュメント][task-types] を参照してください。 + +### 実行するかどうか + +`enable` が `false` のとき、そのタスクは実行されません。 +GUI でオフのタスクもファイルには残り、`enable = false` が付きます。 +実行するには、そのフィールドを `true` にするか、削除します。 + +### 理性作戦のパラメータ + +- `medicine` は理性薬の個数です。設定ドキュメントでは、ユーザー入力の例にこの名前が出るだけで、理性薬だとは書いてありません。 +- `stone` は純正源石の個数です。 +- `times` は回数の上限です。 +- `series` は連戦の回数です。 +- `drops` は目標ドロップです。 +- `medicine_expire_days` は、期限まで何日以内の理性薬を使うかです。設定ドキュメントの例にある `expiring_medicine` は別の名前で、移行はそちらを書きません。 +- `DrGrandet` が `true` のとき、純正源石の確認画面で理性が 1 回復するまで待ち、すぐに使用します。 + +### 公開求人 + +- `times` は求人の回数です。 +- `refresh` は星 3 タグを更新するかどうかです。 +- `extra_tags_mode` は追加タグの扱いです。数値の意味は統合ドキュメントを参照してください。 +- `select` と `confirm` は、自動で選び、自動で確定する星の数です。例は `3`、`4`、`5` です。 +- `recruitment_time` は星ごとの求人分数です。 +- `first_tags` は優先タグです。 +- `preserve_tags` は残すタグです。 +- `level3_recruitment_permit_reserve` は残しておく星 3 求人許可証の数です。この項目がオフ、または個数が 0 より大きくないとき、この名前は書きません。 + +GUI の即時更新は書き込まれません。 +オンのとき、要約に飛ばしたフィールドが 1 行出て、警告も出ます。 +星 6 の自動確定がオンのとき、コマンドは警告します。 + +### 基建交代のパラメータ + +- `drones` はドローンの用途です。 +- `threshold` は宿舎の気分しきい値で、範囲は 0 から 1 です。GUI の百分率はこの小数になります。 +- `replenish` は貿易所の源石の欠片を自動補充するかどうかです。 +- `dorm_notstationed_enabled` は宿舎で「未配置」を使うかどうかです。 +- `reception_message_board`、`reception_clue_exchange`、`reception_send_clue` は応接室の伝言板、手がかり交換、手がかりの送付です。 +- `continue_training` は訓練室で訓練を続けるかどうかです。 +- `fiammetta_targets` は、フィアメッタが気分を回復するオペレーターの名簿です。 +- `fiammetta_recovery_enabled` は、フィアメッタで気分を回復するかどうかです。 +- `use_pinus_sylvestris`、`use_perception_information`、`use_worldly_plight`、`use_abyssal_hunter` は、レッドパイン、知覚、塵世、アビサルの 4 つの施設組み合わせです。`false` のときは書きません。 + +`fiammetta_targets` が効くのは、`mode` が 0 かつ `fiammetta_recovery_enabled` が true のときだけです。 +`fiammetta_recovery_enabled` と上の 4 つの組み合わせが効くのは、`mode` が 0 のときだけです。 + +### 信用交換 + +- `shopping` は信用商品を買うかどうかです。 +- `credit_fight` は信用戦闘を行うかどうかです。 +- `formation_index` は信用戦闘で使う編成です。 +- `visit_friends` はフレンドを訪問するかどうかです。 +- `buy_first` は優先して買う商品です。 +- `blacklist` は買わない商品です。 +- `force_shopping_if_credit_full` は、信用が満杯のときブラックリスト以外をまだ買うかどうかです。 +- `only_buy_discount` は割引商品だけ買うかどうかです。 +- `reserve_max_credit` は信用を上限付近まで残すかどうかです。 + +信用戦闘とフレンド訪問の前回時刻、今日まだできるかは、GUI 自身の記録です。 +結果には書きません。 + +### 報酬 + +- `award` はデイリーまたはウィークリー任務の報酬を受け取ります。 +- `mail` はメール報酬を受け取ります。 +- `recruit` は限定ガチャの毎日の無料 1 回を受け取ります。 +- `orundum` はラッキーウォールの合成玉を受け取ります。 +- `mining` は期間限定の採掘許可証の合成玉を受け取ります。 +- `specialaccess` は 5 周年でもらえる月間パスを受け取ります。 +- `signinevent` は期間限定のサインイン報酬を受け取ります。 + +### ローグライク + +これらの設定は実機では確認していません。 +各パラメータの働きは [MAA 統合ドキュメント][task-types] を参照してください。 + +これらの名前が意味を持つのは、対応するテーマとモードだけです。 +GUI で選んだ内容を残すために書き込みます。 +使わないモードでは、MaaCore はそれらを無視します。 + +- `theme` はテーマです。 +- `mode` はモード番号です。スコア周回は `0`、源石錠周回は `1`、開始報酬狙いは `4`、崩壊パラダイムは `5`、月次分隊は `6`、深層調査は `7`、常楽ノード狙いは `20001`、黒流樹海の開始周回は `30003` です。判別できないモードは `mode` を書かず、要約に記録します。 +- `squad` は開始分隊です。`roles` は開始の職分の組み合わせです。 +- `core_char` は最初の開始オペレーターです。`core_char_list` は開始オペレーター全員で、各項目に `name` と `use_support` があります。 +- `use_support` は最初の 1 人をサポートにするかどうかです。`use_nonfriend_support` はフレンド以外のサポートを使うかどうかです。 +- `starts_count` は開始回数です。`difficulty` は難易度です。難易度がプレースホルダの非常に大きい数なら書きません。 +- `investment_enabled` は投資するかどうかです。`investments_count` は投資回数です。`investment_with_more_score` はより高いスコアで投資するかどうかです。`stop_when_investment_full` は預金が満杯なら止めるかどうかです。 +- `stop_at_final_boss` は結末の前で止めるかどうかです。`stop_at_max_level` はレベルが最大なら止めるかどうかです。 +- `start_with_elite_two` と `only_start_with_elite_two` はエリート 2 での開始です。 +- `refresh_trader_with_dice` はサイコロで商店を更新するかどうかです。 +- `collectible_mode_squad` は開始報酬狙い、または黒流樹海の準備局で使う分隊です。`collectible_mode_shopping` は開始報酬狙いで買い物するかどうかです。`collectible_mode_start_list` は欲しい開始報酬で、例は `hot_water` です。 +- `monthly_squad_auto_iterate` は月次分隊を自動で切り替えるかどうかです。`monthly_squad_check_comms` は切り替え時に通信を見るかどうかです。 +- `deep_exploration_auto_iterate` は深層調査を自動で切り替えるかどうかです。 +- `find_playTime_target` は常楽ノードです。`1` はリィン、`2` はシュー、`3` はニェンです。 +- `blackflow_strategy` は黒流樹海の結末方針で、例は `ending1` です。`blackflow_cultivation_target` は育てる襁褓の動物です。`blackflow_start_animals` は準備局で揃える襁褓の動物です。 +- `expected_collapsal_paradigms` は欲しい崩壊パラダイムです。 +- `first_floor_foldartal` と `start_foldartal_list` は密文板です。 +- `start_with_seed` は固定シードです。 + +黒流樹海の開始報酬のビット組み合わせと、快速飛行は書き込まれません。 +GUI でこの 2 つがオフでも「なし」でもないとき、要約に飛ばしたフィールドがそれぞれ 1 行出ます。 + +### 生息演算 + +これらの設定は実機では確認していません。 +各パラメータの働きは [MAA 統合ドキュメント][task-types] を参照してください。 + +- `theme` はテーマです。 +- `mode` が `0` ならセーブしない繁栄、`1` ならセーブへ書く繁栄です。判別できないモードは書かず、要約に記録します。 +- `tools_to_craft` は作る道具です。 +- `increment_mode` は増分モードです。 +- `num_craft_batches` は 1 周あたりの製作バッチ数です。 + +倉庫認識とオペレーター box はタスクになりません。 +未知のタスクもタスクになりません。 +この 2 つは要約にだけ出ます。 + +## 要約 + +飛ばした項目やオフのタスクがあるとき、要約は標準エラーへ書かれます。 +何も失っていないとき、何も印刷しません。 +コマンドが印刷するのは英語です。 +次の 3 項目は、必ず同時に出るわけではありません。 + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +1 行目は、この変換が一部を落としたことを表します。 + +`Skipped tasks` は、ファイルにまったく書かれなかったタスクです。 +引用符の中は、GUI でのそのタスクの名前です。 +`UserDataUpdateTask` は倉庫認識とオペレーター box です。 + +`Disabled tasks` は、ファイルには書いたが、既定では実行しないタスクです。 +括弧内の `params.enable=false` が、前の節の `enable` です。 +引用符の中はタスク名です。 +`FightTask` は作戦、`RecruitTask` は公開求人、`InfrastTask` は基建です。 + +`Skipped fields` は、あるタスクの中で書き込まれなかった設定 1 つです。 +引用符の中はタスク名です。 +コロンの後は、GUI の書き出しファイルにあった元のフィールド名です。 +その名前を知る必要はありません。 +その設定が落とされたことだけを表します。 +公開求人で即時更新がオンのとき、上の最後の行になります。 + +日常の実行に使う前に、要約を読んでください。 + +## 参考 + +- [設定](config.md) +- [使い方](usage.md) +- [MAA 統合ドキュメントのタスクタイプ][task-types] + +[task-types]: https://maa.plus/docs/ja-jp/protocol/integration.html diff --git a/crates/maa-cli/docs/ko-KR/intro.md b/crates/maa-cli/docs/ko-KR/intro.md index e4f9fcae..53ffd238 100644 --- a/crates/maa-cli/docs/ko-KR/intro.md +++ b/crates/maa-cli/docs/ko-KR/intro.md @@ -13,6 +13,7 @@ Rust로 작성된 간단한 [MAA][maa-home] 명령줄 도구입니다. - [설치 및 빌드](install.md) - [사용법](usage.md) - [설정](config.md) +- [WPF GUI 설정 옮기기](wpf.md) - [FAQ](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights/ diff --git a/crates/maa-cli/docs/ko-KR/wpf.md b/crates/maa-cli/docs/ko-KR/wpf.md new file mode 100644 index 00000000..e7988222 --- /dev/null +++ b/crates/maa-cli/docs/ko-KR/wpf.md @@ -0,0 +1,292 @@ +# WPF GUI 설정 옮기기 + +`maa migrate wpf`는 MAA Windows GUI가 내보낸 JSON을 maa-cli 작업 파일로 바꿉니다. +지원하지 않는 작업과 설정은 건너뜁니다. +명령이 끝나면 요약을 표준 오류로 씁니다. +매일 실행에 쓰기 전에 결과를 [설정](config.md)과 맞춰 확인합니다. + +## 설정 하나 옮기기 + +입력은 GUI가 내보낸 JSON 파일입니다. + +```bash +# 입력과 같은 이름의 .toml을 쓴다 +maa migrate wpf gui.json + +# 확장자가 형식을 정한다. toml, yaml, json 중 하나 +maa migrate wpf gui.json tasks/daily.toml + +# 내보내기에 설정이 여러 개이면 이름으로 하나를 고른다 +maa migrate wpf gui.json --profile-name Default + +# GUI에서 이미 사용자 기지 일정이면, 지정한 일정 파일을 쓴다 +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +내보내기에 설정이 하나이면 명령은 그것을 사용합니다. +여러 개이고 `--profile-name`이 없으면 명령이 고르라고 묻습니다. + +`--custom-schedule`은 그 기반 시설이 GUI에서 이미 사용자 일정일 때만 동작합니다. +아직 사용자 일정이 아니면 이 옵션을 주면 명령은 오류로 끝납니다. + +만든 파일을 `$MAA_CONFIG_DIR/tasks`에 두고 `maa run `로 실행합니다. +설정 디렉터리는 `maa dir config`로 확인합니다. + +## 설정 문서에 이미 있는 항목 + +출력 구조는 [사용자 정의 작업](config.md#사용자-정의-작업)과 같습니다. +맨 위는 `[[tasks]]`입니다. +각 작업에는 `type`, `name`, `params`가 있습니다. +조건으로 바꿀 때는 `variants`도 있습니다. +조건 작성은 [작업 조건](config.md#작업-조건)을 봅니다. + +아래에서 말하는 이름은 설정 문서에 이미 있는 이름입니다. +설정 문서에 없고, 실행할 때 MaaCore가 읽는 이름은 다음 절에 있습니다. + +### 시작 + +`client_type`과 `start_game_enabled`는 설정 문서의 시작 예시에 있는 두 항목입니다. +GUI에 알아볼 수 있는 클라이언트가 없으면 `client_type`은 실행 중에 고르는 입력이 됩니다. +작성법은 설정 문서의 사용자 입력을 봅니다. +계정 전환이 켜져 있고 계정 이름이 비어 있으면 그 항목은 쓰지 않습니다. +요약에 건너뛴 필드가 한 줄 나옵니다. + +### 이성 작전 + +칠 스테이지는 각각 변형 하나입니다. +스테이지 이름은 `stage`입니다. + +조건은 스테이지 종류를 따릅니다. + +- 자원이나 칩 스테이지는 오피셜 서버 시간대의 `Weekday`입니다. +- 이벤트 스테이지는 이 기기의 핫업데이트 이벤트 표에 그 이벤트가 있으면 `DateTime`입니다. 시작 시각, 종료 시각, 시간대가 들어갑니다. 없으면 `Always`가 되고 경고가 납니다. +- 메인, 섬멸, `LS-6` 같은 상설 스테이지는 `Always`입니다. + +`DateTime`, `Weekday`, `DayMod`는 설정 문서의 작업 조건에 있습니다. +`Always`도 maa-cli 조건입니다. 시각을 보지 않으므로 그 변형은 항상 실행할 수 있습니다. +이벤트 스테이지의 `start`와 `end`는 옮길 당시 이 기기의 이벤트 표에서 가져옵니다. +이후 핫업데이트에는 따라가지 않습니다. +이벤트가 바뀌면 다시 옮기거나 조건을 직접 고칩니다. +GUI에 주간 계획도 있으면 주간 계획과 스테이지 조건을 `And`로 묶습니다. + +아래는 후보 스테이지가 여러 개일 때의 결과입니다. + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +params = { stage = "TO-5" } + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { stage = "LS-6" } +``` + +결과에 순오리지늄을 쓰면 명령이 경고합니다. +이 설정은 순오리지늄을 쓸 수 있습니다. +이성 물약, 순오리지늄, 횟수, 만료 임박 물약의 매개변수 이름은 설정 문서에 없습니다. 다음 절을 봅니다. + +### 기반 시설 교대 + +시설 순서는 `facility`입니다. +신뢰도가 가득 차지 않은 오퍼레이터를 숙소에 넣을지는 `dorm_trust_enabled`입니다. +이 두 이름은 설정 문서의 기반 시설 예시에 있습니다. + +GUI가 시각으로 교대하고 일정의 각 교대조에 시간대가 있으면 `mode`는 `10000`입니다. +변형은 시간대에 따라 `plan_index`를 고릅니다. +자정을 넘는 시간대에는 `DayMod`가 붙습니다. +이것은 설정 문서의 기반 시설 예시와 같은 구성입니다. +일정에 쓸 수 있는 시간대가 없거나 GUI가 고정 교대조를 고르면, 결과는 사용자 기지로 남고 `plan_index`를 포함합니다. + +써 넣는 `filename`은 절대 경로입니다. +일정 경로의 `~`는 홈 디렉터리로 펼칩니다. +상대 경로는 명령에 넘긴 JSON이 있는 디렉터리에서 해석합니다. +설정 문서에 있듯이 이 일정 JSON은 maa-cli가 아니라 MaaCore가 읽습니다. +일정 파일 안의 피아메타도 그 파일에 남고, 작업 매개변수로 복사하지 않습니다. + +다른 일정을 쓰려면 `--custom-schedule`로 그 JSON 경로를 줍니다. +GUI가 아직 사용자 일정이 아니면 명령은 오류로 끝납니다. +그 파일을 읽지 못해도 오류로 끝나며, 기본 기반 시설로 돌아가지 않습니다. + +이 옵션이 없고, 일정이 없거나, 해석할 수 없거나, 교대조가 없으면 `mode`는 `0`으로 씁니다. +요약에는 일정을 건너뛰었다고 나옵니다. + +드론, 기분 임계값, 피아메타 명단은 설정 문서에 없는 이름입니다. 다음 절을 봅니다. + +## MaaCore에 넘기는 매개변수 + +[기본 구조](config.md#기본-구조)에 있듯이 작업 `params`의 구체적인 이름은 MAA 통합 문서를 따릅니다. +maa-cli는 그 이름이 맞는지 검사하지 않습니다. +실행할 때 MaaCore가 읽습니다. +아래는 옮길 때 `params`에 쓰지만 설정 문서가 설명하지 않는 이름입니다. +값을 바꿀 때는 이 이름을 바꿉니다. +각 이름의 허용 값은 [MAA 통합 문서][task-types]를 봅니다. + +### 실행 여부 + +`enable`이 `false`이면 그 작업은 실행되지 않습니다. +GUI에서 끈 작업도 파일에는 남고 `enable = false`가 붙습니다. +실행하려면 그 필드를 `true`로 바꾸거나 지웁니다. + +### 이성 작전 매개변수 + +- `medicine`은 이성 물약 개수입니다. 설정 문서는 사용자 입력 예시에만 이 이름이 있고, 이성 물약이라고 쓰지 않습니다. +- `stone`은 순오리지늄 개수입니다. +- `times`는 횟수 상한입니다. +- `series`는 연속 전투 횟수입니다. +- `drops`는 목표 드롭입니다. +- `medicine_expire_days`는 며칠 안에 만료되는 이성 물약을 쓸지입니다. 설정 문서 예시의 `expiring_medicine`은 다른 이름이며, 옮길 때 그 이름을 쓰지 않습니다. +- `DrGrandet`이 `true`이면 순오리지늄 확인 화면에서 이성이 1 회복될 때까지 기다렸다가 바로 사용합니다. + +### 공개모집 + +- `times`는 모집 횟수입니다. +- `refresh`는 3성 태그를 새로고침할지입니다. +- `extra_tags_mode`는 추가 태그를 다루는 방식입니다. 숫자의 의미는 통합 문서를 봅니다. +- `select`와 `confirm`은 자동으로 고르고 자동으로 확정할 성급입니다. 예는 `3`, `4`, `5`입니다. +- `recruitment_time`은 성급별 모집 분입니다. +- `first_tags`는 선호 태그입니다. +- `preserve_tags`는 남길 태그입니다. +- `level3_recruitment_permit_reserve`는 남겨 둘 3성 모집 허가증 수입니다. 이 항목이 꺼져 있거나 수량이 0보다 크지 않으면 이 이름을 쓰지 않습니다. + +GUI의 즉시 새로고침은 쓰지 않습니다. +켜져 있으면 요약에 건너뛴 필드가 한 줄 나오고 경고도 납니다. +6성 자동 확정이 켜져 있으면 명령이 경고합니다. + +### 기반 시설 교대 매개변수 + +- `drones`는 드론 용도입니다. +- `threshold`는 숙소 기분 임계값이며 범위는 0에서 1입니다. GUI의 백분율은 이 소수가 됩니다. +- `replenish`는 무역소의 오리지늄 파편을 자동 보충할지입니다. +- `dorm_notstationed_enabled`는 숙소에서 「미배치」를 쓸지입니다. +- `reception_message_board`, `reception_clue_exchange`, `reception_send_clue`는 응접실 메시지 보드, 단서 교환, 단서 보내기입니다. +- `continue_training`은 훈련실에서 훈련을 계속할지입니다. +- `fiammetta_targets`는 피아메타가 기분을 회복할 오퍼레이터 명단입니다. +- `fiammetta_recovery_enabled`는 피아메타로 기분을 회복할지입니다. +- `use_pinus_sylvestris`, `use_perception_information`, `use_worldly_plight`, `use_abyssal_hunter`는 홍송, 지각, 진세, 심해 네 가지 시설 조합입니다. `false`이면 쓰지 않습니다. + +`fiammetta_targets`는 `mode`가 0이고 `fiammetta_recovery_enabled`가 true일 때만 적용됩니다. +`fiammetta_recovery_enabled`와 위 네 조합은 `mode`가 0일 때만 적용됩니다. + +### 크레딧 상점 + +- `shopping`은 크레딧 상품을 살지입니다. +- `credit_fight`는 크레딧 전투를 할지입니다. +- `formation_index`는 크레딧 전투에 쓸 편성입니다. +- `visit_friends`는 친구를 방문할지입니다. +- `buy_first`는 먼저 살 상품입니다. +- `blacklist`는 사지 않을 상품입니다. +- `force_shopping_if_credit_full`은 크레딧이 가득 찼을 때 차단 목록 밖을 아직 살지입니다. +- `only_buy_discount`는 할인 상품만 살지입니다. +- `reserve_max_credit`은 크레딧을 상한 근처에 남길지입니다. + +크레딧 전투와 친구 방문의 마지막 시각, 오늘 아직 할 수 있는지는 GUI 자신의 기록입니다. +결과에는 쓰지 않습니다. + +### 보상 + +- `award`는 일일 또는 주간 임무 보상을 받습니다. +- `mail`은 우편 보상을 받습니다. +- `recruit`는 한정 모집에서 주는 매일 무료 1회를 받습니다. +- `orundum`은 행운의 벽 합성옥을 받습니다. +- `mining`은 기간 한정 채굴 허가증의 합성옥을 받습니다. +- `specialaccess`는 5주년 월간권을 받습니다. +- `signinevent`는 기간 한정 출석 보상을 받습니다. + +### 로그라이크 + +이 설정은 실기에서 확인하지 않았습니다. +각 매개변수의 작용은 [MAA 통합 문서][task-types]를 봅니다. + +이 이름들은 해당하는 테마와 모드에서만 의미가 있습니다. +GUI에서 고른 내용을 남기려고 씁니다. +쓰지 않는 모드에서 MaaCore는 이들을 무시합니다. + +- `theme`는 테마입니다. +- `mode`는 모드 번호입니다. 점수 파밍은 `0`, 원석정 파밍은 `1`, 시작 보상 맞추기는 `4`, 붕괴 패러다임은 `5`, 월간 분대는 `6`, 심층 조사는 `7`, 상락 노드 찾기는 `20001`, 흑류수해 시작 런은 `30003`입니다. 알아보지 못한 모드는 `mode`를 쓰지 않고 요약에 기록합니다. +- `squad`는 시작 분대입니다. `roles`는 시작 직업 조합입니다. +- `core_char`는 첫 시작 오퍼레이터입니다. `core_char_list`는 시작 오퍼레이터 전체이며 각 항목에 `name`과 `use_support`가 있습니다. +- `use_support`는 첫 오퍼레이터를 서포트로 쓸지입니다. `use_nonfriend_support`는 친구가 아닌 서포트를 쓸지입니다. +- `starts_count`는 시작 횟수입니다. `difficulty`는 난이도입니다. 난이도가 자리 표시용인 매우 큰 수이면 쓰지 않습니다. +- `investment_enabled`는 투자할지입니다. `investments_count`는 투자 횟수입니다. `investment_with_more_score`는 더 높은 점수로 투자할지입니다. `stop_when_investment_full`은 예금이 가득 차면 멈출지입니다. +- `stop_at_final_boss`는 엔딩 전에 멈출지입니다. `stop_at_max_level`은 레벨이 최대이면 멈출지입니다. +- `start_with_elite_two`와 `only_start_with_elite_two`는 정예 2 시작입니다. +- `refresh_trader_with_dice`는 주사위로 상점을 새로고침할지입니다. +- `collectible_mode_squad`는 시작 보상 맞추기 또는 흑류수해 준비 판에서 쓰는 분대입니다. `collectible_mode_shopping`은 시작 보상 맞추기에서 쇼핑할지입니다. `collectible_mode_start_list`는 원하는 시작 보상이며 예는 `hot_water`입니다. +- `monthly_squad_auto_iterate`는 월간 분대를 자동으로 바꿀지입니다. `monthly_squad_check_comms`는 바꿀 때 통신을 볼지입니다. +- `deep_exploration_auto_iterate`는 심층 조사를 자동으로 바꿀지입니다. +- `find_playTime_target`은 상락 노드입니다. `1`은 링, `2`는 슈, `3`은 니엔입니다. +- `blackflow_strategy`는 흑류수해 엔딩 전략이며 예는 `ending1`입니다. `blackflow_cultivation_target`은 키울 강보 동물입니다. `blackflow_start_animals`는 준비 판에서 모을 강보 동물입니다. +- `expected_collapsal_paradigms`는 원하는 붕괴 패러다임입니다. +- `first_floor_foldartal`과 `start_foldartal_list`는 밀문판입니다. +- `start_with_seed`는 고정 시드입니다. + +흑류수해 시작 보상의 비트 조합과 빠른 비행은 쓰지 않습니다. +GUI에서 이 둘이 꺼짐이나 「없음」이 아니면 요약에 건너뛴 필드가 각각 한 줄 나옵니다. + +### 생존 연산 + +이 설정은 실기에서 확인하지 않았습니다. +각 매개변수의 작용은 [MAA 통합 문서][task-types]를 봅니다. + +- `theme`는 테마입니다. +- `mode` `0`은 저장하지 않는 번영, `1`은 세이브에 쓰는 번영입니다. 알아보지 못한 모드는 쓰지 않고 요약에 기록합니다. +- `tools_to_craft`는 만들 도구입니다. +- `increment_mode`는 증분 모드입니다. +- `num_craft_batches`는 한 라운드의 제작 배치 수입니다. + +창고 인식과 오퍼레이터 box는 작업이 되지 않습니다. +알 수 없는 작업도 작업이 되지 않습니다. +둘 다 요약에만 나옵니다. + +## 요약 + +건너뛰거나 끈 작업이 있으면 요약은 표준 오류로 갑니다. +잃은 것이 없으면 아무것도 인쇄하지 않습니다. +명령이 인쇄하는 글은 영어입니다. +아래 세 항목이 항상 함께 나오지는 않습니다. + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +첫 줄은 이번 변환이 일부를 버렸다는 뜻입니다. + +`Skipped tasks`는 파일에 아예 쓰지 않은 작업입니다. +따옴표 안은 GUI에 있는 그 작업의 이름입니다. +`UserDataUpdateTask`는 창고 인식과 오퍼레이터 box입니다. + +`Disabled tasks`는 파일에는 썼지만 기본적으로 실행하지 않는 작업입니다. +괄호 안의 `params.enable=false`가 앞 절의 `enable`입니다. +따옴표 안은 작업 이름입니다. +`FightTask`는 작전, `RecruitTask`는 공개모집, `InfrastTask`는 기반 시설입니다. + +`Skipped fields`는 어떤 작업 안에서 쓰지 않은 설정 하나입니다. +따옴표 안은 작업 이름입니다. +콜론 뒤는 GUI 내보내기 파일에 있던 원래 필드 이름입니다. +그 이름을 알 필요는 없습니다. +그 설정이 버려졌다는 뜻만 있습니다. +공개모집에서 즉시 새로고침이 켜져 있으면 위 마지막 줄이 됩니다. + +매일 실행에 쓰기 전에 요약을 읽습니다. + +## 참고 + +- [설정](config.md) +- [사용법](usage.md) +- [MAA 통합 문서의 작업 유형][task-types] + +[task-types]: https://maa.plus/docs/ko-kr/protocol/integration.html diff --git a/crates/maa-cli/docs/zh-CN/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..e3832fc2 --- /dev/null +++ b/crates/maa-cli/docs/zh-CN/wpf.md @@ -0,0 +1,292 @@ +# 从 WPF GUI 迁移配置 + +`maa migrate wpf` 把 MAA Windows 图形界面导出的 JSON 转成 maa-cli 任务文件。 +不支持的任务和设置会被跳过。 +结束时,命令把摘要写到标准错误。 +转完后对照 [配置](config.md) 检查一遍,再用于日常任务。 + +## 迁移一份配置 + +输入是图形界面导出的 JSON 文件。 + +```bash +# 默认写到与输入同名的 .toml +maa migrate wpf gui.json + +# 扩展名决定格式。可以是 toml、yaml 或 json +maa migrate wpf gui.json tasks/daily.toml + +# 导出里有多份配置时,用名字选一份 +maa migrate wpf gui.json --profile-name Default + +# 图形界面里已经是自定义排班时,改用你给出的排班文件 +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +导出里只有一份配置时,命令直接用它。 +有多份,并且没有 `--profile-name` 时,命令会让你选。 + +`--custom-schedule` 只在图形界面里这份基建已经是自定义排班时有效。 +还不是自定义排班时,加上这个选项会报错退出。 + +把生成的文件放到 `$MAA_CONFIG_DIR/tasks`,再用 `maa run ` 运行。 +配置目录用 `maa dir config` 查看。 + +## 配置文档里已经有的设置 + +输出和 [自定义任务](config.md#自定义任务) 的结构相同。 +顶层是 `[[tasks]]`。 +每个任务有 `type`、`name` 和 `params`。 +需要按条件切换时还有 `variants`。 +条件的写法见 [任务条件](config.md#任务条件)。 + +下面只说明配置文档里已经出现过的名字。 +配置文档没写、运行时直接交给 MaaCore 的名字,在下一节。 + +### 开始唤醒 + +`client_type` 和 `start_game_enabled` 就是配置文档启动示例里的那两个。 +图形界面没有可识别的客户端时,`client_type` 会变成运行时再选的输入。 +写法见配置文档的用户输入。 +开了切换账号但没有账号名时,这项不写入。 +摘要里会有一行跳过的字段。 + +### 理智作战 + +每个要打的关卡是一个变体。 +关卡名写在 `stage`。 + +条件按关卡种类写。 + +- 资源本或芯片本用 `Weekday`,时区是官服。 +- 活动关能在本机热更新的活动表里查到时,用 `DateTime`。里面有开始时间、结束时间和时区。查不到则用 `Always`,并告警。 +- 主线、剿灭和 `LS-6` 这类常驻关用 `Always`。 + +`DateTime`、`Weekday` 和 `DayMod` 在配置文档的任务条件里。 +`Always` 也是 maa-cli 的条件,表示不看时间,这个变体总是可以执行。 +活动关的 `start` 和 `end` 取自迁移当时本机的活动表。 +活动换档后不会自动更新。 +需要重新迁移,或手改条件。 +图形界面里还有一份每周计划时,周计划和关卡条件用 `And` 合在一起。 + +下面是多个备选关卡时的结果。 + +```toml +[[tasks]] +name = "理智作战" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +params = { stage = "TO-5" } + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { stage = "LS-6" } +``` + +如果结果里会使用源石,命令会警告。 +这个设置可能消耗源石。 +理智药、源石、次数和过期药的参数名不在配置文档里,见下一节。 + +### 基建换班 + +设施顺序写在 `facility`。 +宿舍是否填入信赖未满的干员写在 `dorm_trust_enabled`。 +这两个名字在配置文档的基建例子里。 + +图形界面按时间自动换班,并且排班里每个班次都有时段时,`mode` 是 `10000`。 +变体按时间段选择 `plan_index`。 +跨天时段会加上 `DayMod`。 +这和配置文档里的基建例子是同一套。 +排班没有可用时段,或图形界面选了固定班次时,结果仍是自定义基建,并带上 `plan_index`。 + +写出的 `filename` 是绝对路径。 +排班路径里的 `~` 展开成你的主目录。 +相对路径按你传给命令的那份 JSON 所在目录解析。 +配置文档说明,这份排班 JSON 由 MaaCore 读取,不由 maa-cli 读取。 +排班文件里的菲亚梅塔也留在那个文件里,不抄进任务参数。 + +要换成另一份排班,用 `--custom-schedule` 给出那份 JSON 的路径。 +图形界面还不是自定义排班时,命令报错退出。 +指定的文件读失败时也报错退出,不会退回默认基建。 + +没有这个选项,并且排班不存在、无法解析或没有班次时,`mode` 写成 `0`。 +摘要里会说明跳过了排班。 + +无人机、心情阈值、菲亚梅塔名单这些名字不在配置文档里,见下一节。 + +## 交给 MaaCore 的参数 + +[配置](config.md#基本结构) 写明,任务 `params` 里的具体名字以 MAA 集成文档为准。 +maa-cli 不检查这些名字对不对。 +运行时由 MaaCore 读取。 +下面这些就是迁移会写进 `params`、但配置文档没有解释的名字。 +要改数值,改这些名字。 +每个名字的全部可选取值见 [MAA 集成文档][task-types]。 + +### 是否执行 + +`enable` 为 `false` 时,这个任务不执行。 +图形界面里关掉的任务仍会写进文件,并带上 `enable = false`。 +要让它跑,把该字段改成 `true`,或删掉它。 + +### 理智作战参数 + +- `medicine` 是理智药个数。配置文档只在用户输入的例子里出现过这个名字,没有说明它是理智药。 +- `stone` 是源石个数。 +- `times` 是次数上限。 +- `series` 是连战次数。 +- `drops` 是目标掉落。 +- `medicine_expire_days` 是使用多少天内将过期的理智药。配置文档示例里的 `expiring_medicine` 是另一个名字,迁移不会写成那个名字。 +- `DrGrandet` 为 `true` 时,在使用源石的确认界面等到回复 1 点理智,再立刻使用。 + +### 公开招募 + +- `times` 是招募次数。 +- `refresh` 表示是否刷新三星标签。 +- `extra_tags_mode` 是额外标签的处理方式。数字的含义见集成文档。 +- `select` 和 `confirm` 是要自动选择、自动确认的星级,例如 `3`、`4`、`5`。 +- `recruitment_time` 是各星级的招募分钟数。 +- `first_tags` 是优先标签。 +- `preserve_tags` 是要保留的标签。 +- `level3_recruitment_permit_reserve` 是三星招募许可要保留的数量。这项没开,或数量不大于 0 时,不写这个名字。 + +图形界面里的立即刷新不会写入。 +它开着时,摘要里会有一行跳过的字段,并警告。 +开了自动确认六星时,命令会警告。 + +### 基建换班参数 + +- `drones` 是无人机用途。 +- `threshold` 是宿舍心情阈值,范围是 0 到 1。图形界面里的百分比会换成这个小数。 +- `replenish` 表示贸易站的源石碎片是否自动补货。 +- `dorm_notstationed_enabled` 表示宿舍是否启用「未进驻」。 +- `reception_message_board`、`reception_clue_exchange` 和 `reception_send_clue` 是会客室的留言板、线索交流和送出线索。 +- `continue_training` 表示训练室是否继续训练。 +- `fiammetta_targets` 是菲亚梅塔要换心情的干员名单。 +- `fiammetta_recovery_enabled` 表示是否用菲亚梅塔恢复心情。 +- `use_pinus_sylvestris`、`use_perception_information`、`use_worldly_plight` 和 `use_abyssal_hunter` 是红松、感知、尘世和深海这四组跨设施组合。为 `false` 时不写。 + +`fiammetta_targets` 只在 `mode` 为 0 且 `fiammetta_recovery_enabled` 为 true 时生效。 +`fiammetta_recovery_enabled` 和上面四组组合只在 `mode` 为 0 时生效。 + +### 信用商店 + +- `shopping` 表示是否购买信用商品。 +- `credit_fight` 表示是否进行信用作战。 +- `formation_index` 是信用作战使用的编队。 +- `visit_friends` 表示是否访问好友。 +- `buy_first` 是优先购买的商品。 +- `blacklist` 是不购买的商品。 +- `force_shopping_if_credit_full` 表示信用满了是否仍按黑名单以外的商品购买。 +- `only_buy_discount` 表示是否只买折扣商品。 +- `reserve_max_credit` 表示是否把信用留到上限附近。 + +信用作战和访问好友的上次时间、今天是否还能做,是图形界面自己的记录。 +这些不写入结果。 + +### 领奖 + +- `award` 是领取每日或每周任务奖励。 +- `mail` 是领取邮件奖励。 +- `recruit` 是领取限定池赠送的每日免费单抽。 +- `orundum` 是领取幸运墙的合成玉。 +- `mining` 是领取限时开采许可的合成玉。 +- `specialaccess` 是领取五周年赠送的月卡。 +- `signinevent` 是领取限时签到奖励。 + +### 肉鸽 + +这些配置未经实机验证。 +相关参数的作用见 [MAA 集成文档][task-types]。 + +这些名字只在对应的主题和模式里有用。 +写进去是为了留下你在图形界面里选过的内容。 +MaaCore 在用不到的模式下会忽略它们。 + +- `theme` 是主题。 +- `mode` 是模式编号。刷分是 `0`,刷源石锭是 `1`,凹开局是 `4`,坍缩范式是 `5`,月度小队是 `6`,深入调查是 `7`,刷常乐节点是 `20001`,黑流树海刷开局是 `30003`。认不出的模式不写 `mode`,并记入摘要。 +- `squad` 是开局分队。`roles` 是开局职业组合。 +- `core_char` 是第一名开局干员。`core_char_list` 是全部开局干员,每项有 `name` 和 `use_support`。 +- `use_support` 表示第一名是否使用助战。`use_nonfriend_support` 表示是否使用非好友助战。 +- `starts_count` 是开始次数。`difficulty` 是难度。难度如果是一个占位用的极大数字,则不写。 +- `investment_enabled` 表示是否投资。`investments_count` 是投资次数。`investment_with_more_score` 表示是否用更高分数投资。`stop_when_investment_full` 表示存满后是否停止。 +- `stop_at_final_boss` 表示是否在结局前停止。`stop_at_max_level` 表示等级满了是否停止。 +- `start_with_elite_two` 和 `only_start_with_elite_two` 是精二开局。 +- `refresh_trader_with_dice` 表示是否用骰子刷新商店。 +- `collectible_mode_squad` 是凹开局或黑流树海准备局使用的分队。`collectible_mode_shopping` 表示凹开局是否购物。`collectible_mode_start_list` 是希望碰到的开局奖励,例如 `hot_water`。 +- `monthly_squad_auto_iterate` 表示月度小队是否自动切换。`monthly_squad_check_comms` 表示切换时是否看通信。 +- `deep_exploration_auto_iterate` 表示深入调查是否自动切换。 +- `find_playTime_target` 是常乐节点。`1` 是令,`2` 是黍,`3` 是年。 +- `blackflow_strategy` 是黑流树海的结局策略,例如 `ending1`。`blackflow_cultivation_target` 是要培育的襁褓动物。`blackflow_start_animals` 是准备局要凑齐的襁褓动物。 +- `expected_collapsal_paradigms` 是希望碰到的坍缩范式。 +- `first_floor_foldartal` 和 `start_foldartal_list` 是密文板。 +- `start_with_seed` 是固定种子。 + +黑流树海的开局奖励位组合,以及快速飞行,没有写入。 +图形界面里这两项不是关闭或「无」时,摘要里会各有一行跳过的字段。 + +### 生息演算 + +这些配置未经实机验证。 +相关参数的作用见 [MAA 集成文档][task-types]。 + +- `theme` 是主题。 +- `mode` 为 `0` 是不存档的繁荣,为 `1` 是写入存档的繁荣。认不出的模式不写,并记入摘要。 +- `tools_to_craft` 是要制作的工具。 +- `increment_mode` 是增量模式。 +- `num_craft_batches` 是每轮制作批数。 + +仓库识别和干员 box 不会写成任务。 +不认识的任务也不会写成任务。 +这两类只出现在摘要里。 + +## 迁移摘要 + +有跳过或关掉的任务时,摘要写到标准错误。 +没有任何损失时,什么都不打印。 +命令打印的是英文。 +下面三项不一定同时出现。 + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +第一行表示这次转换丢掉了一部分内容。 + +`Skipped tasks` 是整项没有写进文件的任务。 +引号里是图形界面里该任务的名字。 +`UserDataUpdateTask` 是仓库识别和干员 box。 + +`Disabled tasks` 是已经写进文件、但默认不执行的任务。 +括号里的 `params.enable=false` 就是上一节的 `enable`。 +引号里是任务名。 +`FightTask` 是作战,`RecruitTask` 是公开招募,`InfrastTask` 是基建。 + +`Skipped fields` 是某个任务里没写进去的一项设置。 +引号里是任务名。 +冒号后面是图形界面导出文件里的原字段名。 +你不需要认识这个名字。 +它只表示这项设置被丢掉了。 +公开招募打开了立即刷新时,就会看到上面最后一行。 + +先看摘要,再把输出文件用于日常任务。 + +## 参考 + +- [配置](config.md) +- [使用](usage.md) +- [MAA 集成文档里的任务类型][task-types] + +[task-types]: https://maa.plus/docs/zh-cn/protocol/integration.html#任务类型一览 diff --git a/crates/maa-cli/docs/zh-TW/intro.md b/crates/maa-cli/docs/zh-TW/intro.md index 921815d2..102867a6 100644 --- a/crates/maa-cli/docs/zh-TW/intro.md +++ b/crates/maa-cli/docs/zh-TW/intro.md @@ -17,6 +17,7 @@ This page is outdated and maybe still in Simplified Chinese. Translation is need - [安装及编译](install.md) - [使用](usage.md) - [配置](config.md) +- [從 WPF GUI 遷移設定](wpf.md) - [常见问题](faq.md) [maa-home]: https://github.com/MaaAssistantArknights/MaaAssistantArknights/ diff --git a/crates/maa-cli/docs/zh-TW/wpf.md b/crates/maa-cli/docs/zh-TW/wpf.md new file mode 100644 index 00000000..d54d2b3d --- /dev/null +++ b/crates/maa-cli/docs/zh-TW/wpf.md @@ -0,0 +1,292 @@ +# 從 WPF GUI 遷移設定 + +`maa migrate wpf` 把 MAA Windows 圖形介面匯出的 JSON 轉成 maa-cli 任務檔。 +不支援的任務和設定會被跳過。 +結束時,命令把摘要寫到標準錯誤。 +轉完後對照 [設定](config.md) 檢查一遍,再用於日常任務。 + +## 遷移一份設定 + +輸入是圖形介面匯出的 JSON 檔。 + +```bash +# 預設寫到與輸入同名的 .toml +maa migrate wpf gui.json + +# 副檔名決定格式。可以是 toml、yaml 或 json +maa migrate wpf gui.json tasks/daily.toml + +# 匯出裡有多份設定時,用名字選一份 +maa migrate wpf gui.json --profile-name Default + +# 圖形介面裡已經是自訂排班時,改用你給出的排班檔 +maa migrate wpf gui.json --custom-schedule ~/base-schedule.json +``` + +匯出裡只有一份設定時,命令直接用它。 +有多份,並且沒有 `--profile-name` 時,命令會讓你選。 + +`--custom-schedule` 只在圖形介面裡這份基建已經是自訂排班時有效。 +還不是自訂排班時,加上這個選項會報錯退出。 + +把生成的檔案放到 `$MAA_CONFIG_DIR/tasks`,再用 `maa run ` 執行。 +設定目錄用 `maa dir config` 查看。 + +## 設定文件裡已經有的設定 + +輸出和 [自訂任務](config.md#自定义任务) 的結構相同。 +頂層是 `[[tasks]]`。 +每個任務有 `type`、`name` 和 `params`。 +需要按條件切換時還有 `variants`。 +條件的寫法見 [任務條件](config.md#任务条件)。 + +下面只說明設定文件裡已經出現過的名字。 +設定文件沒寫、執行時直接交給 MaaCore 的名字,在下一節。 + +### 開始喚醒 + +`client_type` 和 `start_game_enabled` 就是設定文件啟動示例裡的那兩個。 +圖形介面沒有可識別的客戶端時,`client_type` 會變成執行時再選的輸入。 +寫法見設定文件的使用者輸入。 +開了切換帳號但沒有帳號名時,這項不寫入。 +摘要裡會有一行跳過的欄位。 + +### 理智作戰 + +每個要打的關卡是一個變體。 +關卡名寫在 `stage`。 + +條件按關卡種類寫。 + +- 資源本或晶片本用 `Weekday`,時區是官服。 +- 活動關能在本機熱更新的活動表裡查到時,用 `DateTime`。裡面有開始時間、結束時間和時區。查不到則用 `Always`,並告警。 +- 主線、剿滅和 `LS-6` 這類常駐關用 `Always`。 + +`DateTime`、`Weekday` 和 `DayMod` 在設定文件的任務條件裡。 +`Always` 也是 maa-cli 的條件,表示不看時間,這個變體總是可以執行。 +活動關的 `start` 和 `end` 取自遷移當時本機的活動表。 +活動換檔後不會自動更新。 +需要重新遷移,或手改條件。 +圖形介面裡還有一份每週計畫時,週計畫和關卡條件用 `And` 合在一起。 + +下面是多個備選關卡時的結果。 + +```toml +[[tasks]] +name = "理智作戰" +type = "Fight" + +[[tasks.variants]] +condition = { type = "DateTime", start = "2026-08-08T16:00:00", end = "2026-08-22T03:59:59", timezone = 8 } +params = { stage = "TO-5" } + +[[tasks.variants]] +condition = { type = "Weekday", weekdays = ["Tue", "Thu", "Sat", "Sun"], timezone = "Official" } +params = { stage = "CE-6" } + +[[tasks.variants]] +condition = { type = "Always" } +params = { stage = "LS-6" } +``` + +如果結果裡會使用源石,命令會警告。 +這個設定可能消耗源石。 +理智藥、源石、次數和過期藥的參數名不在設定文件裡,見下一節。 + +### 基建換班 + +設施順序寫在 `facility`。 +宿舍是否填入信賴未滿的幹員寫在 `dorm_trust_enabled`。 +這兩個名字在設定文件的基建例子裡。 + +圖形介面按時間自動換班,並且排班裡每個班次都有時段時,`mode` 是 `10000`。 +變體按時間段選擇 `plan_index`。 +跨天時段會加上 `DayMod`。 +這和設定文件裡的基建例子是同一套。 +排班沒有可用時段,或圖形介面選了固定班次時,結果仍是自訂基建,並帶上 `plan_index`。 + +寫出的 `filename` 是絕對路徑。 +排班路徑裡的 `~` 展開成你的主目錄。 +相對路徑按你傳給命令的那份 JSON 所在目錄解析。 +設定文件說明,這份排班 JSON 由 MaaCore 讀取,不由 maa-cli 讀取。 +排班檔裡的菲亞梅塔也留在那個檔案裡,不抄進任務參數。 + +要換成另一份排班,用 `--custom-schedule` 給出那份 JSON 的路徑。 +圖形介面還不是自訂排班時,命令報錯退出。 +指定的檔案讀失敗時也報錯退出,不會退回預設基建。 + +沒有這個選項,並且排班不存在、無法解析或沒有班次時,`mode` 寫成 `0`。 +摘要裡會說明跳過了排班。 + +無人機、心情閾值、菲亞梅塔名單這些名字不在設定文件裡,見下一節。 + +## 交給 MaaCore 的參數 + +[設定](config.md#基本结构) 寫明,任務 `params` 裡的具體名字以 MAA 整合文件為準。 +maa-cli 不檢查這些名字對不對。 +執行時由 MaaCore 讀取。 +下面這些就是遷移會寫進 `params`、但設定文件沒有解釋的名字。 +要改數值,改這些名字。 +每個名字的全部可選取值見 [MAA 整合文件][task-types]。 + +### 是否執行 + +`enable` 為 `false` 時,這個任務不執行。 +圖形介面裡關掉的任務仍會寫進檔案,並帶上 `enable = false`。 +要讓它跑,把該欄位改成 `true`,或刪掉它。 + +### 理智作戰參數 + +- `medicine` 是理智藥個數。設定文件只在使用者輸入的例子裡出現過這個名字,沒有說明它是理智藥。 +- `stone` 是源石個數。 +- `times` 是次數上限。 +- `series` 是連戰次數。 +- `drops` 是目標掉落。 +- `medicine_expire_days` 是使用多少天內將過期的理智藥。設定文件示例裡的 `expiring_medicine` 是另一個名字,遷移不會寫成那個名字。 +- `DrGrandet` 為 `true` 時,在使用源石的確認介面等到回復 1 點理智,再立刻使用。 + +### 公開招募 + +- `times` 是招募次數。 +- `refresh` 表示是否重新整理三星標籤。 +- `extra_tags_mode` 是額外標籤的處理方式。數字的含義見整合文件。 +- `select` 和 `confirm` 是要自動選擇、自動確認的星級,例如 `3`、`4`、`5`。 +- `recruitment_time` 是各星級的招募分鐘數。 +- `first_tags` 是優先標籤。 +- `preserve_tags` 是要保留的標籤。 +- `level3_recruitment_permit_reserve` 是三星招募許可要保留的數量。這項沒開,或數量不大於 0 時,不寫這個名字。 + +圖形介面裡的立即重新整理不會寫入。 +它開著時,摘要裡會有一行跳過的欄位,並警告。 +開了自動確認六星時,命令會警告。 + +### 基建換班參數 + +- `drones` 是無人機用途。 +- `threshold` 是宿舍心情閾值,範圍是 0 到 1。圖形介面裡的百分比會換成這個小數。 +- `replenish` 表示貿易站的源石碎片是否自動補貨。 +- `dorm_notstationed_enabled` 表示宿舍是否啟用「未進駐」。 +- `reception_message_board`、`reception_clue_exchange` 和 `reception_send_clue` 是會客室的留言板、線索交流和送出線索。 +- `continue_training` 表示訓練室是否繼續訓練。 +- `fiammetta_targets` 是菲亞梅塔要換心情的幹員名單。 +- `fiammetta_recovery_enabled` 表示是否用菲亞梅塔恢復心情。 +- `use_pinus_sylvestris`、`use_perception_information`、`use_worldly_plight` 和 `use_abyssal_hunter` 是紅松、感知、塵世和深海這四組跨設施組合。為 `false` 時不寫。 + +`fiammetta_targets` 只在 `mode` 為 0 且 `fiammetta_recovery_enabled` 為 true 時生效。 +`fiammetta_recovery_enabled` 和上面四組組合只在 `mode` 為 0 時生效。 + +### 信用商店 + +- `shopping` 表示是否購買信用商品。 +- `credit_fight` 表示是否進行信用作戰。 +- `formation_index` 是信用作戰使用的編隊。 +- `visit_friends` 表示是否訪問好友。 +- `buy_first` 是優先購買的商品。 +- `blacklist` 是不購買的商品。 +- `force_shopping_if_credit_full` 表示信用滿了是否仍按黑名單以外的商品購買。 +- `only_buy_discount` 表示是否只買折扣商品。 +- `reserve_max_credit` 表示是否把信用留到上限附近。 + +信用作戰和訪問好友的上次時間、今天是否還能做,是圖形介面自己的記錄。 +這些不寫入結果。 + +### 領獎 + +- `award` 是領取每日或每週任務獎勵。 +- `mail` 是領取郵件獎勵。 +- `recruit` 是領取限定池贈送的每日免費單抽。 +- `orundum` 是領取幸運牆的合成玉。 +- `mining` 是領取限時開採許可的合成玉。 +- `specialaccess` 是領取五週年贈送的月卡。 +- `signinevent` 是領取限時簽到獎勵。 + +### 肉鴿 + +這些設定未經實機驗證。 +相關參數的作用見 [MAA 整合文件][task-types]。 + +這些名字只在對應的主題和模式裡有用。 +寫進去是為了留下你在圖形介面裡選過的內容。 +MaaCore 在用不到的模式下會忽略它們。 + +- `theme` 是主題。 +- `mode` 是模式編號。刷分是 `0`,刷源石錠是 `1`,凹開局是 `4`,坍縮範式是 `5`,月度小隊是 `6`,深入調查是 `7`,刷常樂節點是 `20001`,黑流樹海刷開局是 `30003`。認不出的模式不寫 `mode`,並記入摘要。 +- `squad` 是開局分隊。`roles` 是開局職業組合。 +- `core_char` 是第一名開局幹員。`core_char_list` 是全部開局幹員,每項有 `name` 和 `use_support`。 +- `use_support` 表示第一名是否使用助戰。`use_nonfriend_support` 表示是否使用非好友助戰。 +- `starts_count` 是開始次數。`difficulty` 是難度。難度如果是一個佔位用的極大數字,則不寫。 +- `investment_enabled` 表示是否投資。`investments_count` 是投資次數。`investment_with_more_score` 表示是否用更高分數投資。`stop_when_investment_full` 表示存滿後是否停止。 +- `stop_at_final_boss` 表示是否在結局前停止。`stop_at_max_level` 表示等級滿了是否停止。 +- `start_with_elite_two` 和 `only_start_with_elite_two` 是精二開局。 +- `refresh_trader_with_dice` 表示是否用骰子重新整理商店。 +- `collectible_mode_squad` 是凹開局或黑流樹海準備局使用的分隊。`collectible_mode_shopping` 表示凹開局是否購物。`collectible_mode_start_list` 是希望碰到的開局獎勵,例如 `hot_water`。 +- `monthly_squad_auto_iterate` 表示月度小隊是否自動切換。`monthly_squad_check_comms` 表示切換時是否看通信。 +- `deep_exploration_auto_iterate` 表示深入調查是否自動切換。 +- `find_playTime_target` 是常樂節點。`1` 是令,`2` 是黍,`3` 是年。 +- `blackflow_strategy` 是黑流樹海的結局策略,例如 `ending1`。`blackflow_cultivation_target` 是要培育的襁褓動物。`blackflow_start_animals` 是準備局要湊齊的襁褓動物。 +- `expected_collapsal_paradigms` 是希望碰到的坍縮範式。 +- `first_floor_foldartal` 和 `start_foldartal_list` 是密文板。 +- `start_with_seed` 是固定種子。 + +黑流樹海的開局獎勵位組合,以及快速飛行,沒有寫入。 +圖形介面裡這兩項不是關閉或「無」時,摘要裡會各有一行跳過的欄位。 + +### 生息演算 + +這些設定未經實機驗證。 +相關參數的作用見 [MAA 整合文件][task-types]。 + +- `theme` 是主題。 +- `mode` 為 `0` 是不存檔的繁榮,為 `1` 是寫入存檔的繁榮。認不出的模式不寫,並記入摘要。 +- `tools_to_craft` 是要製作的工具。 +- `increment_mode` 是增量模式。 +- `num_craft_batches` 是每輪製作批數。 + +倉庫識別和幹員 box 不會寫成任務。 +不認識的任務也不會寫成任務。 +這兩類只出現在摘要裡。 + +## 遷移摘要 + +有跳過或關掉的任務時,摘要寫到標準錯誤。 +沒有任何損失時,什麼都不列印。 +命令列印的是英文。 +下面三項不一定同時出現。 + +```text +Migration summary (lossy): + Skipped tasks (unsupported type): + - UserDataUpdateTask "同步数据" + Disabled tasks (kept with params.enable=false; will not run): + - FightTask "剿灭" + Skipped fields (not migrated): + - RecruitTask "公开招募": ForceRefresh +``` + +第一行表示這次轉換丟掉了一部分內容。 + +`Skipped tasks` 是整項沒有寫進檔案的任務。 +引號裡是圖形介面裡該任務的名字。 +`UserDataUpdateTask` 是倉庫識別和幹員 box。 + +`Disabled tasks` 是已經寫進檔案、但預設不執行的任務。 +括號裡的 `params.enable=false` 就是上一節的 `enable`。 +引號裡是任務名。 +`FightTask` 是作戰,`RecruitTask` 是公開招募,`InfrastTask` 是基建。 + +`Skipped fields` 是某個任務裡沒寫進去的一項設定。 +引號裡是任務名。 +冒號後面是圖形介面匯出檔裡的原欄位名。 +你不需要認識這個名字。 +它只表示這項設定被丟掉了。 +公開招募打開了立即重新整理時,就會看到上面最後一行。 + +先看摘要,再把輸出檔用於日常任務。 + +## 參考 + +- [設定](config.md) +- [使用](usage.md) +- [MAA 整合文件裡的任務類型][task-types] + +[task-types]: https://maa.plus/docs/zh-tw/protocol/integration.html 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 77545a80..37b9bc04 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)] @@ -247,6 +257,32 @@ 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; defaults to the input path with its extension replaced by + /// .toml + output: Option, + /// Select a named configuration when migrating a multi-profile GUI export + #[arg(long = "profile-name")] + 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")] @@ -618,6 +654,70 @@ 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 migrate_wpf_help_describes_default_output_path() { + let help = match Cli::try_parse_from(["maa", "migrate", "wpf", "--help"]) { + Err(error) => { + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + error.to_string() + } + Ok(_) => panic!("--help should stop argument parsing"), + }; + + assert!(help.contains("defaults to the input path"), "{help}"); + assert!(!help.contains("printed to stdout"), "{help}"); + } + #[test] fn activity() { assert_matches!(parse_from(["maa", "activity"]).command, Command::Activity { 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..f4a3b498 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/award.rs @@ -0,0 +1,252 @@ +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", + "SignInEvent", +]; + +#[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")] + signinevent: 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(default)] + sign_in_event: 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), + signinevent: task.sign_in_event.then_some(true), + 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..dd0efbd5 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/fight.rs @@ -0,0 +1,818 @@ +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", + "IsDrGrandet", +]; + +/// 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(rename = "DrGrandet", skip_serializing_if = "Option::is_none")] + dr_grandet: 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.dr_grandet.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(default)] + is_dr_grandet: bool, + + #[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 open + /// window (and timezone) from the resource file + /// + /// If the activity resource cannot be read (even after a hot-update attempt), migration + /// fails instead of inventing a condition. Stages absent from a readable activity file + /// 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) => Self::activity_schedule_condition(schedule), + None => { + warn!( + "FightTask stage `{stage}` has no known open schedule; treating as Always" + ); + Condition::Always + } + }, + }) + } + + fn activity_schedule_condition(schedule: crate::activity::SideStorySchedule) -> Condition { + Condition::DateTime { + start: Some(schedule.start), + end: Some(schedule.end), + timezone: TimeOffset::TimeZone(schedule.timezone), + } + } + + /// Resolve the open window for an activity stage from `StageActivityV2.json`. + /// + /// On read failure, attempts a hot update once and reloads. Persistent read failure + /// is an error so migration does not invent dates. + 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, + dr_grandet: task.is_dr_grandet.then_some(true), + 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 activity_schedule_condition_keeps_start_and_end() { + let schedule = crate::activity::SideStorySchedule { + start: chrono::NaiveDateTime::MIN, + end: chrono::NaiveDateTime::MAX, + timezone: 8, + }; + + assert_eq!( + WpfFightTask::activity_schedule_condition(schedule), + Condition::DateTime { + start: Some(schedule.start), + end: Some(schedule.end), + timezone: TimeOffset::TimeZone(schedule.timezone), + } + ); + } + + #[test] + fn custom_annihilation_uses_annihilation_stage() { + let config = fight_profile(serde_json::json!({ + "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..8197f997 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/infrast.rs @@ -0,0 +1,906 @@ +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", + "FiammettaTarget1", + "FiammettaTarget2", + "FiammettaTarget3", + "FiammettaRecoveryEnabled", + "UsePinusSylvestris", + "UsePerceptionInformation", + "UseWorldlyPlight", + "UseAbyssalHunter", +]; + +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 = "Vec::is_empty")] + fiammetta_targets: Vec, + #[serde(skip_serializing_if = "is_false")] + fiammetta_recovery_enabled: bool, + #[serde(skip_serializing_if = "is_false")] + use_pinus_sylvestris: bool, + #[serde(skip_serializing_if = "is_false")] + use_perception_information: bool, + #[serde(skip_serializing_if = "is_false")] + use_worldly_plight: bool, + #[serde(skip_serializing_if = "is_false")] + use_abyssal_hunter: bool, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + 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(default)] + fiammetta_target1: String, + #[serde(default)] + fiammetta_target2: String, + #[serde(default)] + fiammetta_target3: String, + #[serde(default)] + fiammetta_recovery_enabled: bool, + #[serde(default)] + use_pinus_sylvestris: bool, + #[serde(default)] + use_perception_information: bool, + #[serde(default)] + use_worldly_plight: bool, + #[serde(default)] + use_abyssal_hunter: bool, + #[serde(flatten)] + unknown: Map, +} + +fn default_plan_select() -> i32 { + -1 +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[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, + schedule_base: Option<&Path>, + ) -> Result { + if let Some(path) = custom_schedule + && self.wants_custom() + { + *schedule_applied = true; + return self.try_custom_task(&path.to_string_lossy(), None); + } + + if self.wants_custom() { + match self.try_custom_task(&self.filename, schedule_base) { + 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), + fiammetta_targets: self.fiammetta_targets(), + fiammetta_recovery_enabled: self.fiammetta_recovery_enabled, + use_pinus_sylvestris: self.use_pinus_sylvestris, + use_perception_information: self.use_perception_information, + use_worldly_plight: self.use_worldly_plight, + use_abyssal_hunter: self.use_abyssal_hunter, + filename, + plan_index, + enable: (!self.is_enable).then_some(false), + } + } + + fn fiammetta_targets(&self) -> Vec { + [ + &self.fiammetta_target1, + &self.fiammetta_target2, + &self.fiammetta_target3, + ] + .into_iter() + .filter(|name| !name.is_empty()) + .cloned() + .collect() + } + + fn try_custom_task( + &self, + filename: &str, + schedule_base: Option<&Path>, + ) -> Result { + ensure!(!filename.is_empty(), "custom mode has empty Filename"); + let filename = resolve_infrast_filename(filename, schedule_base); + let schedule = load_custom_schedule(&filename)?; + let n_plans = + i32::try_from(schedule.plans.len()).context("too many plans in custom infrast file")?; + + 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, schedule_base: Option<&Path>) -> PathBuf { + let expanded = expand_tilde(Path::new(path)); + if expanded.is_absolute() { + return expanded.into_owned(); + } + match schedule_base { + Some(dir) => dir.join(expanded), + None => expanded.into_owned(), + } +} + +fn load_custom_schedule(path: &Path) -> Result { + 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, migrate_with_base}, + *, + }; + 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, None).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 relative_filename_resolves_beside_the_profile() { + let dir = tempfile::tempdir().unwrap(); + let schedule_path = dir.path().join("plan.json"); + std::fs::write( + &schedule_path, + r#"{"plans":[{"period":[["09:00","21:00"]]}]}"#, + ) + .unwrap(); + let (value, summary) = migrate_with_base( + infrast_profile("Custom", "plan.json"), + None, + Some(dir.path()), + ) + .unwrap(); + assert!(summary.is_empty()); + let json = serde_json::to_value(value).unwrap(); + assert_eq!( + json["tasks"][0]["params"]["filename"], + schedule_path.to_str().unwrap() + ); + } + + #[test] + fn keeps_fiammetta_targets_for_default_mode() { + let task = serde_json::from_value(serde_json::json!({ + "Name": "", + "IsEnable": true, + "Mode": "Normal", + "Filename": "", + "PlanSelect": -1, + "ContinueTraining": false, + "RoomList": [{"Room": "Dorm"}], + "UsesOfDrones": "_NotUse", + "DormThreshold": 30, + "OriginiumShardAutoReplenishment": false, + "DormFilterNotStationed": false, + "DormTrustEnabled": false, + "ReceptionMessageBoard": false, + "ReceptionClueExchange": false, + "SendClue": false, + "FiammettaTarget1": "清流", + "FiammettaTarget2": "可露希尔", + "FiammettaTarget3": "但书", + "FiammettaRecoveryEnabled": true, + })) + .unwrap(); + let mut summary = MigrationSummary::default(); + let json = to_json(&task, &mut summary); + assert!(summary.is_empty()); + assert_eq!( + json["params"]["fiammetta_targets"], + serde_json::json!(["清流", "可露希尔", "但书"]) + ); + assert_eq!(json["params"]["fiammetta_recovery_enabled"], true); + } + + #[test] + fn tilde_in_filename_expands_to_home() { + let expanded = resolve_infrast_filename("~/一图流-153-一天两换-MAA.json", None); + 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(), None) + .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..c30a054c --- /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", + // GUI bookkeeping. MaaCore mall params have no matching fields. + "CreditFightLastTime", + "CreditFightOnceADay", + "VisitFriendsLastTime", + "VisitFriendsOnceADay", + "IsCreditFightAvailable", + "IsVisitFriendsAvailable", +]; + +#[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!(summary.is_empty()); + 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..ec685313 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/mod.rs @@ -0,0 +1,509 @@ +use std::{collections::BTreeMap, io::BufReader, 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>, + schedule_base: Option<&Path>, + ) -> Result> { + let mut tasks = Vec::new(); + let mut schedule_applied = false; + for task in &self.task_queue { + if let Some(item) = task.migrate_task( + &self.gui, + summary, + custom_schedule, + &mut schedule_applied, + schedule_base, + )? { + 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(Box), + 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_file = std::fs::File::open(file).context("Trying to open wpf profile")?; + let profile: WpfProfile = serde_json::from_reader(BufReader::new(profile_file))?; + ensure!( + profile.config_version == SUPPORTED_CONFIG_VERSION, + "Unsupported WPF ConfigVersion {} (expected {SUPPORTED_CONFIG_VERSION})", + profile.config_version + ); + let configuration = select_configuration(profile, profile_name)?; + let (value, summary) = migrate_with_base(configuration, custom_schedule, file.parent())?; + + 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. +#[cfg(test)] +pub(super) fn migrate( + config: WpfConfig, + custom_schedule: Option<&Path>, +) -> Result<(CliConfig, MigrationSummary)> { + migrate_with_base(config, custom_schedule, None) +} + +pub(super) fn migrate_with_base( + config: WpfConfig, + custom_schedule: Option<&Path>, + schedule_base: Option<&Path>, +) -> Result<(CliConfig, MigrationSummary)> { + let mut summary = MigrationSummary::default(); + let value = config + .migrate_tasks(&mut summary, custom_schedule, schedule_base)? + .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(Box::new(serde_json::from_value( + value, + )?))), + "ReclamationTask" => Ok(WpfTask::ReclamationTask(serde_json::from_value(value)?)), + other => Ok(WpfTask::Unsupported { + type_tag: other.to_string(), + name, + }), + } +} + +impl WpfTask { + fn migrate_task( + &self, + gui: &WpfGuiSettings, + summary: &mut MigrationSummary, + custom_schedule: Option<&Path>, + schedule_applied: &mut bool, + schedule_base: Option<&Path>, + ) -> 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, + schedule_base, + )?)?)) + } + 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.as_ref())?)) + } + 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..d3380b06 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/recruit.rs @@ -0,0 +1,342 @@ +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", + "Level3RecruitmentPermitReserveEnabled", + "Level3RecruitmentPermitReserve", +]; + +#[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")] + level3_recruitment_permit_reserve: 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(default)] + level3_recruitment_permit_reserve_enabled: bool, + #[serde(default)] + level3_recruitment_permit_reserve: i32, + #[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, + level3_recruitment_permit_reserve: (task.level3_recruitment_permit_reserve_enabled + && task.level3_recruitment_permit_reserve > 0) + .then_some(task.level3_recruitment_permit_reserve), + enable: (!task.is_enable).then_some(false), + }, + }) + } +} + +#[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..33115220 --- /dev/null +++ b/crates/maa-cli/src/config/migrate/wpf/roguelike.rs @@ -0,0 +1,754 @@ +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", + "StartingOpers", + "UseAdditionalStartingOpers", + "StartCount", + "Difficulty", + "Investment", + "InvestCount", + "InvestWithMoreScore", + "StopWhenDepositFull", + "StopAtFinalBoss", + "StopWhenLevelMax", + "UseSupport", + "UseSupportNonFriend", + "RefreshTraderWithDice", + "StartWithEliteTwo", + "StartWithEliteTwoOnly", + "SquadCollectible", + "CollectibleStartAwards", + "CollectibleShopping", + "ExpectedCollapsalParadigms", + "MonthlySquadAutoIterate", + "MonthlySquadCheckComms", + "DeepExplorationAutoIterate", + "FindPlaytimeTarget", + "BlackFlowCultivationTarget", + "BlackFlowEnding", + "BlackFlowStartAnimal1", + "BlackFlowStartAnimal2", + "BlackFlowStartRewards", + "SamiFirstFloorFoldartal", + "SamiFirstFloorFoldartals", + "SamiNewSquad2StartingFoldartal", + "SamiNewSquad2StartingFoldartals", + "StartWithSeed", + "Seed", +]; + +#[derive(Debug, Serialize)] +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")] + core_char_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + collectible_mode_squad: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collectible_mode_shopping: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collectible_mode_start_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + monthly_squad_auto_iterate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + monthly_squad_check_comms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + deep_exploration_auto_iterate: Option, + #[serde( + rename = "find_playTime_target", + skip_serializing_if = "Option::is_none" + )] + find_play_time_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blackflow_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blackflow_cultivation_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blackflow_start_animals: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + expected_collapsal_paradigms: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + first_floor_foldartal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + start_foldartal_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + start_with_seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enable: Option, +} + +#[derive(Debug, Serialize)] +struct CliStartingOper { + name: String, + use_support: bool, +} + +/// Newer GUI exports list starting operators here instead of flat `CoreChar` / +/// `UseSupport`. The full list maps to `core_char_list`; the first entry also +/// maps to `core_char` and `use_support`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WpfStartingOper { + name: String, + #[serde(default)] + use_support: bool, +} + +/// 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, + /// Older exports; newer ones omit this in favor of [`Self::starting_opers`]. + #[serde(default)] + core_char: Option, + #[serde(default)] + starting_opers: Vec, + start_count: i32, + difficulty: i32, + investment: bool, + invest_count: i32, + invest_with_more_score: bool, + stop_when_deposit_full: bool, + stop_at_final_boss: bool, + stop_when_level_max: bool, + /// Older exports; newer ones put support on each [`WpfStartingOper`]. + #[serde(default)] + use_support: Option, + use_support_non_friend: bool, + refresh_trader_with_dice: bool, + start_with_elite_two: bool, + start_with_elite_two_only: bool, + #[serde(default)] + squad_collectible: String, + #[serde(default)] + collectible_start_awards: String, + #[serde(default)] + collectible_shopping: bool, + #[serde(default)] + expected_collapsal_paradigms: String, + #[serde(default)] + monthly_squad_auto_iterate: bool, + #[serde(default)] + monthly_squad_check_comms: bool, + #[serde(default)] + deep_exploration_auto_iterate: bool, + #[serde(default)] + find_playtime_target: String, + #[serde(default)] + black_flow_cultivation_target: String, + #[serde(default)] + black_flow_ending: String, + #[serde(default)] + black_flow_start_animal1: String, + #[serde(default)] + black_flow_start_animal2: String, + #[serde(default)] + black_flow_start_rewards: String, + #[serde(default)] + sami_first_floor_foldartal: bool, + #[serde(default)] + sami_first_floor_foldartals: String, + #[serde(default)] + sami_new_squad2_starting_foldartal: bool, + #[serde(default)] + sami_new_squad2_starting_foldartals: String, + #[serde(default)] + start_with_seed: bool, + #[serde(default)] + seed: String, + #[serde(flatten)] + unknown: Map, +} + +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), + "FindPlaytime" => Some(20001), + "BlackFlowStartRun" => Some(30003), + _ => None, + } + } + + /// Prefer `StartingOpers[0]`, then legacy `CoreChar` / `UseSupport`. + fn starting_oper(&self) -> (Option, bool) { + if let Some(oper) = self.starting_opers.first() { + let name = (!oper.name.is_empty()).then(|| oper.name.clone()); + return (name, oper.use_support); + } + ( + self.core_char + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_string), + self.use_support.unwrap_or(false), + ) + } + + fn named_or_skip( + &self, + summary: &mut MigrationSummary, + field: &str, + raw: &str, + mapped: Option, + ) { + if !raw.is_empty() && raw != "None" && mapped.is_none() { + summary.skip_field("RoguelikeTask", Some(self.name.clone()), field); + } + } + + fn report_unmapped(&self, summary: &mut MigrationSummary) { + self.named_or_skip( + summary, + "FindPlaytimeTarget", + &self.find_playtime_target, + playtime_target(&self.find_playtime_target).map(|n| n.to_string()), + ); + self.named_or_skip( + summary, + "BlackFlowCultivationTarget", + &self.black_flow_cultivation_target, + swaddled(&self.black_flow_cultivation_target).map(str::to_string), + ); + self.named_or_skip( + summary, + "BlackFlowEnding", + &self.black_flow_ending, + ending_strategy(&self.black_flow_ending).map(str::to_string), + ); + for (field, raw) in [ + ( + "BlackFlowStartAnimal1", + self.black_flow_start_animal1.as_str(), + ), + ( + "BlackFlowStartAnimal2", + self.black_flow_start_animal2.as_str(), + ), + ] { + self.named_or_skip(summary, field, raw, swaddled(raw).map(str::to_string)); + } + if !self.black_flow_start_rewards.is_empty() && self.black_flow_start_rewards != "None" { + summary.skip_field( + "RoguelikeTask", + Some(self.name.clone()), + "BlackFlowStartRewards", + ); + } + self.named_or_skip( + summary, + "CollectibleStartAwards", + &self.collectible_start_awards, + award_key(&self.collectible_start_awards).map(str::to_string), + ); + } + + fn core_char_list(&self) -> Option> { + let list: Vec = self + .starting_opers + .iter() + .filter(|oper| !oper.name.is_empty()) + .map(|oper| CliStartingOper { + name: oper.name.clone(), + use_support: oper.use_support, + }) + .collect(); + (!list.is_empty()).then_some(list) + } + + fn start_animals(&self) -> Option> { + let animals: Vec = [ + &self.black_flow_start_animal1, + &self.black_flow_start_animal2, + ] + .into_iter() + .filter_map(|name| swaddled(name)) + .map(str::to_string) + .collect(); + (!animals.is_empty()).then_some(animals) + } + + pub(super) fn report_to(&self, summary: &mut MigrationSummary) { + if !self.is_enable { + summary.disable_task("RoguelikeTask", Some(self.name.clone())); + } + if self.mapped_mode().is_none() { + summary.skip_field("RoguelikeTask", Some(self.name.clone()), "Mode"); + } + self.report_unmapped(summary); + 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 { + let (core_char, use_support) = task.starting_oper(); + 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, + 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(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), + core_char_list: task.core_char_list(), + collectible_mode_squad: (!task.squad_collectible.is_empty()) + .then(|| task.squad_collectible.clone()), + collectible_mode_shopping: task.collectible_shopping.then_some(true), + collectible_mode_start_list: award_key(&task.collectible_start_awards).map(|key| { + let mut list = Map::new(); + list.insert(key.to_string(), Value::Bool(true)); + list + }), + monthly_squad_auto_iterate: task.monthly_squad_auto_iterate.then_some(true), + monthly_squad_check_comms: task.monthly_squad_check_comms.then_some(true), + deep_exploration_auto_iterate: task.deep_exploration_auto_iterate.then_some(true), + find_play_time_target: playtime_target(&task.find_playtime_target), + blackflow_strategy: ending_strategy(&task.black_flow_ending).map(str::to_string), + blackflow_cultivation_target: swaddled(&task.black_flow_cultivation_target) + .map(str::to_string), + blackflow_start_animals: task.start_animals(), + expected_collapsal_paradigms: split_list(&task.expected_collapsal_paradigms), + first_floor_foldartal: (task.sami_first_floor_foldartal + && !task.sami_first_floor_foldartals.is_empty()) + .then(|| task.sami_first_floor_foldartals.clone()), + start_foldartal_list: (task.sami_new_squad2_starting_foldartal) + .then(|| split_list(&task.sami_new_squad2_starting_foldartals)) + .flatten(), + start_with_seed: (task.start_with_seed && !task.seed.is_empty()) + .then(|| task.seed.clone()), + enable: (!task.is_enable).then_some(false), + }, + }) + } +} + +fn playtime_target(name: &str) -> Option { + match name { + "Ling" => Some(1), + "Shu" => Some(2), + "Nian" => Some(3), + _ => None, + } +} + +fn swaddled(name: &str) -> Option<&'static str> { + match name { + "Cat" => Some("swaddled_cat"), + "Dog" => Some("swaddled_dog"), + "Cerberus" => Some("swaddled_cerberus"), + "FeatheredSerpent" => Some("swaddled_feathered_serpent"), + _ => None, + } +} + +fn ending_strategy(name: &str) -> Option<&'static str> { + match name { + "Ending1" => Some("ending1"), + "Ending2" => Some("ending2"), + "Ending3" => Some("ending3"), + _ => None, + } +} + +fn award_key(name: &str) -> Option<&'static str> { + match name { + "HotWater" => Some("hot_water"), + "Shield" => Some("shield"), + "Ingot" => Some("ingot"), + "Hope" => Some("hope"), + "Random" => Some("random"), + "Key" => Some("key"), + "Dice" => Some("dice"), + "Ideas" | "Idea" => Some("ideas"), + _ => None, + } +} + +fn split_list(raw: &str) -> Option> { + let items: Vec = raw + .split([';', ',', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(); + (!items.is_empty()).then_some(items) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + 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_starting_opers_when_core_char_absent() { + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [{ + "$type": "RoguelikeTask", + "Name": "黑流树海-刷源石锭", + "IsEnable": true, + "Theme": "BlackFlow", + "Mode": "Investment", + "Squad": "特勤分队", + "Roles": "稳扎稳打", + "StartCount": 99999, + "StartingOpers": [ + { "Name": "机械师", "UseSupport": true }, + { "Name": "额外干员", "UseSupport": false }, + ], + "Difficulty": i32::MAX, + "Investment": true, + "InvestCount": 999, + "InvestWithMoreScore": false, + "StopWhenDepositFull": true, + "StopAtFinalBoss": false, + "StopWhenLevelMax": false, + "UseSupportNonFriend": false, + "RefreshTraderWithDice": false, + "StartWithEliteTwo": false, + "StartWithEliteTwoOnly": false, + }], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.skipped_fields.is_empty()); + assert_eq!( + config.tasks[0].pointer("/params/core_char_list"), + Some(&json!([ + { "name": "机械师", "use_support": true }, + { "name": "额外干员", "use_support": false }, + ])) + ); + assert_eq!( + config.tasks[0].pointer("/params/core_char"), + Some(&json!("机械师")) + ); + assert_eq!( + config.tasks[0].pointer("/params/use_support"), + Some(&json!(true)) + ); + assert_eq!( + config.tasks[0].pointer("/params/theme"), + Some(&json!("BlackFlow")) + ); + } + + #[test] + fn accepts_roguelike_without_core_char_or_starting_opers() { + let mut task = roguelike_task("Exp", 1, true); + task.as_object_mut().unwrap().remove("CoreChar"); + task.as_object_mut().unwrap().remove("UseSupport"); + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [task], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + assert!(summary.is_empty()); + assert!(config.tasks[0].pointer("/params/core_char").is_none()); + assert_eq!( + config.tasks[0].pointer("/params/use_support"), + Some(&json!(false)) + ); + } + + #[test] + fn maps_known_roguelike_modes() { + for (mode, expected) in [ + ("Exp", 0), + ("Collect", 4), + ("CollapsalParadigms", 5), + ("MonthlySquad", 6), + ("DeepExploration", 7), + ("FindPlaytime", 20001), + ("BlackFlowStartRun", 30003), + ] { + 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 maps_blackflow_protocol_fields() { + let mut task = roguelike_task("BlackFlowStartRun", i32::MAX, true); + let obj = task.as_object_mut().unwrap(); + obj.insert("Theme".into(), json!("BlackFlow")); + obj.insert("SquadCollectible".into(), json!("指挥分队")); + obj.insert("CollectibleStartAwards".into(), json!("HotWater")); + obj.insert("FindPlaytimeTarget".into(), json!("Ling")); + obj.insert("BlackFlowCultivationTarget".into(), json!("Cerberus")); + obj.insert("BlackFlowEnding".into(), json!("Ending1")); + obj.insert("BlackFlowStartAnimal1".into(), json!("Cerberus")); + obj.insert("BlackFlowStartAnimal2".into(), json!("None")); + obj.insert("BlackFlowStartRewards".into(), json!("None")); + obj.insert("MonthlySquadAutoIterate".into(), json!(true)); + obj.remove("CoreChar"); + obj.insert( + "StartingOpers".into(), + json!([{ "Name": "机械师", "UseSupport": false }]), + ); + + let (config, summary) = migrate( + serde_json::from_value(json!({ + "TaskQueue": [task], + "Gui": {}, + })) + .unwrap(), + None, + ) + .unwrap(); + + assert!(summary.skipped_fields.is_empty()); + assert_eq!(config.tasks[0].pointer("/params/mode"), Some(&json!(30003))); + assert!(config.tasks[0].pointer("/params/difficulty").is_none()); + assert_eq!( + config.tasks[0].pointer("/params/blackflow_start_animals"), + Some(&json!(["swaddled_cerberus"])) + ); + assert_eq!( + config.tasks[0].pointer("/params/blackflow_cultivation_target"), + Some(&json!("swaddled_cerberus")) + ); + assert_eq!( + config.tasks[0].pointer("/params/blackflow_strategy"), + Some(&json!("ending1")) + ); + assert_eq!( + config.tasks[0].pointer("/params/find_playTime_target"), + Some(&json!(1)) + ); + assert_eq!( + config.tasks[0].pointer("/params/collectible_mode_squad"), + Some(&json!("指挥分队")) + ); + assert_eq!( + config.tasks[0].pointer("/params/collectible_mode_start_list/hot_water"), + Some(&json!(true)) + ); + assert_eq!( + config.tasks[0].pointer("/params/monthly_squad_auto_iterate"), + Some(&json!(true)) + ); + assert_eq!( + config.tasks[0].pointer("/params/core_char_list/0/name"), + Some(&json!("机械师")) + ); + } + + #[test] + fn unknown_roguelike_mode_is_skipped() { + let (config, summary) = migrate( + 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 1e5785af..643d08f8 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 e09d6a11..633d2ca8 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 036d34e8..4ea0c30c 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -109,6 +109,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};