// Main fleet console store — snapshot stream, selection, XP, toasts, source switch.
// Exports: FleetStore.
import Foundation
import SwiftUI
@MainActor
@Observable
final class FleetStore {
var snapshot: FleetSnapshot
var selectedTab: CenterTab
var selectedSectorID: String?
var selectedMissionID: MissionID?
var missionDetail: MissionDetail?
var actionMessage: String?
var documentPayload: DocumentPayload?
var xpState: XPState
var toasts: [ToastEvent] = []
var commanderName: String
var showSettings = false
var showLeftRail = false
var connectionConfig: ConnectionConfig
var probeResult: String?
private var streamTask: Task<Void, Never>?
private var detailTask: Task<Void, Never>?
private let demoSource = DemoSource()
private var liveSource: LiveSource?
init() {
connectionConfig = ConnectionConfig.load()
snapshot = DemoDataset.initialSnapshot()
xpState = XPState(xp: UserDefaults.standard.integer(forKey: Self.xpKey).nonZeroOr(4280))
commanderName = UserDefaults.standard.string(forKey: Self.commanderKey) ?? "CMDR"
selectedTab = Self.loadTab()
selectedSectorID = Self.loadSectorID(fallback: snapshot.sectors.first?.id)
// Prefer a real selection over an empty brief with a half-filled unit card.
selectedMissionID = snapshot.sectors.flatMap(\.missions).first?.id
startSource()
loadDetail(for: selectedMissionID)
}
func startSource() {
streamTask?.cancel()
liveSource?.updateConfig(connectionConfig)
if connectionConfig.source == .live {
let source = liveSource ?? LiveSource(config: connectionConfig)
source.updateConfig(connectionConfig)
liveSource = source
selectedMissionID = nil
missionDetail = nil
var previous = snapshot
streamTask = Task {
for await update in source.snapshots() {
guard !Task.isCancelled else { break }
handleUpdate(update, previous: &previous)
}
}
} else {
startDemoStream()
}
}
func startDemoStream() {
streamTask?.cancel()
var previous = snapshot
streamTask = Task {
for await update in demoSource.snapshots() {
guard !Task.isCancelled else { break }
handleUpdate(update, previous: &previous)
}
}
}
func applyConnection(_ config: ConnectionConfig) throws {
try config.persist(token: config.token)
connectionConfig = ConnectionConfig.load()
if ProcessInfo.processInfo.environment["AID_BASE_URL"] != nil {
connectionConfig = config
}
startSource()
}
func refresh() {
startSource()
}
func runProbe() async {
var config = connectionConfig
config.token = config.token ?? KeychainTokenStore.load()
probeResult = await ConnectionProbe.test(config: config)
}
func selectMission(_ id: MissionID?) {
selectedMissionID = id
loadDetail(for: id)
}
func selectSector(_ id: String?) {
selectedSectorID = id
persistSectorID(id)
persistTab(.hangar)
}
func persistTab(_ tab: CenterTab) {
selectedTab = tab
UserDefaults.standard.set(tab.rawValue, forKey: Self.tabKey)
}
func applyLaunchState() {
selectedTab = Self.loadTab()
selectedSectorID = Self.loadSectorID(fallback: snapshot.sectors.first?.id)
if selectedMissionID == nil {
selectedMissionID = snapshot.sectors.flatMap(\.missions).first?.id
loadDetail(for: selectedMissionID)
}
}
func dismissToast(_ id: UUID) {
toasts.removeAll { $0.id == id }
}
func persistCommanderName(_ name: String) {
commanderName = name
UserDefaults.standard.set(name, forKey: Self.commanderKey)
}
func performAction(_ action: MissionAction) async {
guard let id = selectedMissionID else {
actionMessage = "no mission selected"
return
}
do {
switch action {
case .diff:
let text = try await activeSource().diff(id)
documentPayload = DocumentPayload(title: "DIFF · \(id)", body: text, allowsSave: true)
actionMessage = nil
case .export:
let text = try await activeSource().result(id)
documentPayload = DocumentPayload(title: "EXPORT · \(id)", body: text, allowsSave: true)
actionMessage = nil
default:
let result = try await activeSource().act(action, on: id)
snapshot = activeSource().currentSnapshot()
actionMessage = result.message
if result.ok {
loadDetail(for: id)
}
}
} catch {
actionMessage = LiveSource.message(for: error)
}
}
var selectedSector: Sector? {
guard let selectedSectorID else { return snapshot.sectors.first }
return snapshot.sectors.first { $0.id == selectedSectorID } ?? snapshot.sectors.first
}
private func activeSource() -> any FleetDataSource {
if connectionConfig.source == .live, let liveSource {
return liveSource
}
return demoSource
}
private func loadDetail(for id: MissionID?) {
detailTask?.cancel()
guard let id else {
missionDetail = nil
return
}
detailTask = Task {
let loaded = try? await activeSource().detail(id)
guard !Task.isCancelled else { return }
missionDetail = loaded
}
}
private func handleUpdate(_ update: FleetSnapshot, previous: inout FleetSnapshot) {
// LiveSource emits an empty "connecting" snapshot first — don't wipe a painted console.
if update.sectors.isEmpty, !previous.sectors.isEmpty {
var kept = previous
kept.connection = update.connection
kept.serverVersion = update.serverVersion == "—" ? previous.serverVersion : update.serverVersion
snapshot = kept
previous = kept
return
}
detectTransitions(from: previous, to: update)
snapshot = update
if selectedSectorID == nil || !update.sectors.contains(where: { $0.id == selectedSectorID }) {
selectedSectorID = update.sectors.first?.id
}
let priorMission = selectedMissionID
ensureMissionSelection(in: update)
if selectedMissionID != priorMission {
missionDetail = nil
}
if let id = selectedMissionID {
loadDetail(for: id)
} else {
missionDetail = nil
}
previous = update
}
private func ensureMissionSelection(in update: FleetSnapshot) {
let ids = update.sectors.flatMap(\.missions).map(\.id)
if let selectedMissionID, ids.contains(selectedMissionID) { return }
selectedMissionID = ids.first
}
private func detectTransitions(from old: FleetSnapshot, to new: FleetSnapshot) {
let oldMap = missionMap(old)
for sector in new.sectors {
for mission in sector.missions {
guard let prior = oldMap[mission.id], prior.state == .run else { continue }
if mission.state == .done || mission.state == .fail {
let payload = PayloadDeriver.derive(from: mission, sectorTag: sector.tag)
let xp = mission.state == .done ? (payload?.rarity.xp ?? PayloadRarity.rare.xp) : 20
xpState.award(for: mission.state, payloadXP: xp)
UserDefaults.standard.set(xpState.xp, forKey: Self.xpKey)
let toast = ToastEvent(
id: UUID(), missionID: mission.id, title: mission.title,
state: mission.state, xpAward: xp
)
toasts.append(toast)
scheduleDismiss(toast.id)
}
}
}
}
private func scheduleDismiss(_ id: UUID) {
Task {
try? await Task.sleep(for: .seconds(5))
dismissToast(id)
}
}
private func missionMap(_ snapshot: FleetSnapshot) -> [MissionID: Mission] {
var map: [MissionID: Mission] = [:]
for sector in snapshot.sectors {
for mission in sector.missions {
map[mission.id] = mission
}
}
return map
}
private static func loadTab() -> CenterTab {
guard let raw = UserDefaults.standard.string(forKey: tabKey),
let tab = CenterTab(rawValue: raw) else {
return .fleetLog
}
return tab
}
private static func loadSectorID(fallback: String?) -> String? {
UserDefaults.standard.string(forKey: sectorKey) ?? fallback
}
private func persistSectorID(_ id: String?) {
if let id {
UserDefaults.standard.set(id, forKey: Self.sectorKey)
}
}
private static let xpKey = "aid.command.xp"
private static let commanderKey = "aid.command.commander"
private static let tabKey = "aid.command.tab"
private static let sectorKey = "aid.command.sector"
}
private extension Int {
func nonZeroOr(_ fallback: Int) -> Int {
self == 0 ? fallback : self
}
}