Zum Inhalt

apple/PhysarumApp/Sources/Model/PresetSlots.swift

import PhysarumEngine

/// The fixed, save-game-style preset store: 24 slots, each empty or holding one Preset, plus the pinned
/// set, the seed version, and a per-slot `userOwned` flag (false while a slot tracks the shipped default,
/// true once the user has saved into it — that flag is what makes "an update never overwrites your edits"
/// work). Pure value type: no persistence, no iCloud — `SlotStore` adds those. Host-testable.
struct PresetSlots: Equatable {
    static let count = 24
    static func slotID(_ index: Int) -> String { String(format: "slot-%02d", index) }
    static func index(of slotID: String) -> Int? { Int(slotID.dropFirst("slot-".count)) }

    private struct Slot: Equatable { var preset: Preset?; var userOwned: Bool }
    private var slots: [Slot]
    private(set) var pinned: Set<String> = []
    private(set) var seedVersion: Int = 0

    init() { slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count) }

    var count: Int { slots.count }
    func preset(at index: Int) -> Preset? { slots.indices.contains(index) ? slots[index].preset : nil }
    func isUserOwned(_ index: Int) -> Bool { slots.indices.contains(index) ? slots[index].userOwned : false }

    /// The filled presets, in slot order (this is what the library shows and selection resolves against).
    var filled: [Preset] { slots.compactMap { $0.preset } }

    var firstEmptyIndex: Int? { slots.firstIndex { $0.preset == nil } }

    mutating func fill(index: Int, preset: Preset, userOwned: Bool) {
        guard slots.indices.contains(index) else { return }
        var p = preset; p.id = Self.slotID(index)   // a slot's preset id IS its slot id (stable identity)
        slots[index] = Slot(preset: p, userOwned: userOwned)
    }

    mutating func overwrite(slotID: String, preset: Preset) {
        guard let i = Self.index(of: slotID) else { return }
        fill(index: i, preset: preset, userOwned: true)   // a user save always marks the slot owned
    }

    mutating func clear(slotID: String) {
        guard let i = Self.index(of: slotID), slots.indices.contains(i) else { return }
        slots[i] = Slot(preset: nil, userOwned: false)
        pinned.remove(slotID)
    }

    func isPinned(_ slotID: String) -> Bool { pinned.contains(slotID) }
    mutating func pin(_ slotID: String) { if preset(atSlotID: slotID) != nil { pinned.insert(slotID) } }
    mutating func unpin(_ slotID: String) { pinned.remove(slotID) }

    private func preset(atSlotID id: String) -> Preset? { Self.index(of: id).flatMap { preset(at: $0) } }

    struct Factory: Equatable {
        var presets: [Preset]            // slot 0..presets.count-1
        var version: Int
        var pinnedSlotIDs: Set<String> = []
    }

    /// First run (empty) or an update with a newer factory version: write each factory preset into its
    /// slot ONLY if that slot is empty or not user-owned. User-owned slots are sacrosanct. Pins are only
    /// applied on the very first seed (when seedVersion was 0), so an update never re-pins behind the user.
    mutating func seed(_ factory: Factory) {
        guard factory.version > seedVersion else { return }
        let firstSeed = seedVersion == 0
        for (i, preset) in factory.presets.enumerated() where slots.indices.contains(i) {
            if slots[i].preset == nil || !slots[i].userOwned {
                fill(index: i, preset: preset, userOwned: false)
            }
        }
        if firstSeed { pinned = factory.pinnedSlotIDs.filter { preset(atSlotID: $0) != nil } }
        seedVersion = factory.version
    }

    /// Deliberate factory reset (the "Reset all Presets" button): every slot back to its factory default
    /// (or empty), ownership cleared, pins back to factory pins.
    mutating func resetToFactory(_ factory: Factory) {
        slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count)
        for (i, preset) in factory.presets.enumerated() where slots.indices.contains(i) {
            fill(index: i, preset: preset, userOwned: false)
        }
        pinned = factory.pinnedSlotIDs.filter { preset(atSlotID: $0) != nil }
        seedVersion = factory.version
    }

    /// Internal setter so extensions in other files can restore `seedVersion` from a payload without
    /// bypassing `private(set)` (which only permits same-file mutation from outside the struct).
    mutating func applySeedVersion(_ v: Int) { seedVersion = v }

    /// First non-empty, non-user-owned slot (a factory default not yet touched by the user) — the
    /// fallback target for migration AND import once every slot is filled, since the factory ships
    /// all 24 slots (a displaced factory default is recoverable via Reset; the user's import is not).
    var firstNonUserOwnedIndex: Int? {
        slots.firstIndex { $0.preset != nil && !$0.userOwned }
    }

    /// One-shot migration of the pre-slot custom presets, marked userOwned so a future factory update
    /// never clobbers them. Each goes into the lowest empty slot; when none are empty (the factory fills
    /// all 24), it displaces the lowest factory default instead (those are recoverable via Reset, the
    /// user's own prior work is not — so user work wins). Only when every slot is already user-owned is a
    /// custom returned as overflow for the caller to export rather than drop.
    mutating func migrate(customs: [Preset]) -> [Preset] {
        var overflow: [Preset] = []
        for custom in customs {
            if let i = firstEmptyIndex {
                fill(index: i, preset: custom, userOwned: true)
            } else if let i = firstNonUserOwnedIndex {
                fill(index: i, preset: custom, userOwned: true)
            } else {
                overflow.append(custom)
            }
        }
        return overflow
    }

    /// Collapse to just the pinned presets, packed into the lowest slots (`slot-00`, `slot-01`, …) in
    /// slot order; every other slot is emptied. Pins are remapped to the new slot ids (so the kept set
    /// stays pinned) and each kept preset's `userOwned` flag is preserved. No-op when nothing is pinned
    /// — that would wipe everything (the caller's button is also disabled in that state).
    mutating func keepOnlyPinned() {
        let kept: [(Preset, Bool)] = slots.compactMap { slot in
            guard let p = slot.preset, pinned.contains(p.id) else { return nil }
            return (p, slot.userOwned)
        }
        guard !kept.isEmpty else { return }
        slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count)
        pinned = []
        for (i, pair) in kept.enumerated() {
            fill(index: i, preset: pair.0, userOwned: pair.1)
            pinned.insert(Self.slotID(i))
        }
    }

    /// The ATV lean-back rotation: the pinned filled slots in slot order; nothing pinned → all filled.
    var rotation: [Preset] {
        let pins = slots.compactMap { $0.preset }.filter { pinned.contains($0.id) }
        return pins.isEmpty ? filled : pins
    }
}