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
}
}