diff --git a/MeoAsstMac.xcodeproj/project.pbxproj b/MeoAsstMac.xcodeproj/project.pbxproj index 93034292d..3a524a5c6 100644 --- a/MeoAsstMac.xcodeproj/project.pbxproj +++ b/MeoAsstMac.xcodeproj/project.pbxproj @@ -48,6 +48,10 @@ 8341195329EC25A900044F45 /* MAALog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8341195229EC25A900044F45 /* MAALog.swift */; }; 8341195529EC55C600044F45 /* CopilotConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8341195429EC55C600044F45 /* CopilotConfiguration.swift */; }; 8341195729EC58CF00044F45 /* MAACopilot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8341195629EC58CF00044F45 /* MAACopilot.swift */; }; + AA01F00DCAFE000000000001 /* MAAStage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01F00DCAFE000000000002 /* MAAStage.swift */; }; + AA01F00DCAFE000000000003 /* MAAStageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01F00DCAFE000000000004 /* MAAStageManager.swift */; }; + AA01F00DCAFE000000000005 /* CopilotEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01F00DCAFE000000000006 /* CopilotEntry.swift */; }; + AA01F00DCAFE000000000007 /* CopilotListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01F00DCAFE000000000008 /* CopilotListView.swift */; }; 8341195D29EC719B00044F45 /* CopilotContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8341195C29EC719B00044F45 /* CopilotContent.swift */; }; 834374AC2EB61ACA002CC2D3 /* MaaUtils.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834374AB2EB61ACA002CC2D3 /* MaaUtils.xcframework */; }; 834374AD2EB61C3E002CC2D3 /* MaaUtils.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 834374AB2EB61ACA002CC2D3 /* MaaUtils.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -91,7 +95,9 @@ 83E9D138294B74440091D1FF /* OpenCV.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 83E9D133294B74230091D1FF /* OpenCV.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 83E9D139294B74460091D1FF /* ONNXRuntime.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83E9D135294B74270091D1FF /* ONNXRuntime.xcframework */; }; 83E9D13A294B74460091D1FF /* ONNXRuntime.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 83E9D135294B74270091D1FF /* ONNXRuntime.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 83E9ED972DF693B20069CFE9 /* OTAFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E9ED962DF693B20069CFE9 /* OTAFetcher.swift */; }; + 83E9ED972DF693B20069CFE9 /* GameCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E9ED962DF693B20069CFE9 /* GameCalendar.swift */; }; + AA03F00DCAFE000000000001 /* StageCodeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03F00DCAFE000000000002 /* StageCodeResolver.swift */; }; + AA02F00DCAFE000000000001 /* MaaApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02F00DCAFE000000000002 /* MaaApiService.swift */; }; 83FF5A9C2978FE27000AE831 /* resource in Resources */ = {isa = PBXBuildFile; fileRef = 83FF5A9B2978FE27000AE831 /* resource */; }; 92CDF6222A065E55005669EC /* Codeable+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92CDF6212A065E55005669EC /* Codeable+JSON.swift */; }; 92EB82872A0CD315006DD9D2 /* SystemSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92EB82862A0CD315006DD9D2 /* SystemSettingsView.swift */; }; @@ -172,6 +178,10 @@ 8341195229EC25A900044F45 /* MAALog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MAALog.swift; sourceTree = ""; }; 8341195429EC55C600044F45 /* CopilotConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotConfiguration.swift; sourceTree = ""; }; 8341195629EC58CF00044F45 /* MAACopilot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MAACopilot.swift; sourceTree = ""; }; + AA01F00DCAFE000000000002 /* MAAStage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MAAStage.swift; sourceTree = ""; }; + AA01F00DCAFE000000000004 /* MAAStageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MAAStageManager.swift; sourceTree = ""; }; + AA01F00DCAFE000000000006 /* CopilotEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotEntry.swift; sourceTree = ""; }; + AA01F00DCAFE000000000008 /* CopilotListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotListView.swift; sourceTree = ""; }; 8341195C29EC719B00044F45 /* CopilotContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotContent.swift; sourceTree = ""; }; 834374AB2EB61ACA002CC2D3 /* MaaUtils.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MaaUtils.xcframework; path = ../../build/MaaUtils.xcframework; sourceTree = ""; }; 83477CB129FEA12300A5FF99 /* TaskCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCommands.swift; sourceTree = ""; }; @@ -214,7 +224,9 @@ 83DD50B629BE385A000E84E7 /* UpdaterSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterSettingsView.swift; sourceTree = ""; }; 83E9D133294B74230091D1FF /* OpenCV.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = OpenCV.xcframework; path = ../../build/OpenCV.xcframework; sourceTree = ""; }; 83E9D135294B74270091D1FF /* ONNXRuntime.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ONNXRuntime.xcframework; path = ../../build/ONNXRuntime.xcframework; sourceTree = ""; }; - 83E9ED962DF693B20069CFE9 /* OTAFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTAFetcher.swift; sourceTree = ""; }; + 83E9ED962DF693B20069CFE9 /* GameCalendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCalendar.swift; sourceTree = ""; }; + AA03F00DCAFE000000000002 /* StageCodeResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageCodeResolver.swift; sourceTree = ""; }; + AA02F00DCAFE000000000002 /* MaaApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaaApiService.swift; sourceTree = ""; }; 83FF5A9B2978FE27000AE831 /* resource */ = {isa = PBXFileReference; lastKnownFileType = folder; name = resource; path = ../../../../resource; sourceTree = ""; }; 92CDF6212A065E55005669EC /* Codeable+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codeable+JSON.swift"; sourceTree = ""; }; 92EB82862A0CD315006DD9D2 /* SystemSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsView.swift; sourceTree = ""; }; @@ -310,6 +322,7 @@ 832985A929EE9958009F7ED5 /* RecruitView.swift */, 832985AB29EE9977009F7ED5 /* DepotView.swift */, 83531D5E29F00F0A008A5DF8 /* CopilotView.swift */, + AA01F00DCAFE000000000008 /* CopilotListView.swift */, 8366DE0029F2F26100F715E8 /* OperBoxView.swift */, 83661E2B29F77CFF007793CA /* VideoRecogView.swift */, 838FF82629FDD0F9000566A0 /* GachaView.swift */, @@ -423,6 +436,9 @@ isa = PBXGroup; children = ( 8341195629EC58CF00044F45 /* MAACopilot.swift */, + AA01F00DCAFE000000000006 /* CopilotEntry.swift */, + AA01F00DCAFE000000000002 /* MAAStage.swift */, + AA01F00DCAFE000000000004 /* MAAStageManager.swift */, 832985B529EF1890009F7ED5 /* MAADepot.swift */, 838FF82829FDE1FA000566A0 /* MAAError.swift */, 83531D6229F0A033008A5DF8 /* MAAInfrast.swift */, @@ -474,7 +490,9 @@ 8352529E2D533F9000C6D6D3 /* MirrorChyan.swift */, 835252A02D534A9E00C6D6D3 /* GitHub.swift */, 83812ED12D5B0B8100CB39D6 /* Binding+semicolonString.swift */, - 83E9ED962DF693B20069CFE9 /* OTAFetcher.swift */, + 83E9ED962DF693B20069CFE9 /* GameCalendar.swift */, + AA03F00DCAFE000000000002 /* StageCodeResolver.swift */, + AA02F00DCAFE000000000002 /* MaaApiService.swift */, ); path = Utils; sourceTree = ""; @@ -611,7 +629,9 @@ 832985AE29EEE672009F7ED5 /* UtilityContent.swift in Sources */, 838FF82929FDE1FA000566A0 /* MAAError.swift in Sources */, 832985B429EF1219009F7ED5 /* MAARecruit.swift in Sources */, - 83E9ED972DF693B20069CFE9 /* OTAFetcher.swift in Sources */, + 83E9ED972DF693B20069CFE9 /* GameCalendar.swift in Sources */, + AA03F00DCAFE000000000001 /* StageCodeResolver.swift in Sources */, + AA02F00DCAFE000000000001 /* MaaApiService.swift in Sources */, 83264A2629EBC9C10074E33A /* ReclamationSettingsView.swift in Sources */, 832985B629EF1890009F7ED5 /* MAADepot.swift in Sources */, 83D3283928F3048E00018D10 /* RoguelikeSettingsView.swift in Sources */, @@ -630,6 +650,10 @@ 83264A3329EBEE680074E33A /* MallConfiguration.swift in Sources */, 83D061F62CF81519008F0140 /* FileLogger.swift in Sources */, 8341195729EC58CF00044F45 /* MAACopilot.swift in Sources */, + AA01F00DCAFE000000000001 /* MAAStage.swift in Sources */, + AA01F00DCAFE000000000003 /* MAAStageManager.swift in Sources */, + AA01F00DCAFE000000000005 /* CopilotEntry.swift in Sources */, + AA01F00DCAFE000000000007 /* CopilotListView.swift in Sources */, 8347B5A82CDF85A000AEB627 /* ResourceUpdateView.swift in Sources */, 8391C9FA2B2746FA0047DB20 /* MaaToolClient.swift in Sources */, 835252A12D534A9E00C6D6D3 /* GitHub.swift in Sources */, diff --git a/MeoAsstMac/Configuration Views/FightSettingsView.swift b/MeoAsstMac/Configuration Views/FightSettingsView.swift index 694ea72d1..237953d98 100644 --- a/MeoAsstMac/Configuration Views/FightSettingsView.swift +++ b/MeoAsstMac/Configuration Views/FightSettingsView.swift @@ -8,10 +8,12 @@ import SwiftUI struct FightSettingsView: View { + @EnvironmentObject private var viewModel: MAAViewModel @Binding var config: FightConfiguration @State private var useCustomStage = false @State private var dropItemList: [(name: String, id: String)] = [] + @AppStorage("MAAHideUnavailableStage") private var hideUnavailableStage = false var body: some View { Form { @@ -22,17 +24,21 @@ struct FightSettingsView: View { } else { Picker("关卡选择", selection: $config.stage) { Text("当前/上次").tag("") - Text("1-7").tag("1-7") - Text("CE-6").tag("CE-6") - Text("AP-5").tag("AP-5") - Text("CA-5").tag("CA-5") - Text("LS-6").tag("LS-6") - Text("剿灭模式").tag("Annihilation") + ForEach(pickerStages) { stage in + stageLabel(stage).tag(stage.value) + } } } Toggle("手动输入关卡名", isOn: isUsingCustomStage) } .animation(.default, value: config.stage) + + if !useCustomStage && !stageNotListed { + Toggle("隐藏未开放关卡", isOn: $hideUnavailableStage) + if let tip = selectedStageTip { + Text(tip).font(.caption).foregroundStyle(.secondary) + } + } } if useCustomStage || stageNotListed { @@ -210,12 +216,73 @@ struct FightSettingsView: View { } } - private var stageNotListed: Bool { !listedStages.contains(config.stage) } - private let listedStages = ["", "1-7", "CE-6", "AP-5", "CA-5", "LS-6", "Annihilation"] + // MARK: - Stage List + + /// In-game weekday (accounts for the 04:00 server day-rollover), per current channel. + private var currentWeekday: Int { + GameCalendar.yjWeekday(channel: viewModel.clientChannel) + } + + /// Stages shown in the picker, respecting the "hide unavailable" toggle. + /// The currently-selected stage is always kept visible so the picker never shows blank. + private var pickerStages: [MAAStageInfo] { + var listed = viewModel.stages.listedStages(hideClosed: hideUnavailableStage, weekday: currentWeekday) + if !config.stage.isEmpty, !listed.contains(where: { $0.value == config.stage }), + let selected = viewModel.stages.stageInfo(for: config.stage) + { + listed.insert(selected, at: 0) + } + return listed + } + + /// Tip text of the currently-selected stage (e.g. open days or activity description), + /// combined with the stage's drop material when available. + private var selectedStageTip: String? { + guard let stage = viewModel.stages.stageInfo(for: config.stage) else { + return nil + } + + var parts: [String] = [] + if !stage.tip.isEmpty { + parts.append(stage.tip) + } + if let drop = dropDescription(for: stage) { + parts.append(String(localized: "可刷取:\(drop)")) + } + return parts.isEmpty ? nil : parts.joined(separator: "\n") + } + + /// Human-readable drop material for a stage. The `drop` field is either an item id + /// (resolved to its localized name via item_index) or already a descriptive string. + private func dropDescription(for stage: MAAStageInfo) -> String? { + guard let drop = stage.drop, !drop.isEmpty else { return nil } + if let idx = FightConfiguration.id2index[drop] { + return FightConfiguration.dropItems[idx].item.name + } + return drop + } + + /// Renders a stage entry: dimmed when closed today, strikethrough when its activity expired. + @ViewBuilder private func stageLabel(_ stage: MAAStageInfo) -> some View { + let isOpen = stage.isStageOpen(weekday: currentWeekday) + if stage.isOutdated { + Text(stage.display).strikethrough().foregroundStyle(.red) + } else if isOpen { + Text(stage.display) + } else { + Text(stage.display).foregroundStyle(.secondary) + } + } + + private var stageNotListed: Bool { + guard !config.stage.isEmpty else { return false } + return viewModel.stages.stageInfo(for: config.stage) == nil + } } struct FightSettingsView_Previews: PreviewProvider { static var previews: some View { FightSettingsView(config: .constant(.init())) + .environmentObject(MAAViewModel()) } } diff --git a/MeoAsstMac/Model/CopilotEntry.swift b/MeoAsstMac/Model/CopilotEntry.swift new file mode 100644 index 000000000..46c63d1eb --- /dev/null +++ b/MeoAsstMac/Model/CopilotEntry.swift @@ -0,0 +1,70 @@ +// +// CopilotEntry.swift +// MAA +// +// Model for the Copilot List (作业集) feature: an ordered, persisted list of +// copilot operations that run in sequence via the core's `copilot_list` param. +// + +import Foundation + +/// A single entry in the copilot list. Field names mirror the Windows persisted schema +/// (`name`/`file_path`/`copilot_id`/`is_raid`/`is_checked`) for cross-platform familiarity. +struct CopilotEntry: Codable, Identifiable, Equatable { + var id = UUID() + + /// Stage code, used by the core for auto-navigation (e.g. "1-7", "main_01-07"). + var name: String + + /// Absolute path to the copilot JSON file. + var filePath: String + + /// PRTS Plus id (0 for local files). + var copilotId: Int + + /// Whether this stage should be run in raid (突袭) difficulty. + var isRaid: Bool + + /// Whether this entry is selected for the next run. + var isChecked: Bool + + enum CodingKeys: String, CodingKey { + case name + case filePath = "file_path" + case copilotId = "copilot_id" + case isRaid = "is_raid" + case isChecked = "is_checked" + } + + init( + name: String, filePath: String, copilotId: Int = 0, isRaid: Bool = false, + isChecked: Bool = true + ) { + self.name = name + self.filePath = filePath + self.copilotId = copilotId + self.isRaid = isRaid + self.isChecked = isChecked + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = UUID() + self.name = try container.decode(String.self, forKey: .name) + self.filePath = try container.decode(String.self, forKey: .filePath) + self.copilotId = try container.decodeIfPresent(Int.self, forKey: .copilotId) ?? 0 + self.isRaid = try container.decodeIfPresent(Bool.self, forKey: .isRaid) ?? false + self.isChecked = try container.decodeIfPresent(Bool.self, forKey: .isChecked) ?? true + } +} + +/// Global options applied to a whole copilot-list run, mirroring the Windows +/// `AsstCopilotTask` global flags. +struct CopilotListOptions: Codable, Equatable { + var formation = true + var addTrust = false + var useSanityPotion = false + + /// Support unit usage: 0 = none, 1 = when needed, 3 = random. + var supportUnitUsage = 0 +} diff --git a/MeoAsstMac/Model/MAAStage.swift b/MeoAsstMac/Model/MAAStage.swift new file mode 100644 index 000000000..d32d1eca6 --- /dev/null +++ b/MeoAsstMac/Model/MAAStage.swift @@ -0,0 +1,188 @@ +// +// MAAStage.swift +// MAA +// +// Stage activity parsing and open-stage computation. +// Ported from MaaWpfGui StageManager / StageInfo / StageActivityInfo. +// Parses the V2 schema (gui/StageActivityV2.json). +// + +import Foundation + +// MARK: - Raw JSON Models (gui/StageActivityV2.json) + +/// Top-level structure of `gui/StageActivityV2.json`, keyed by client channel. +struct StageActivityFile: Codable { + let Official: StageActivityServer? + let YoStarEN: StageActivityServer? + let YoStarJP: StageActivityServer? + let YoStarKR: StageActivityServer? + let txwy: StageActivityServer? + + func server(for channel: MAAClientChannel) -> StageActivityServer? { + switch channel { + case .Official, .Bilibili: + return Official + case .YoStarEN: + return YoStarEN + case .YoStarJP: + return YoStarJP + case .YoStarKR: + return YoStarKR + case .txwy: + return txwy + } + } +} + +struct StageActivityServer: Codable { + /// In V2 the side-story stages are grouped by activity name (e.g. "SSReopen", "TD"). + /// Each group shares one `Activity` (open window) and lists its `Stages`. + let sideStoryStage: [String: StageActivityGroup]? + let resourceCollection: StageActivityDetail? + let miniGame: [MiniGameEntry]? +} + +struct StageActivityGroup: Codable { + let Activity: StageActivityDetail? + let Stages: [StageActivityStage]? + let MinimumRequired: String? +} + +struct StageActivityStage: Codable { + let Display: String + let Value: String + let Drop: String? + let MinimumRequired: String? +} + +struct StageActivityDetail: Codable { + let Tip: String? + let StageName: String? + let UtcStartTime: String? + let UtcExpireTime: String? + let TimeZone: Int? + let IsResourceCollection: Bool? +} + +struct MiniGameEntry: Codable { + let Display: String? + let DisplayKey: String? + let Value: String? + let Tip: String? + let TipKey: String? + let UtcStartTime: String? + let UtcExpireTime: String? + let TimeZone: Int? + let MinimumRequired: String? +} + +// MARK: - Runtime Activity Info + +/// Open/expire computation for an activity, mirroring `StageActivityInfo` on Windows. +struct StageActivityInfo: Equatable { + var tip: String = "" + var stageName: String = "" + var startTime: Date + var expireTime: Date + var isResourceCollection = false + + /// Activity currently open: started and not yet expired. + var beingOpen: Bool { status().beingOpen } + + var isExpired: Bool { status().isExpired } + + var notOpenYet: Bool { status().notOpenYet } + + /// Computes the open/expired flags at a single point in time. Capturing one `date` + /// avoids inconsistencies near boundary times (each `Date()` call would otherwise + /// differ by a few milliseconds). + func status(at date: Date = Date()) -> (beingOpen: Bool, isExpired: Bool, notOpenYet: Bool) { + let isExpired = date >= expireTime + let notOpenYet = date <= startTime + return (!notOpenYet && !isExpired, isExpired, notOpenYet) + } + + init( + tip: String = "", stageName: String = "", startTime: Date, expireTime: Date, + isResourceCollection: Bool = false + ) { + self.tip = tip + self.stageName = stageName + self.startTime = startTime + self.expireTime = expireTime + self.isResourceCollection = isResourceCollection + } + + /// Build from raw JSON detail. Times are parsed in the activity's own timezone. + init?(detail: StageActivityDetail) { + guard let start = Self.parseDate(detail.UtcStartTime, timeZone: detail.TimeZone), + let expire = Self.parseDate(detail.UtcExpireTime, timeZone: detail.TimeZone) + else { + return nil + } + self.tip = detail.Tip ?? "" + self.stageName = detail.StageName ?? "" + self.startTime = start + self.expireTime = expire + self.isResourceCollection = detail.IsResourceCollection ?? false + } + + /// Parses `"yyyy/MM/dd HH:mm:ss"` interpreted in the given timezone offset (hours). + static func parseDate(_ string: String?, timeZone: Int?) -> Date? { + guard let string else { return nil } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: (timeZone ?? 8) * 3600) + return formatter.date(from: string) + } +} + +// MARK: - Stage Info + +/// A single stage entry, mirroring `StageInfo` on Windows. +struct MAAStageInfo: Identifiable, Equatable { + var display: String + var value: String + var tip: String = "" + var drop: String? + + /// Days of week (1 = Sunday ... 7 = Saturday, matching `Calendar` weekday) on which a + /// resource stage is open. `nil`/empty means always open. + var openDaysOfWeek: [Int]? + + var activity: StageActivityInfo? + + /// Hidden stages are still reachable via manual input but not listed. + var isHidden = false + + var id: String { value } + + /// Whether the associated activity exists and has expired (used for strikethrough display). + var isOutdated: Bool { + guard let activity else { return false } + return activity.isExpired && !activity.isResourceCollection + } + + /// Whether the stage is open on the given weekday (1 = Sunday ... 7 = Saturday). + func isStageOpen(weekday: Int) -> Bool { + if let activity { + let status = activity.status() + if status.beingOpen { + return true + } + // Expired non-resource activity is closed. + if !activity.isResourceCollection { + return false + } + // Expired resource activity falls through to weekly schedule. + } + + if let openDaysOfWeek, !openDaysOfWeek.isEmpty { + return openDaysOfWeek.contains(weekday) + } + + return true + } +} diff --git a/MeoAsstMac/Model/MAAStageManager.swift b/MeoAsstMac/Model/MAAStageManager.swift new file mode 100644 index 000000000..148152de6 --- /dev/null +++ b/MeoAsstMac/Model/MAAStageManager.swift @@ -0,0 +1,170 @@ +// +// MAAStageManager.swift +// MAA +// +// Loads stage activity data and merges permanent resource stages. +// Ported from MaaWpfGui StageManager. +// + +import Foundation + +/// Stateless helper that builds the stage list. The resulting `[MAAStageInfo]` is held +/// as `@Published` state on `MAAViewModel` so SwiftUI views observe it directly. +enum MAAStageManager { + /// Cache file written by the OTA service (documentDirectory/cache/gui/StageActivityV2.json). + private static var cacheURL: URL { + FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask).first! + .appendingPathComponent("cache") + .appendingPathComponent("gui") + .appendingPathComponent("StageActivityV2.json") + } + + /// Build the stage list for the given channel. Safe to call when the cache is missing; + /// in that case only the permanent stages are produced. + static func loadStages(channel: MAAClientChannel) -> [MAAStageInfo] { + let server = loadServer(channel: channel) + var result: [MAAStageInfo] = [] + + let resourceCollection: StageActivityInfo? = server?.resourceCollection.flatMap { + var info = StageActivityInfo(detail: $0) + info?.isResourceCollection = true + return info + } + + // Activity (side story) stages. In V2 each named group shares one Activity window, + // applied to every stage in that group. + for group in (server?.sideStoryStage ?? [:]).values { + let activity = group.Activity.flatMap(StageActivityInfo.init(detail:)) + for stage in group.Stages ?? [] { + result.append( + MAAStageInfo( + display: stage.Display, + value: stage.Value, + tip: activity?.tip ?? "", + drop: stage.Drop, + openDaysOfWeek: nil, + activity: activity)) + } + } + + // Permanent stages (main story, resource stages, chip stages, annihilation). + let existing = Set(result.map(\.value)) + for stage in permanentStages(resourceCollection: resourceCollection) where !existing.contains(stage.value) { + result.append(stage) + } + + return result + } + + // MARK: - Loading + + private static func loadServer(channel: MAAClientChannel) -> StageActivityServer? { + guard let data = try? Data(contentsOf: cacheURL), + let file = try? JSONDecoder().decode(StageActivityFile.self, from: data) + else { + return nil + } + return file.server(for: channel) + } + + // MARK: - Permanent Stages + + /// Weekday constants matching `Calendar.component(.weekday)` (Sunday = 1 ... Saturday = 7). + private enum Weekday { + static let sunday = 1 + static let monday = 2 + static let tuesday = 3 + static let wednesday = 4 + static let thursday = 5 + static let friday = 6 + static let saturday = 7 + } + + /// Permanent stages with weekly open schedules, ported from `StageManager.AddPermanentStages`. + private static func permanentStages(resourceCollection: StageActivityInfo?) -> [MAAStageInfo] { + typealias W = Weekday + func resource( + _ value: String, tip: String, days: [Int], drop: String? = nil + ) -> MAAStageInfo { + MAAStageInfo( + display: value, value: value, tip: tip, drop: drop, + openDaysOfWeek: days, activity: resourceCollection) + } + + return [ + // 主线关卡 + MAAStageInfo(display: "1-7", value: "1-7"), + MAAStageInfo(display: "R8-11", value: "R8-11"), + MAAStageInfo(display: "12-17-HARD", value: "12-17-HARD"), + + // 资源本(开放日仅由 days 维护一处;tip 只放材料名,对齐 Windows 的 CETip 等文案, + // 关卡是否开放通过下拉中的变灰/隐藏体现,不在 tip 里重复描述星期) + resource( + "CE-6", tip: String(localized: "CE-6: 龙门币"), + days: [W.tuesday, W.thursday, W.saturday, W.sunday]), + resource( + "AP-5", tip: String(localized: "AP-5: 红票"), + days: [W.monday, W.thursday, W.saturday, W.sunday]), + resource( + "CA-5", tip: String(localized: "CA-5: 技能书"), + days: [W.tuesday, W.wednesday, W.friday, W.sunday]), + resource("LS-6", tip: String(localized: "LS-6: 经验书"), days: []), + resource( + "SK-5", tip: String(localized: "SK-5: 碳条"), + days: [W.monday, W.wednesday, W.friday, W.saturday]), + + // 剿灭模式 + MAAStageInfo(display: String(localized: "剿灭模式"), value: "Annihilation"), + + // 芯片本(PR-X-1 与 PR-X-2 掉落同类芯片,仅效率不同) + resource( + "PR-A-1", tip: String(localized: "PR-A-1/2: 重装、医疗芯片"), + days: [W.monday, W.thursday, W.friday, W.sunday]), + resource( + "PR-A-2", tip: String(localized: "PR-A-1/2: 重装、医疗芯片"), + days: [W.monday, W.thursday, W.friday, W.sunday]), + resource( + "PR-B-1", tip: String(localized: "PR-B-1/2: 狙击、术师芯片"), + days: [W.monday, W.tuesday, W.friday, W.saturday]), + resource( + "PR-B-2", tip: String(localized: "PR-B-1/2: 狙击、术师芯片"), + days: [W.monday, W.tuesday, W.friday, W.saturday]), + resource( + "PR-C-1", tip: String(localized: "PR-C-1/2: 先锋、辅助芯片"), + days: [W.wednesday, W.thursday, W.saturday, W.sunday]), + resource( + "PR-C-2", tip: String(localized: "PR-C-1/2: 先锋、辅助芯片"), + days: [W.wednesday, W.thursday, W.saturday, W.sunday]), + resource( + "PR-D-1", tip: String(localized: "PR-D-1/2: 近卫、特种芯片"), + days: [W.tuesday, W.wednesday, W.saturday, W.sunday]), + resource( + "PR-D-2", tip: String(localized: "PR-D-1/2: 近卫、特种芯片"), + days: [W.tuesday, W.wednesday, W.saturday, W.sunday]), + + // 常驻活动关,隐藏但仍可通过手动输入访问 + MAAStageInfo(display: "OF-1", value: "OF-1", isHidden: true), + MAAStageInfo(display: "OF-F3", value: "OF-F3", isHidden: true), + ] + } +} + +// MARK: - Filtering Helpers + +extension Array where Element == MAAStageInfo { + /// Stages visible in the picker for the given weekday, optionally hiding closed ones. + func listedStages(hideClosed: Bool, weekday: Int) -> [MAAStageInfo] { + filter { stage in + guard !stage.isHidden else { return false } + if hideClosed { + return stage.isStageOpen(weekday: weekday) + } + return true + } + } + + func stageInfo(for value: String) -> MAAStageInfo? { + first { $0.value == value } + } +} diff --git a/MeoAsstMac/Model/MAAViewModel.swift b/MeoAsstMac/Model/MAAViewModel.swift index a47c21cfa..190c6e716 100644 --- a/MeoAsstMac/Model/MAAViewModel.swift +++ b/MeoAsstMac/Model/MAAViewModel.swift @@ -96,6 +96,28 @@ import SwiftUI @Published var showImportCopilot = false @Published var copilotDetailMode: CopilotDetailMode = .log + /// Ordered list of copilot operations for the 作业集 feature. + @Published var copilotList: [CopilotEntry] = [] { + didSet { saveCopilotList() } + } + + /// Global options applied to a copilot-list run. + @Published var copilotListOptions = CopilotListOptions() + + /// Whether the copilot panel runs the list (true) or the single selected file (false). + @Published var useCopilotList = false + + // MARK: - Stages + + /// Stage list (activity + permanent), refreshed after each resource load / channel change. + @Published var stages: [MAAStageInfo] = [] + + /// In-game day of the last stage hot-update, used to detect day rollover. + private var lastStageRefreshDay: DateComponents? + + /// Timer driving the stage hot-update (checks the in-game day-rollover points). + private var stageRefreshTimer: Timer? + // MARK: - Recognition @Published var recruitConfig = RecruitConfiguration.recognition @@ -192,6 +214,7 @@ import SwiftUI .store(in: &cancellables) initScheduledDailyTaskTimer() + loadCopilotList() } } @@ -304,28 +327,31 @@ extension MAAViewModel { } } - /// Fetches OTA resources for the specified channel. - private func fetchOTAResource(channel: MAAClientChannel) async throws { - let otaFetcher = OTAFetcher() - var files = [ - (path: "resource/tasks.json", name: "resource/tasks/tasks.json"), - (path: "gui/StageActivity.json", name: "gui/StageActivity.json"), - ] + /// Fetches OTA (hot-update) resources for the specified channel via MaaApiService: + /// StageActivityV2.json + tasks.json (+ the channel-specific tasks.json for global servers). + /// Returns whether the tasks data changed (i.e. the core resources should be reloaded). + @discardableResult + private func fetchOTAResource(channel: MAAClientChannel) async throws -> Bool { + let api = MaaApiService.shared + + // StageActivityV2.json: download path == cache path. + async let stageCached = api.requestWithCache(api: "gui/StageActivityV2.json") + + // tasks.json: API path differs from the core's cache layout (resource/tasks/tasks.json). + async let tasksCached = api.requestWithCache( + api: "resource/tasks.json", cacheName: "resource/tasks/tasks.json") + + var tasksChanged = !(try await tasksCached) + _ = try await stageCached + if channel.isGlobal { - files.append( - ( - path: "resource/global/\(channel.rawValue)/resource/tasks.json", - name: "resource/global/\(channel.rawValue)/resource/tasks/tasks.json" - )) - } - try await withThrowingTaskGroup(of: Void.self) { group in - for (path, name) in files { - group.addTask { - try await otaFetcher.download(path: path, name: name) - } - } - try await group.waitForAll() + let globalCached = try await api.requestWithCache( + api: "resource/global/\(channel.rawValue)/resource/tasks.json", + cacheName: "resource/global/\(channel.rawValue)/resource/tasks/tasks.json") + tasksChanged = tasksChanged || !globalCached } + + return tasksChanged } /// Load resources from bundled, user, and remote resources. @@ -361,6 +387,10 @@ extension MAAViewModel { logError("关卡数据获取失败: \(error.localizedDescription)") } + stages = MAAStageManager.loadStages(channel: channel) + lastStageRefreshDay = GameCalendar.yjDay(channel: channel) + startStageRefreshTimer() + #if DEBUG guard false else { return } #endif @@ -530,15 +560,187 @@ extension MAAViewModel { _ = try await handle?.appendTask(type: .Copilot, params: params) case .sss: _ = try await handle?.appendTask(type: .SSSCopilot, params: params) + case .list: + // 作业集复用 Copilot 任务类型,核心通过 copilot_list 参数识别为多任务 + _ = try await handle?.appendTask(type: .Copilot, params: params) + } + + try await handle?.start() + + status = .busy + } +} + +// MARK: Copilot List (作业集) + +extension MAAViewModel { + /// 作业集列表持久化文件:Application Support/CopilotList.json + private var copilotListURL: URL { + Self.userDirectory.appendingPathComponent("CopilotList.json", isDirectory: false) + } + + func loadCopilotList() { + guard let data = try? Data(contentsOf: copilotListURL), + let list = try? JSONDecoder().decode([CopilotEntry].self, from: data) + else { + return + } + copilotList = list + } + + func saveCopilotList() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + guard let data = try? encoder.encode(copilotList) else { return } + // 编码在主线程读取已发布状态,写盘 IO 放到后台队列,避免勾选/排序时阻塞 UI。 + let url = copilotListURL + Self.copilotListIOQueue.async { + try? data.write(to: url) + } + } + + /// Serial queue for persisting the copilot list off the main thread. + private static let copilotListIOQueue = DispatchQueue(label: "com.hguandl.MeoAsstMac.copilotListIO") + + /// 把一个作业文件加入作业集。会自动把作业的内部关卡名解析为核心导航所需的关卡代号 + /// (如 act50side_01 → TD-1)。 + func addToCopilotList(filePath: String, copilotId: Int = 0, isRaid: Bool = false) { + let copilot = MAACopilot(url: URL(fileURLWithPath: filePath)) + let navCode = StageCodeResolver.navigationCode( + stageName: copilot?.stage_name ?? "", title: copilot?.doc?.title) + let entry = CopilotEntry( + name: navCode, filePath: filePath, copilotId: copilotId, isRaid: isRaid) + copilotList.append(entry) + } + + /// 运行作业集:把所有勾选项打包成一个 copilot_list 任务。 + func startCopilotList() async throws { + status = .pending + defer { handleEarlyReturn(backTo: .idle) } + + let items = copilotList.filter(\.isChecked).map { + CopilotListItem(filename: $0.filePath, stage_name: $0.name, is_raid: $0.isRaid) } + guard !items.isEmpty else { + logError("作业集为空,请先勾选作业") + status = .idle + return + } + + let config = CopilotListConfiguration( + copilot_list: items, + formation: copilotListOptions.formation, + add_trust: copilotListOptions.addTrust, + support_unit_usage: copilotListOptions.supportUnitUsage, + use_sanity_potion: copilotListOptions.useSanityPotion) + + guard let params = CopilotConfiguration.list(config).params else { + status = .idle + return + } + + try await ensureHandle() + _ = try await handle?.appendTask(type: .Copilot, params: params) try await handle?.start() status = .busy } + + /// 从 PRTS 作业集 ID 批量导入:先取作业集元数据,再逐个下载其中的作业。 + func importCopilotSet(id: String) async { + guard let setURL = URL(string: "https://prts.maa.plus/set/get?id=\(id)") else { return } + + do { + let data = try await URLSession.shared.data(from: setURL).0 + let response = try JSONDecoder().decode(PrtsCopilotSetResponse.self, from: data) + guard response.statusCode == 200, let copilotIds = response.data?.copilotIds, + !copilotIds.isEmpty + else { + logError("作业集获取失败,请确认这是作业集神秘代码而非单份作业: \(id)") + return + } + + logInfo("正在导入作业集,共 \(copilotIds.count) 份作业……") + var success = 0 + for copilotId in copilotIds where await downloadCopilotToList(id: copilotId) { + success += 1 + } + + if success == copilotIds.count { + logInfo("作业集导入完成,成功 \(success) 份") + } else { + logError("作业集导入完成,成功 \(success)/\(copilotIds.count) 份,部分作业下载失败") + } + } catch { + logError("作业集导入失败: \(error.localizedDescription)") + } + } + + /// 下载单个作业(按 PRTS ID)并加入作业集。返回是否成功。 + @discardableResult + private func downloadCopilotToList(id: Int) async -> Bool { + // 作业集的作业下载到独立目录,避免混入单个作业列表(后者读取 copilot/)。 + let externalDirectory = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask).first! + .appendingPathComponent("copilot_list") + try? FileManager.default.createDirectory( + at: externalDirectory, withIntermediateDirectories: true) + + let file = externalDirectory + .appendingPathComponent("\(id)") + .appendingPathExtension("json") + + guard let url = URL(string: "https://prts.maa.plus/copilot/get/\(id)") else { return false } + + do { + let data = try await URLSession.shared.data(from: url).0 + let response = try JSONDecoder().decode(CopilotContentResponse.self, from: data) + + // 写入文件,并校验确实能解析为合法作业后才加入列表。 + try response.data.content.write(toFile: file.path, atomically: true, encoding: .utf8) + guard MAACopilot(url: file) != nil else { + try? FileManager.default.removeItem(at: file) + logError("作业解析失败 (\(id))") + return false + } + + addToCopilotList(filePath: file.path, copilotId: id) + return true + } catch { + logError("作业下载失败 (\(id)): \(error.localizedDescription)") + return false + } + } +} + +// MARK: - PRTS Copilot Set Models + +private struct PrtsCopilotSetResponse: Codable { + let statusCode: Int + let data: SetData? + + enum CodingKeys: String, CodingKey { + case statusCode = "status_code" + case data + } + + struct SetData: Codable { + let copilotIds: [Int] + + enum CodingKeys: String, CodingKey { + case copilotIds = "copilot_ids" + } + } } -// MARK: Utility +private struct CopilotContentResponse: Codable { + let data: Content + + struct Content: Codable { + let content: String + } +} extension MAAViewModel { func recognizeRecruit() async throws { @@ -628,7 +830,55 @@ extension MAAViewModel { } } -// MARK: - Prevent Sleep +// MARK: - Stage Hot Update + +extension MAAViewModel { + /// Starts the 60s timer that triggers a stage hot-update at in-game day-rollover points + /// (server 04:00 / 16:00, i.e. Yj 0:00 / 12:00) or whenever the in-game day changes. + func startStageRefreshTimer() { + stageRefreshTimer?.invalidate() + let timer = Timer(timeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.stageRefreshTick() + } + } + // Allow the timer to fire while menus/popovers are open. + RunLoop.main.add(timer, forMode: .common) + stageRefreshTimer = timer + } + + func stopStageRefreshTimer() { + stageRefreshTimer?.invalidate() + stageRefreshTimer = nil + } + + /// Per-tick check: refresh only when the in-game day changed, or at the 0:00 / 12:00 + /// Yj refresh points (mirrors the Windows NeedToUpdateDatePrompt logic). + private func stageRefreshTick() async { + let channel = clientChannel + let today = GameCalendar.yjDay(channel: channel) + let (hour, minute) = GameCalendar.yjHourMinute(channel: channel) + + let isRefreshPoint = minute == 0 && (hour == 0 || hour == 12) + let isNewDay = today != lastStageRefreshDay + + guard isRefreshPoint || isNewDay else { return } + + lastStageRefreshDay = today + await refreshStageActivity(channel: channel) + } + + /// Hot-update: re-fetch StageActivityV2.json (ETag/304) and reload the stage list, + /// without touching the full resource package. Safe to call at runtime. + func refreshStageActivity(channel: MAAClientChannel) async { + do { + try await MaaApiService.shared.requestWithCache(api: "gui/StageActivityV2.json") + } catch { + logError("关卡数据热更新失败: \(error.localizedDescription)") + } + stages = MAAStageManager.loadStages(channel: channel) + } +} extension MAAViewModel { func switchAwakeGuard(_ newValue: Status) { diff --git a/MeoAsstMac/Navigation/CopilotContent.swift b/MeoAsstMac/Navigation/CopilotContent.swift index f65545737..62bbf6b7d 100644 --- a/MeoAsstMac/Navigation/CopilotContent.swift +++ b/MeoAsstMac/Navigation/CopilotContent.swift @@ -14,8 +14,43 @@ struct CopilotContent: View { @State private var copilots = Set() @State private var downloading = false @State private var expanded = false + @State private var copilotListSelection: CopilotEntry.ID? var body: some View { + VStack(spacing: 0) { + Picker("", selection: $viewModel.useCopilotList) { + Text("单个作业").tag(false) + Text("作业集").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(8) + + if viewModel.useCopilotList { + CopilotListView(selection: $copilotListSelection) + } else { + copilotFileList + } + } + .toolbar(content: listToolbar) + .animation(.default, value: copilots) + .animation(.default, value: downloading) + .animation(.default, value: viewModel.useCopilotList) + .onAppear(perform: loadUserCopilots) + .onChange(of: copilotListSelection, perform: showCopilotListSelection) + .onChange(of: viewModel.useCopilotList, perform: switchCopilotMode) + .onDrop(of: [.fileURL], isTargeted: .none, perform: addCopilots) + .onReceive(viewModel.$copilotDetailMode, perform: deselectCopilot) + .onReceive(viewModel.$downloadCopilot, perform: downloadCopilot) + .onReceive(viewModel.$videoRecoginition, perform: selectNewCopilot) + .fileImporter( + isPresented: $viewModel.showImportCopilot, + allowedContentTypes: [.json], + allowsMultipleSelection: true, + onCompletion: addCopilots) + } + + @ViewBuilder private var copilotFileList: some View { List(selection: $selection) { DisclosureGroup(isExpanded: $expanded) { ForEach(bundledCopilots, id: \.self) { url in @@ -49,19 +84,6 @@ struct CopilotContent: View { } } } - .toolbar(content: listToolbar) - .animation(.default, value: copilots) - .animation(.default, value: downloading) - .onAppear(perform: loadUserCopilots) - .onDrop(of: [.fileURL], isTargeted: .none, perform: addCopilots) - .onReceive(viewModel.$copilotDetailMode, perform: deselectCopilot) - .onReceive(viewModel.$downloadCopilot, perform: downloadCopilot) - .onReceive(viewModel.$videoRecoginition, perform: selectNewCopilot) - .fileImporter( - isPresented: $viewModel.showImportCopilot, - allowedContentTypes: [.json], - allowsMultipleSelection: true, - onCompletion: addCopilots) } // MARK: - Toolbar @@ -99,6 +121,26 @@ struct CopilotContent: View { // MARK: - Actions + /// Drives the detail pane to preview the selected copilot-list entry. + private func showCopilotListSelection(_ id: CopilotEntry.ID?) { + guard let id, let entry = viewModel.copilotList.first(where: { $0.id == id }) else { + return + } + viewModel.copilotDetailMode = .copilotConfig + selection = URL(fileURLWithPath: entry.filePath) + } + + /// Clears the opposite pane's selection when switching between single / list modes. + private func switchCopilotMode(_ useList: Bool) { + selection = nil + if useList { + // Re-show the currently selected list entry, if any. + showCopilotListSelection(copilotListSelection) + } else { + copilotListSelection = nil + } + } + private func stop() { Task { try await viewModel.stop() @@ -108,7 +150,11 @@ struct CopilotContent: View { private func start() { Task { viewModel.copilotDetailMode = .log - try await viewModel.startCopilot() + if viewModel.useCopilotList { + try await viewModel.startCopilotList() + } else { + try await viewModel.startCopilot() + } } } @@ -173,6 +219,11 @@ struct CopilotContent: View { } private func deleteSelectedCopilot() { + if viewModel.useCopilotList { + deleteSelectedCopilotListEntry() + return + } + guard let selection, let index = copilots.urls.firstIndex(of: selection) else { return } deleteCopilot(url: selection) @@ -185,6 +236,26 @@ struct CopilotContent: View { } } + /// Removes the selected copilot-list entry, then selects the neighbouring entry + /// (mirrors the single-copilot deletion behaviour). + private func deleteSelectedCopilotListEntry() { + guard let selectedID = copilotListSelection, + let index = viewModel.copilotList.firstIndex(where: { $0.id == selectedID }) + else { + return + } + + viewModel.copilotList.remove(at: index) + + if viewModel.copilotList.isEmpty { + copilotListSelection = nil + } else if index < viewModel.copilotList.count { + copilotListSelection = viewModel.copilotList[index].id + } else { + copilotListSelection = viewModel.copilotList.last?.id + } + } + private func deselectCopilot(_ viewMode: MAAViewModel.CopilotDetailMode) { if viewMode != .copilotConfig { selection = nil @@ -201,7 +272,10 @@ struct CopilotContent: View { // MARK: - State Wrappers private var shouldDisableDeletion: Bool { - selection == nil || isBundled(selection) + if viewModel.useCopilotList { + return copilotListSelection == nil + } + return selection == nil || isBundled(selection) } private func isBundled(_ url: URL?) -> Bool { diff --git a/MeoAsstMac/Navigation/CopilotDetail.swift b/MeoAsstMac/Navigation/CopilotDetail.swift index aea6f0c31..16c7abeaa 100644 --- a/MeoAsstMac/Navigation/CopilotDetail.swift +++ b/MeoAsstMac/Navigation/CopilotDetail.swift @@ -16,7 +16,9 @@ struct CopilotDetail: View { var body: some View { VStack { if let url { - CopilotView(url: url) + // In copilot-list mode the detail pane previews the selected entry + // (documentation only) without touching the single-copilot run state. + CopilotView(url: url, previewOnly: viewModel.useCopilotList) } else { LogView() } @@ -36,6 +38,15 @@ struct CopilotDetail: View { } .help("添加作业") .popover(isPresented: $showAdd, arrowEdge: .bottom, content: addPopover) + + if let url { + Button { + addCurrentToList(url: url) + } label: { + Label("加入作业集", systemImage: "text.badge.plus") + } + .help("将当前作业加入作业集") + } } ToolbarItemGroup { @@ -73,6 +84,17 @@ struct CopilotDetail: View { .buttonStyle(.borderedProminent) .disabled(prtsCode.parsedID == nil) + Button { + let setID = prtsCode.parsedID ?? prtsCode + showAdd = false + Task { await viewModel.importCopilotSet(id: setID) } + } label: { + Label("导入作业集", systemImage: "square.stack.3d.down.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(prtsCode.parsedID == nil) + Button { if let clipboardString = NSPasteboard.general.string(forType: .string), let parsedID = clipboardString.parsedID @@ -95,9 +117,17 @@ struct CopilotDetail: View { } .buttonStyle(.bordered) } - .frame(width: 200) + .frame(width: 220) .padding() } + + // MARK: - Actions + + /// 将当前选中的作业加入作业集(导航代号由 addToCopilotList 自动解析)。 + private func addCurrentToList(url: URL) { + viewModel.addToCopilotList(filePath: url.path) + viewModel.useCopilotList = true + } } struct CopilotDetail_Previews: PreviewProvider { diff --git a/MeoAsstMac/Task Configurations/CopilotConfiguration.swift b/MeoAsstMac/Task Configurations/CopilotConfiguration.swift index 6221ceb88..db0163c06 100644 --- a/MeoAsstMac/Task Configurations/CopilotConfiguration.swift +++ b/MeoAsstMac/Task Configurations/CopilotConfiguration.swift @@ -20,6 +20,24 @@ struct SSSCopilotConfiguration: Codable { var loop_times = 1 } +/// One element of the `copilot_list` array sent to the core. +struct CopilotListItem: Codable { + var filename: String + var stage_name: String + var is_raid: Bool +} + +/// Parameters for a copilot-list (作业集) run. Field names match the core's `Copilot` +/// task params built by the Windows `AsstCopilotTask`. +struct CopilotListConfiguration: Codable { + var enable = true + var copilot_list: [CopilotListItem] + var formation = true + var add_trust = false + var support_unit_usage = 0 + var use_sanity_potion = false +} + struct VideoRecognitionConfiguration: Codable { var enable = true var filename: String @@ -32,6 +50,7 @@ struct VideoRecognitionConfiguration: Codable { enum CopilotConfiguration { case regular(RegularCopilotConfiguration) case sss(SSSCopilotConfiguration) + case list(CopilotListConfiguration) var params: String? { switch self { @@ -39,6 +58,8 @@ enum CopilotConfiguration { return try? config.jsonString() case .sss(let config): return try? config.jsonString() + case .list(let config): + return try? config.jsonString() } } } diff --git a/MeoAsstMac/Utils/GameCalendar.swift b/MeoAsstMac/Utils/GameCalendar.swift new file mode 100644 index 000000000..79f114f61 --- /dev/null +++ b/MeoAsstMac/Utils/GameCalendar.swift @@ -0,0 +1,59 @@ +// +// GameCalendar.swift +// MAA +// +// Game-server day calculation, ported from MaaWpfGui DateTimeExtension. +// The in-game day rolls over at 04:00 server-local time (YjDayStartHour), +// so "today" for stage scheduling is offset accordingly. +// + +import Foundation + +enum GameCalendar { + /// The in-game day starts at 04:00 server-local time. + private static let dayStartHour = 4 + + /// Server timezone offset (hours from UTC) per client channel, matching the + /// Windows `_clientTypeTimezone` table. + static func timezoneOffset(for channel: MAAClientChannel) -> Int { + switch channel { + case .Official, .Bilibili, .txwy: + return 8 + case .YoStarEN: + return -7 + case .YoStarJP, .YoStarKR: + return 9 + } + } + + /// Converts a wall-clock instant to the "Yj" datetime used for day-rollover logic: + /// `UTC + (serverTimezone - dayStartHour)`. The result's calendar fields (in UTC) + /// represent the in-game date/time. + static func yjDate(for date: Date = Date(), channel: MAAClientChannel) -> Date { + let shiftHours = timezoneOffset(for: channel) - dayStartHour + return date.addingTimeInterval(TimeInterval(shiftHours * 3600)) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + /// In-game weekday (1 = Sunday ... 7 = Saturday, matching `Calendar.weekday`). + static func yjWeekday(for date: Date = Date(), channel: MAAClientChannel) -> Int { + utcCalendar.component(.weekday, from: yjDate(for: date, channel: channel)) + } + + /// In-game calendar day, used to detect day rollover for hot updates. + static func yjDay(for date: Date = Date(), channel: MAAClientChannel) -> DateComponents { + utcCalendar.dateComponents([.year, .month, .day], from: yjDate(for: date, channel: channel)) + } + + /// In-game hour and minute, used to detect the 0:00 / 12:00 refresh points + /// (server 04:00 / 16:00). + static func yjHourMinute(for date: Date = Date(), channel: MAAClientChannel) -> (hour: Int, minute: Int) { + let comps = utcCalendar.dateComponents([.hour, .minute], from: yjDate(for: date, channel: channel)) + return (comps.hour ?? 0, comps.minute ?? 0) + } +} diff --git a/MeoAsstMac/Utils/MaaApiService.swift b/MeoAsstMac/Utils/MaaApiService.swift new file mode 100644 index 000000000..eeadfec58 --- /dev/null +++ b/MeoAsstMac/Utils/MaaApiService.swift @@ -0,0 +1,179 @@ +// +// MaaApiService.swift +// MAA +// +// Hot-update (OTA) downloader for lightweight JSON resources, ported from the +// Windows MaaApiService + ETagCache. Fetches files from api.maa.plus (falling +// back to api2.maa.plus), using ETag / Last-Modified conditional requests so +// unchanged files return 304 and are served from the local cache. +// + +import Foundation + +actor MaaApiService { + static let shared = MaaApiService() + + private let primaryBaseURL = "https://api.maa.plus/MaaAssistantArknights/api/" + private let fallbackBaseURL = "https://api2.maa.plus/MaaAssistantArknights/api/" + + private let session: URLSession + + /// Cache root: documentDirectory/cache (shared with the existing resource cache). + private let cacheDir = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask).first! + .appendingPathComponent("cache") + + // ETag / Last-Modified, keyed by full URL, persisted alongside the cache (matching Windows). + private var etagCache: [String: String] = [:] + private var lastModifiedCache: [String: String] = [:] + private var cacheLoaded = false + + init(session: URLSession = .shared) { + self.session = session + } + + // MARK: - Public API + + /// Downloads `api` (a path relative to the API base) into `cache/{cacheName}`, using + /// conditional request headers. `cacheName` defaults to `api` but may differ when the + /// local layout diverges from the API path (e.g. API `resource/tasks.json` → + /// cache `resource/tasks/tasks.json`, which is where the core loads it from). + /// Returns whether the cache was used (304 or network fallback) — `false` means fresh + /// content was written to disk. Throws only when neither network nor cache can satisfy it. + @discardableResult + func requestWithCache(api: String, cacheName: String? = nil) async throws -> Bool { + loadETagCacheIfNeeded() + let cacheRelativePath = cacheName ?? api + + // Primary first (no cache fallback so a primary failure proceeds to the fallback host), + // then fallback host (cache fallback allowed as last resort). + do { + return try await tryRequest( + api: api, cacheRelativePath: cacheRelativePath, + baseURL: primaryBaseURL, allowCacheFallback: false) + } catch { + return try await tryRequest( + api: api, cacheRelativePath: cacheRelativePath, + baseURL: fallbackBaseURL, allowCacheFallback: true) + } + } + + // MARK: - Request + + private func tryRequest( + api: String, cacheRelativePath: String, baseURL: String, allowCacheFallback: Bool + ) async throws -> Bool { + guard let url = URL(string: baseURL + api) else { + throw MaaApiError.invalidURL + } + let urlKey = url.absoluteString + let destination = cacheURL(for: cacheRelativePath) + let cacheExists = FileManager.default.fileExists(atPath: destination.path) + + var request = URLRequest(url: url) + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + // Send conditional headers only when a cached copy exists to validate against. + if cacheExists { + if let etag = etagCache[urlKey] { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + if let lastModified = lastModifiedCache[urlKey] { + request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since") + } + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + return try fallbackToCache(allowed: allowCacheFallback, cacheExists: cacheExists, underlying: error) + } + + guard let http = response as? HTTPURLResponse else { + return try fallbackToCache( + allowed: allowCacheFallback, cacheExists: cacheExists, underlying: MaaApiError.badResponse) + } + + switch http.statusCode { + case 304: + // Not modified: existing cache is valid. + return true + case 200..<300: + try writeCache(data: data, to: destination) + storeValidators(from: http, urlKey: urlKey) + return false + default: + return try fallbackToCache( + allowed: allowCacheFallback, cacheExists: cacheExists, + underlying: MaaApiError.httpStatus(http.statusCode)) + } + } + + private func fallbackToCache(allowed: Bool, cacheExists: Bool, underlying: Error) throws -> Bool { + if allowed, cacheExists { + return true + } + throw underlying + } + + private func writeCache(data: Data, to destination: URL) throws { + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: destination, options: .atomic) + } + + private func storeValidators(from http: HTTPURLResponse, urlKey: String) { + if let etag = http.value(forHTTPHeaderField: "Etag") { + etagCache[urlKey] = etag + } + if let lastModified = http.value(forHTTPHeaderField: "Last-Modified") { + lastModifiedCache[urlKey] = lastModified + } + saveETagCache() + } + + // MARK: - Cache Paths + + private func cacheURL(for api: String) -> URL { + cacheDir.appendingPathComponent(api, isDirectory: false) + } + + // MARK: - ETag persistence + + private var etagFile: URL { cacheDir.appendingPathComponent("etag.json") } + private var lastModifiedFile: URL { cacheDir.appendingPathComponent("last_modified.json") } + + private func loadETagCacheIfNeeded() { + guard !cacheLoaded else { return } + cacheLoaded = true + if let data = try? Data(contentsOf: etagFile), + let dict = try? JSONDecoder().decode([String: String].self, from: data) + { + etagCache = dict + } + if let data = try? Data(contentsOf: lastModifiedFile), + let dict = try? JSONDecoder().decode([String: String].self, from: data) + { + lastModifiedCache = dict + } + } + + private func saveETagCache() { + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + if let data = try? encoder.encode(etagCache) { + try? data.write(to: etagFile) + } + if let data = try? encoder.encode(lastModifiedCache) { + try? data.write(to: lastModifiedFile) + } + } +} + +enum MaaApiError: Error { + case invalidURL + case badResponse + case httpStatus(Int) +} diff --git a/MeoAsstMac/Utils/OTAFetcher.swift b/MeoAsstMac/Utils/OTAFetcher.swift deleted file mode 100644 index 35fc87d0a..000000000 --- a/MeoAsstMac/Utils/OTAFetcher.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// OTAFetcher.swift -// MAA -// -// Created by hguandl on 2025/6/9. -// - -import Foundation - -struct OTAFetcher: Sendable { - private let session: URLSession - - private let baseURL = URL(string: "https://api.maa.plus/MaaAssistantArknights/api/")! - - private let cacheURL = FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask).first! - .appendingPathComponent("cache") - - init(session: URLSession = .shared) { - self.session = session - } - - func download(path: String, name: String) async throws { - let destination = cacheURL.appendingPathComponent(name, isDirectory: false) - let source = baseURL.appendingPathComponent(path, isDirectory: false) - - let eTag = await cachedETag(path: path, at: destination) - var request = URLRequest(url: source) - request.setValue(eTag, forHTTPHeaderField: "If-None-Match") - - let (url, response) = try await session.download(for: request) - defer { try? FileManager.default.removeItem(at: url) } - guard let response = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - switch response.statusCode { - case 304: - return - case 200..<300: - try? FileManager.default.removeItem(at: destination) - try FileManager.default.createDirectory( - at: destination.deletingLastPathComponent(), - withIntermediateDirectories: true) - try FileManager.default.moveItem(at: url, to: destination) - await cacheETag(path, response.value(forHTTPHeaderField: "etag")) - default: - throw URLError(.resourceUnavailable) - } - } - - private func cachedETag(path: String, at url: URL) async -> String? { - guard FileManager.default.fileExists(atPath: url.path) else { - return nil - } - let eTags = await MainActor.run { - UserDefaults.standard.dictionary(forKey: "OTAETags") as? [String: String] - } - return eTags?[path] - } - - private func cacheETag(_ path: String, _ eTag: String?) async { - guard let eTag else { return } - await MainActor.run { - var eTags = UserDefaults.standard.dictionary(forKey: "OTAETags") ?? [:] - eTags[path] = eTag - UserDefaults.standard.set(eTags, forKey: "OTAETags") - } - } -} diff --git a/MeoAsstMac/Utils/StageCodeResolver.swift b/MeoAsstMac/Utils/StageCodeResolver.swift new file mode 100644 index 000000000..7a93617ef --- /dev/null +++ b/MeoAsstMac/Utils/StageCodeResolver.swift @@ -0,0 +1,98 @@ +// +// StageCodeResolver.swift +// MAA +// +// Resolves a copilot's internal stage id (e.g. "act50side_01") to the in-game stage +// code shown on screen (e.g. "TD-1"), which is what the core needs for auto-navigation +// in a copilot list. Mirrors the Windows `DataHelper.FindMap().Code` lookup with a +// title-based fallback (`FindStageName`). +// + +import Foundation + +enum StageCodeResolver { + /// Resolves the navigation code for a copilot. + /// - Parameters: + /// - stageName: the copilot's `stage_name` (internal stage id, e.g. "act50side_01"). + /// - title: the copilot documentation title (e.g. "TD-1 - 硬壳下的胆小鬼"), used as fallback. + /// - Returns: the stage code used for navigation (e.g. "TD-1"); falls back to `stageName`. + static func navigationCode(stageName: String, title: String?) -> String { + if let code = codeFromTilePos(stageId: stageName), !code.isEmpty { + return code + } + if let code = stageCodeFromTitle(title), !code.isEmpty { + return code + } + // Last resort: the internal stage name. For permanent stages whose stage_name already + // equals the code (rare), this still works. + return stageName + } + + // MARK: - Tile-Pos lookup (authoritative) + + /// Looks up the `code` field from the Arknights-Tile-Pos resource for the given stage id. + /// Tile-Pos files are named `{stageId}-...-level_{stageId}.json`. + private static func codeFromTilePos(stageId: String) -> String? { + guard !stageId.isEmpty else { return nil } + + for directory in tilePosDirectories() { + guard let url = matchingTilePosFile(in: directory, stageId: stageId) else { continue } + guard let data = try? Data(contentsOf: url), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let code = obj["code"] as? String + else { + continue + } + return code + } + return nil + } + + /// Finds the Tile-Pos file whose name starts with `{stageId}-` (the leading token is the + /// stage id). Returns the first match. + private static func matchingTilePosFile(in directory: URL, stageId: String) -> URL? { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { + return nil + } + let prefix = stageId + "-" + return entries.first { $0.lastPathComponent.hasPrefix(prefix) } + } + + /// Candidate Tile-Pos directories: user-updated resource first, then the bundled resource. + private static func tilePosDirectories() -> [URL] { + var dirs: [URL] = [] + let documents = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask).first + if let userResource = documents? + .appendingPathComponent("resource") + .appendingPathComponent("Arknights-Tile-Pos") + { + dirs.append(userResource) + } + if let bundled = Bundle.main.resourceURL? + .appendingPathComponent("resource") + .appendingPathComponent("Arknights-Tile-Pos") + { + dirs.append(bundled) + } + return dirs.filter { FileManager.default.fileExists(atPath: $0.path) } + } + + // MARK: - Title fallback + + /// Extracts a stage code from a copilot title, mirroring the Windows `FindStageName` regex. + /// e.g. "TD-1 - 硬壳下的胆小鬼" → "TD-1". + private static func stageCodeFromTitle(_ title: String?) -> String? { + guard let title, !title.isEmpty else { return nil } + let pattern = #"(?:[a-zA-Z]{0,3})(?:\d{0,2})-(?:(?:A|B|C|D|EX|S|TR|MO)-?)?(?:\d{1,2})"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(title.startIndex..., in: title) + guard let match = regex.firstMatch(in: title, range: range), + let matchRange = Range(match.range, in: title) + else { + return nil + } + return String(title[matchRange]) + } +} diff --git a/MeoAsstMac/Views/CopilotListView.swift b/MeoAsstMac/Views/CopilotListView.swift new file mode 100644 index 000000000..23e86e897 --- /dev/null +++ b/MeoAsstMac/Views/CopilotListView.swift @@ -0,0 +1,118 @@ +// +// CopilotListView.swift +// MAA +// +// UI for the Copilot List (作业集) feature: an ordered checklist of copilot +// operations that run in sequence, with per-entry raid toggle and global options. +// + +import SwiftUI + +struct CopilotListView: View { + @EnvironmentObject private var viewModel: MAAViewModel + @Binding var selection: CopilotEntry.ID? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if viewModel.copilotList.isEmpty { + Text("作业集为空,请在右侧作业详情点击「加入作业集」") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + List(selection: $selection) { + ForEach($viewModel.copilotList) { $entry in + CopilotListRow(entry: $entry) + .tag(entry.id) + } + .onMove(perform: moveEntry) + .onDelete(perform: deleteEntry) + } + } + + Divider() + + optionsPanel + .padding(8) + } + } + + // MARK: - Options + + @ViewBuilder private var optionsPanel: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Toggle("自动编队", isOn: $viewModel.copilotListOptions.formation) + Toggle("信赖干员", isOn: $viewModel.copilotListOptions.addTrust) + } + + Toggle("自动使用理智药", isOn: $viewModel.copilotListOptions.useSanityPotion) + + Picker("助战干员", selection: $viewModel.copilotListOptions.supportUnitUsage) { + Text("不使用").tag(0) + Text("仅在需要时").tag(1) + Text("随机助战").tag(3) + } + } + .font(.callout) + } + + // MARK: - Actions + + private func moveEntry(from: IndexSet, to: Int) { + viewModel.copilotList.move(fromOffsets: from, toOffset: to) + } + + private func deleteEntry(at offsets: IndexSet) { + viewModel.copilotList.remove(atOffsets: offsets) + } +} + +// MARK: - Row + +private struct CopilotListRow: View { + @Binding var entry: CopilotEntry + + /// Display title resolved from the copilot file's documentation, loaded once per file. + @State private var title: String? + + var body: some View { + HStack(spacing: 10) { + Toggle("", isOn: $entry.isChecked) + .labelsHidden() + .toggleStyle(.checkbox) + + Text(displayName) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Toggle("突袭", isOn: $entry.isRaid) + .toggleStyle(.button) + .controlSize(.small) + } + .task(id: entry.filePath) { + title = MAACopilot(url: URL(fileURLWithPath: entry.filePath))?.doc?.title + } + } + + /// Prefer the copilot's documentation title; fall back to the stage name, then file name. + private var displayName: String { + if let title, !title.isEmpty { + return title + } + if !entry.name.isEmpty { + return entry.name + } + return URL(fileURLWithPath: entry.filePath).lastPathComponent + } +} + +struct CopilotListView_Previews: PreviewProvider { + static var previews: some View { + CopilotListView(selection: .constant(nil)) + .environmentObject(MAAViewModel()) + } +} diff --git a/MeoAsstMac/Views/CopilotView.swift b/MeoAsstMac/Views/CopilotView.swift index b00662e38..21e0f07dc 100644 --- a/MeoAsstMac/Views/CopilotView.swift +++ b/MeoAsstMac/Views/CopilotView.swift @@ -11,18 +11,29 @@ struct CopilotView: View { @EnvironmentObject private var viewModel: MAAViewModel let url: URL + /// In preview mode (used by the copilot-list detail pane) only the documentation is + /// shown — the single-copilot configuration toggles are hidden and `viewModel.copilot` + /// is not modified, so previewing a list entry never overwrites the single-copilot run state. + var previewOnly = false + var body: some View { if let copilot = MAACopilot(url: url) { VStack(spacing: 20) { - pilotConfiguration() + if !previewOnly { + pilotConfiguration() - Divider() + Divider() + } ScrollView { pilotDescription(pilot: copilot) } } - .task(id: url) { updateCopilot() } + .task(id: url) { + if !previewOnly { + updateCopilot() + } + } } else { Text("文件格式错误") } @@ -68,6 +79,10 @@ struct CopilotView: View { } .frame(maxWidth: 130) + case .list: + // 作业集的配置项在 CopilotListView 中展示,此处单作业详情区留空 + EmptyView() + case .none: EmptyView() } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index aee47be4d..82d59c375 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4916,6 +4916,486 @@ } } } + }, + "可刷取:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drops: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "획득 가능: %@" + } + } + } + }, + "PR-B-1/2: 狙击、术师芯片" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-B-1/2: Sniper & Caster Chip" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-B-1/2: 스나이퍼·캐스터 칩" + } + } + } + }, + "CA-5: 技能书" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CA-5: Skill Summary" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CA-5: 스킬 요약" + } + } + } + }, + "LS-6: 经验书" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS-6: EXP" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS-6: 경험치" + } + } + } + }, + "AP-5: 红票" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AP-5: Red Cert." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AP-5: 붉은 인증서" + } + } + } + }, + "作业集为空,请先勾选作业" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The copilot set is empty. Please select operations first." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트가 비어 있습니다. 먼저 작전을 선택하세요." + } + } + } + }, + "PR-C-1/2: 先锋、辅助芯片" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-C-1/2: Vanguard & Supporter Chip" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-C-1/2: 뱅가드·서포터 칩" + } + } + } + }, + "导入作业集" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Copilot Set" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트 가져오기" + } + } + } + }, + "突袭" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raid" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비상 작전" + } + } + } + }, + "正在导入作业集,共 %lld 份作业……" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importing copilot set, %lld operations in total…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트 가져오는 중, 총 %lld개…" + } + } + } + }, + "作业集" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copilot Set" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트" + } + } + } + }, + "作业集获取失败,请确认这是作业集神秘代码而非单份作业: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to fetch copilot set. Make sure this is a set code, not a single operation: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트를 가져오지 못했습니다. 단일 작전이 아닌 세트 코드인지 확인하세요: %@" + } + } + } + }, + "作业解析失败 (%lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to parse operation (%lld)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 분석 실패 (%lld)" + } + } + } + }, + "未知关卡" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown Stage" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알 수 없는 스테이지" + } + } + } + }, + "将当前作业加入作业集" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add the current copilot to the set" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 작전을 세트에 추가" + } + } + } + }, + "随机助战" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Random Support" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "무작위 지원" + } + } + } + }, + "PR-A-1/2: 重装、医疗芯片" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-A-1/2: Guard & Medic Chip" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-A-1/2: 디펜더·메딕 칩" + } + } + } + }, + "单个作业" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single Copilot" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "단일 작전" + } + } + } + }, + "隐藏未开放关卡" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Unavailable Stages" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미개방 스테이지 숨기기" + } + } + } + }, + "自动使用理智药" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto Use Sanity Potion" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이성 회복제 자동 사용" + } + } + } + }, + "作业集导入完成,成功 %lld 份" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copilot set imported: %lld succeeded" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트 가져오기 완료: %lld개 성공" + } + } + } + }, + "加入作业集" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add to Set" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세트에 추가" + } + } + } + }, + "助战干员" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support Unit" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지원 오퍼레이터" + } + } + } + }, + "仅在需要时" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When Needed" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "필요할 때만" + } + } + } + }, + "作业集为空,请在右侧作业详情点击「加入作业集」" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The copilot set is empty. Add operations via \"Add to Set\" in the detail panel." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트가 비어 있습니다. 상세 패널의 \"세트에 추가\"로 작전을 추가하세요." + } + } + } + }, + "PR-D-1/2: 近卫、特种芯片" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-D-1/2: Guard & Specialist Chip" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR-D-1/2: 가드·스페셜리스트 칩" + } + } + } + }, + "关卡数据热更新失败: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stage data hot-update failed: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스테이지 데이터 핫 업데이트 실패: %@" + } + } + } + }, + "SK-5: 碳条" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SK-5: Carbon" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "SK-5: 카본" + } + } + } + }, + "作业集导入完成,成功 %lld/%lld 份,部分作业下载失败" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copilot set imported: %lld/%lld succeeded, some downloads failed" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작전 세트 가져오기: %lld/%lld개 성공, 일부 다운로드 실패" + } + } + } + }, + "CE-6: 龙门币" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CE-6: LMD" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CE-6: 용문폐" + } + } + } } }, "version" : "1.0"