From 890e4c5bfaf2ad34a1837348f6e65c41c380bfbf Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 28 Mar 2026 21:42:15 -0700 Subject: [PATCH 1/5] refactor: redesign slot types, tighten protocol contracts, fix concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package structure: - Remove #if canImport(Darwin) gate; DHKit and DHKitTests are now unconditional, cross-platform targets - Update Linux CI to build DHKit and run DHKitTests Slot type redesign: - Extract AdversarySlot and EnvironmentSlot from EncounterSession.swift into Sources/DHModels/ alongside PlayerSlot - Make all mutable slot properties `let`; types are now read-only value projections - Remove nonisolated annotation from all three structs (was a no-op) - Remove AdversarySlot.make(from:customName:) public factory; construction from Adversary now happens internally in EncounterSession - EncounterSession stores private _adversarySlots/_playerSlots/_environmentSlots and exposes public read-only computed properties; mutations use copy-with-update (no more in-place var mutation footgun) - Remove modifying(in:id:_:) helper (required { get set }; no longer needed) Protocol changes + mutation API: - CombatParticipant is now { get } only — pure read/display contract - All combat mutation methods take UUID instead of some CombatParticipant - spotlight(_:) keeps its EncounterParticipant-based signature - heal renamed to applyHealing for consistency API naming: - DifficultyBudget.cost(for:) internal param type → role - Compendium.searchAdversaries(query:) → adversaries(matching:) Concurrency: - Add @concurrent to Compendium.decodeArray - Add reentrancy guards (in-flight Sets/flag) to all four async EncounterStore methods - EncounterStoreError stores String descriptions instead of Error payloads; conforms to Sendable --- .github/workflows/linux.yml | 6 + Package.swift | 52 ++- Sources/DHKit/Compendium.swift | 4 +- Sources/DHKit/EncounterSession.swift | 362 ++++++++++--------- Sources/DHKit/EncounterStore.swift | 36 +- Sources/DHModels/AdversarySlot.swift | 63 ++++ Sources/DHModels/DifficultyBudget.swift | 4 +- Sources/DHModels/EncounterParticipant.swift | 11 +- Sources/DHModels/EnvironmentSlot.swift | 33 ++ Sources/DHModels/PlayerSlot.swift | 12 +- Tests/DHKitTests/EncounterSessionTests.swift | 67 ++-- Tests/DHKitTests/EncounterStoreTests.swift | 8 +- Tests/DHKitTests/SessionRegistryTests.swift | 2 +- 13 files changed, 398 insertions(+), 262 deletions(-) create mode 100644 Sources/DHModels/AdversarySlot.swift create mode 100644 Sources/DHModels/EnvironmentSlot.swift diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 5e760c1..5db22b2 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -20,12 +20,18 @@ jobs: - name: Build DHModels run: swift build --target DHModels + - name: Build DHKit + run: swift build --target DHKit + - name: Build validate-dhpack run: swift build --target validate-dhpack - name: Test DHModels run: swift test --filter DHModelsTests + - name: Test DHKit + run: swift test --filter DHKitTests + swift-format: name: swift-format lint runs-on: ubuntu-latest diff --git a/Package.swift b/Package.swift index 1607942..8c79c9a 100644 --- a/Package.swift +++ b/Package.swift @@ -7,18 +7,33 @@ let sharedSettings: [SwiftSetting] = [ .enableUpcomingFeature("MemberImportVisibility"), ] -var products: [Product] = [ +let products: [Product] = [ .library(name: "DHModels", targets: ["DHModels"]), + .library(name: "DHKit", targets: ["DHKit"]), .executable(name: "validate-dhpack", targets: ["validate-dhpack"]), ] -var targets: [Target] = [ +let targets: [Target] = [ // Pure Codable value types — no Apple-only imports, compiles on Linux. .target( name: "DHModels", swiftSettings: sharedSettings ), + // Observable stores + SRD bundle resources. + .target( + name: "DHKit", + dependencies: [ + "DHModels", + .product(name: "Logging", package: "swift-log"), + ], + resources: [ + .copy("Resources/adversaries.json"), + .copy("Resources/environments.json"), + ], + swiftSettings: sharedSettings + [.defaultIsolation(MainActor.self)] + ), + // CLI tool for validating .dhpack files — depends only on DHModels. .executableTarget( name: "validate-dhpack", @@ -36,33 +51,14 @@ var targets: [Target] = [ resources: [.copy("Fixtures")], swiftSettings: sharedSettings ), -] - -#if canImport(Darwin) - products.append(.library(name: "DHKit", targets: ["DHKit"])) - targets += [ - // Observable stores + SRD bundle resources — Apple platforms only. - .target( - name: "DHKit", - dependencies: [ - "DHModels", - .product(name: "Logging", package: "swift-log"), - ], - resources: [ - .copy("Resources/adversaries.json"), - .copy("Resources/environments.json"), - ], - swiftSettings: sharedSettings + [.defaultIsolation(MainActor.self)] - ), - // Tests for DHKit — Apple platforms only. - .testTarget( - name: "DHKitTests", - dependencies: ["DHKit"], - swiftSettings: sharedSettings + [.defaultIsolation(MainActor.self)] - ), - ] -#endif + // Tests for DHKit. + .testTarget( + name: "DHKitTests", + dependencies: ["DHKit"], + swiftSettings: sharedSettings + [.defaultIsolation(MainActor.self)] + ), +] let package = Package( name: "DHModels", diff --git a/Sources/DHKit/Compendium.swift b/Sources/DHKit/Compendium.swift index 18d95dc..2d3b849 100644 --- a/Sources/DHKit/Compendium.swift +++ b/Sources/DHKit/Compendium.swift @@ -252,7 +252,7 @@ public final class Compendium { /// Full-text search across adversary names and descriptions. /// Uses `localizedStandardContains` for diacritic- and case-insensitive matching. - public func searchAdversaries(query: String) -> [Adversary] { + public func adversaries(matching query: String) -> [Adversary] { guard !query.isEmpty else { return adversaries } return adversaries.filter { $0.name.localizedStandardContains(query) || $0.flavorText.localizedStandardContains(query) @@ -336,7 +336,7 @@ public final class Compendium { // MARK: - Private Helpers - nonisolated private static func decodeArray( + @concurrent nonisolated private static func decodeArray( _ type: T.Type, fromResource name: String, bundle: Bundle ) async throws -> [T] { guard let url = bundle.url(forResource: name, withExtension: "json") else { diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index 5d2577e..8b9f50c 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -8,8 +8,9 @@ // // Design notes: // - EncounterSession is @Observable so SwiftUI views bind to it directly. -// - AdversarySlot and EnvironmentSlot are structs stored in the session's -// arrays; mutations flow through the session class. +// - AdversarySlot, PlayerSlot, and EnvironmentSlot are immutable structs stored +// in private backing arrays; mutations replace the affected struct wholesale +// (copy-with-update). Public computed properties expose read-only snapshots. // - Fear and Hope are tracked on the session; individual adversary stress // contributes to Fear when thresholds are crossed (GM's discretion). // - The `spotlightedSlotID` drives spotlight management in the UI. @@ -20,94 +21,6 @@ import Foundation import Logging import Observation -// MARK: - AdversarySlot - -/// A single adversary participant in a live encounter. -/// -/// Wraps a reference to a catalog ``Adversary`` with runtime mutable state: -/// current HP, current Stress, defeat status, and an optional individual name -/// (useful when running multiple copies of the same adversary). -/// -/// `maxHP` and `maxStress` are snapshotted from the catalog at slot-creation -/// time so that HP/stress clamping works correctly even if the source adversary -/// is later edited or removed from the ``Compendium`` (homebrew orphan safety). -nonisolated public struct AdversarySlot: CombatParticipant, Sendable, Equatable, Hashable { - public let id: UUID - /// The slug that identifies this adversary in the ``Compendium``. - public let adversaryID: String - /// Display name override (e.g. "Grimfang" for a named bandit leader). - /// Falls back to the catalog name when `nil`. - public var customName: String? - - // MARK: Stat Snapshot (from catalog at creation time) - public let maxHP: Int - public let maxStress: Int - - // MARK: Tracked Stats - public var currentHP: Int - public var currentStress: Int - public var isDefeated: Bool - public var conditions: Set - - // MARK: - Init - - public init( - id: UUID = UUID(), - adversaryID: String, - customName: String? = nil, - maxHP: Int, - maxStress: Int, - currentHP: Int? = nil, - currentStress: Int = 0, - isDefeated: Bool = false, - conditions: Set = [] - ) { - self.id = id - self.adversaryID = adversaryID - self.customName = customName - self.maxHP = maxHP - self.maxStress = maxStress - self.currentHP = currentHP ?? maxHP - self.currentStress = currentStress - self.isDefeated = isDefeated - self.conditions = conditions - } - - /// Convenience factory: create a slot pre-populated from a catalog entry. - public static func make(from adversary: Adversary, customName: String? = nil) -> AdversarySlot { - AdversarySlot( - adversaryID: adversary.id, - customName: customName, - maxHP: adversary.hp, - maxStress: adversary.stress - ) - } -} - -// MARK: - EnvironmentSlot - -/// An environment element active in the current encounter scene. -/// -/// Environments have no HP or Stress — they are tracked only for -/// their features and activation state. -nonisolated public struct EnvironmentSlot: EncounterParticipant, Sendable, Equatable, Hashable { - public let id: UUID - /// The slug identifying this environment in the ``Compendium``. - public let environmentID: String - /// Whether this environment element is currently active/visible to players. - public var isActive: Bool - - public init( - id: UUID = UUID(), - environmentID: String, - isActive: Bool = true - ) { - self.id = id - self.environmentID = environmentID - self.isActive = isActive - } -} - // MARK: - EncounterSession /// The live state of a Daggerheart encounter being run at the table. @@ -143,10 +56,17 @@ public final class EncounterSession: Identifiable, Hashable { public let id: UUID public var name: String - // MARK: Participants - public var adversarySlots: [AdversarySlot] - public var playerSlots: [PlayerSlot] - public var environmentSlots: [EnvironmentSlot] + // MARK: Participants (private backing stores) + private var _adversarySlots: [AdversarySlot] + private var _playerSlots: [PlayerSlot] + private var _environmentSlots: [EnvironmentSlot] + + /// Read-only snapshot of all adversary slots. + public var adversarySlots: [AdversarySlot] { _adversarySlots } + /// Read-only snapshot of all player slots. + public var playerSlots: [PlayerSlot] { _playerSlots } + /// Read-only snapshot of all environment slots. + public var environmentSlots: [EnvironmentSlot] { _environmentSlots } // MARK: Fear & Hope /// The GM's Fear pool. Increases when players roll with Fear, @@ -183,9 +103,9 @@ public final class EncounterSession: Identifiable, Hashable { ) { self.id = id self.name = name - self.adversarySlots = adversarySlots - self.playerSlots = playerSlots - self.environmentSlots = environmentSlots + self._adversarySlots = adversarySlots + self._playerSlots = playerSlots + self._environmentSlots = environmentSlots self.fearPool = fearPool self.hopePool = hopePool self.spotlightedSlotID = nil @@ -197,18 +117,23 @@ public final class EncounterSession: Identifiable, Hashable { /// Add a new adversary slot populated from a catalog entry. public func add(adversary: Adversary, customName: String? = nil) { - let slot = AdversarySlot.make(from: adversary, customName: customName) - adversarySlots.append(slot) + _adversarySlots.append( + AdversarySlot( + adversaryID: adversary.id, + customName: customName, + maxHP: adversary.hp, + maxStress: adversary.stress + )) } /// Add an environment slot. public func add(environment: DaggerheartEnvironment) { - environmentSlots.append(EnvironmentSlot(environmentID: environment.id)) + _environmentSlots.append(EnvironmentSlot(environmentID: environment.id)) } /// Remove an adversary slot by ID. public func removeAdversary(id: UUID) { - adversarySlots.removeAll { $0.id == id } + _adversarySlots.removeAll { $0.id == id } if spotlightedSlotID == id { spotlightedSlotID = nil } } @@ -216,12 +141,12 @@ public final class EncounterSession: Identifiable, Hashable { /// Add a player slot to the encounter. public func add(player: PlayerSlot) { - playerSlots.append(player) + _playerSlots.append(player) } /// Remove a player slot by ID. public func removePlayer(id: UUID) { - playerSlots.removeAll { $0.id == id } + _playerSlots.removeAll { $0.id == id } if spotlightedSlotID == id { spotlightedSlotID = nil } } @@ -247,106 +172,211 @@ public final class EncounterSession: Identifiable, Hashable { // MARK: - HP & Stress Mutations - /// Apply damage to any combat participant, clamping HP to 0. + /// Apply damage to a combat participant by ID, clamping HP to 0. /// Adversary slots are marked ``AdversarySlot/isDefeated`` when HP reaches 0. - public func applyDamage(_ amount: Int, to participant: some CombatParticipant) { - let id = participant.id - if let i = adversarySlots.firstIndex(where: { $0.id == id }) { - adversarySlots[i].currentHP = max(0, adversarySlots[i].currentHP - amount) - if adversarySlots[i].currentHP == 0 { - adversarySlots[i].isDefeated = true + public func applyDamage(_ amount: Int, to id: UUID) { + if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { + let s = _adversarySlots[i] + let newHP = max(0, s.currentHP - amount) + _adversarySlots[i] = AdversarySlot( + id: s.id, adversaryID: s.adversaryID, customName: s.customName, + maxHP: s.maxHP, maxStress: s.maxStress, + currentHP: newHP, currentStress: s.currentStress, + isDefeated: newHP == 0, conditions: s.conditions + ) + if newHP == 0 { logger.info("Slot \(id) defeated") } else { - logger.debug("Slot \(id) took \(amount) damage, HP now \(self.adversarySlots[i].currentHP)") + logger.debug("Slot \(id) took \(amount) damage, HP now \(newHP)") } return } - if let i = playerSlots.firstIndex(where: { $0.id == id }) { - playerSlots[i].currentHP = max(0, playerSlots[i].currentHP - amount) + if let i = _playerSlots.firstIndex(where: { $0.id == id }) { + let s = _playerSlots[i] + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, + currentHP: max(0, s.currentHP - amount), + maxStress: s.maxStress, currentStress: s.currentStress, + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots, conditions: s.conditions + ) } } - /// Heal any combat participant, clamping HP to the slot's maximum. + /// Heal a combat participant by ID, clamping HP to the slot's maximum. /// Clears ``AdversarySlot/isDefeated`` if the adversary's HP rises above 0. - public func heal(_ amount: Int, to participant: some CombatParticipant) { - let id = participant.id - if let i = adversarySlots.firstIndex(where: { $0.id == id }) { - adversarySlots[i].currentHP = min( - adversarySlots[i].maxHP, adversarySlots[i].currentHP + amount) - if adversarySlots[i].currentHP > 0 { adversarySlots[i].isDefeated = false } - logger.debug( - "Slot \(id) healed \(amount), HP now \(self.adversarySlots[i].currentHP)/\(self.adversarySlots[i].maxHP)" + public func applyHealing(_ amount: Int, to id: UUID) { + if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { + let s = _adversarySlots[i] + let newHP = min(s.maxHP, s.currentHP + amount) + _adversarySlots[i] = AdversarySlot( + id: s.id, adversaryID: s.adversaryID, customName: s.customName, + maxHP: s.maxHP, maxStress: s.maxStress, + currentHP: newHP, currentStress: s.currentStress, + isDefeated: newHP > 0 ? false : s.isDefeated, + conditions: s.conditions ) + logger.debug("Slot \(id) healed \(amount), HP now \(newHP)/\(s.maxHP)") return } - if let i = playerSlots.firstIndex(where: { $0.id == id }) { - playerSlots[i].currentHP = min(playerSlots[i].maxHP, playerSlots[i].currentHP + amount) + if let i = _playerSlots.firstIndex(where: { $0.id == id }) { + let s = _playerSlots[i] + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, + currentHP: min(s.maxHP, s.currentHP + amount), + maxStress: s.maxStress, currentStress: s.currentStress, + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots, conditions: s.conditions + ) } } - /// Apply stress to any combat participant, clamping to the slot's maximum. - public func applyStress(_ amount: Int, to participant: some CombatParticipant) { - let id = participant.id - if modifying( - in: &adversarySlots, id: id, - { $0.currentStress = min($0.maxStress, $0.currentStress + amount) }) - { + /// Apply stress to a combat participant by ID, clamping to the slot's maximum. + public func applyStress(_ amount: Int, to id: UUID) { + if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { + let s = _adversarySlots[i] + _adversarySlots[i] = AdversarySlot( + id: s.id, adversaryID: s.adversaryID, customName: s.customName, + maxHP: s.maxHP, maxStress: s.maxStress, + currentHP: s.currentHP, + currentStress: min(s.maxStress, s.currentStress + amount), + isDefeated: s.isDefeated, conditions: s.conditions + ) return } - modifying(in: &playerSlots, id: id) { - $0.currentStress = min($0.maxStress, $0.currentStress + amount) + if let i = _playerSlots.firstIndex(where: { $0.id == id }) { + let s = _playerSlots[i] + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, + maxStress: s.maxStress, + currentStress: min(s.maxStress, s.currentStress + amount), + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots, conditions: s.conditions + ) } } - /// Reduce stress on any combat participant, clamping to 0. - public func reduceStress(_ amount: Int, from participant: some CombatParticipant) { - let id = participant.id - if modifying( - in: &adversarySlots, id: id, { $0.currentStress = max(0, $0.currentStress - amount) }) - { + /// Reduce stress on a combat participant by ID, clamping to 0. + public func reduceStress(_ amount: Int, from id: UUID) { + if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { + let s = _adversarySlots[i] + _adversarySlots[i] = AdversarySlot( + id: s.id, adversaryID: s.adversaryID, customName: s.customName, + maxHP: s.maxHP, maxStress: s.maxStress, + currentHP: s.currentHP, + currentStress: max(0, s.currentStress - amount), + isDefeated: s.isDefeated, conditions: s.conditions + ) return } - modifying(in: &playerSlots, id: id) { $0.currentStress = max(0, $0.currentStress - amount) } + if let i = _playerSlots.firstIndex(where: { $0.id == id }) { + let s = _playerSlots[i] + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, + maxStress: s.maxStress, + currentStress: max(0, s.currentStress - amount), + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots, conditions: s.conditions + ) + } } // MARK: - Condition Management - /// Apply a condition to any combat participant. + /// Apply a condition to a combat participant by ID. /// Per the SRD, the same condition cannot stack — ``Set`` enforces this. /// `.custom` conditions with an empty or whitespace-only name are silently ignored. - public func applyCondition(_ condition: Condition, to participant: some CombatParticipant) { + public func applyCondition(_ condition: Condition, to id: UUID) { if case .custom(let name) = condition, name.trimmingCharacters(in: .whitespaces).isEmpty { return } - let id = participant.id - if modifying(in: &adversarySlots, id: id, { $0.conditions.insert(condition) }) { return } - modifying(in: &playerSlots, id: id) { $0.conditions.insert(condition) } + if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { + let s = _adversarySlots[i] + var newConditions = s.conditions + newConditions.insert(condition) + _adversarySlots[i] = AdversarySlot( + id: s.id, adversaryID: s.adversaryID, customName: s.customName, + maxHP: s.maxHP, maxStress: s.maxStress, + currentHP: s.currentHP, currentStress: s.currentStress, + isDefeated: s.isDefeated, conditions: newConditions + ) + return + } + if let i = _playerSlots.firstIndex(where: { $0.id == id }) { + let s = _playerSlots[i] + var newConditions = s.conditions + newConditions.insert(condition) + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, + maxStress: s.maxStress, currentStress: s.currentStress, + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots, conditions: newConditions + ) + } } - /// Remove a condition from any combat participant. - public func removeCondition(_ condition: Condition, from participant: some CombatParticipant) { - let id = participant.id - if modifying(in: &adversarySlots, id: id, { $0.conditions.remove(condition) }) { return } - modifying(in: &playerSlots, id: id) { $0.conditions.remove(condition) } + /// Remove a condition from a combat participant by ID. + public func removeCondition(_ condition: Condition, from id: UUID) { + if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { + let s = _adversarySlots[i] + var newConditions = s.conditions + newConditions.remove(condition) + _adversarySlots[i] = AdversarySlot( + id: s.id, adversaryID: s.adversaryID, customName: s.customName, + maxHP: s.maxHP, maxStress: s.maxStress, + currentHP: s.currentHP, currentStress: s.currentStress, + isDefeated: s.isDefeated, conditions: newConditions + ) + return + } + if let i = _playerSlots.firstIndex(where: { $0.id == id }) { + let s = _playerSlots[i] + var newConditions = s.conditions + newConditions.remove(condition) + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, + maxStress: s.maxStress, currentStress: s.currentStress, + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots, conditions: newConditions + ) + } } // MARK: - Armor Slot Management /// Mark one Armor Slot on a player (used to reduce damage severity). public func markArmorSlot(for slotID: UUID) { - guard let index = playerSlots.firstIndex(where: { $0.id == slotID }) else { return } - guard playerSlots[index].currentArmorSlots > 0 else { return } - playerSlots[index].currentArmorSlots -= 1 + guard let i = _playerSlots.firstIndex(where: { $0.id == slotID }) else { return } + let s = _playerSlots[i] + guard s.currentArmorSlots > 0 else { return } + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, + maxStress: s.maxStress, currentStress: s.currentStress, + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: s.currentArmorSlots - 1, conditions: s.conditions + ) } /// Restore one Armor Slot on a player (undo a mark, or recover via a rest ability). public func restoreArmorSlot(for slotID: UUID) { - guard let index = playerSlots.firstIndex(where: { $0.id == slotID }) else { return } - playerSlots[index].currentArmorSlots = min( - playerSlots[index].armorSlots, - playerSlots[index].currentArmorSlots + 1 + guard let i = _playerSlots.firstIndex(where: { $0.id == slotID }) else { return } + let s = _playerSlots[i] + _playerSlots[i] = PlayerSlot( + id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, + maxStress: s.maxStress, currentStress: s.currentStress, + evasion: s.evasion, thresholdMajor: s.thresholdMajor, + thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, + currentArmorSlots: min(s.armorSlots, s.currentArmorSlots + 1), + conditions: s.conditions ) } @@ -372,23 +402,12 @@ public final class EncounterSession: Identifiable, Hashable { /// All adversary slots still in the fight. public var activeAdversaries: [AdversarySlot] { - adversarySlots.filter { !$0.isDefeated } + _adversarySlots.filter { !$0.isDefeated } } /// `true` when all adversary slots are defeated. public var isOver: Bool { - !adversarySlots.isEmpty && adversarySlots.allSatisfy(\.isDefeated) - } - - // MARK: - Private Helpers - - @discardableResult - private func modifying( - in slots: inout [S], id: UUID, _ body: (inout S) -> Void - ) -> Bool { - guard let i = slots.firstIndex(where: { $0.id == id }) else { return false } - body(&slots[i]) - return true + !_adversarySlots.isEmpty && _adversarySlots.allSatisfy(\.isDefeated) } // MARK: - Factory @@ -409,7 +428,8 @@ public final class EncounterSession: Identifiable, Hashable { ) -> EncounterSession { let adversarySlots: [AdversarySlot] = definition.adversaryIDs.compactMap { id in guard let adversary = compendium.adversary(id: id) else { return nil } - return AdversarySlot.make(from: adversary) + return AdversarySlot( + adversaryID: adversary.id, maxHP: adversary.hp, maxStress: adversary.stress) } let environmentSlots: [EnvironmentSlot] = definition.environmentIDs.compactMap { id in diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 93fd8f7..06f06f2 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -22,19 +22,19 @@ import Observation // MARK: - EncounterStoreError /// Errors thrown by ``EncounterStore`` operations. -public enum EncounterStoreError: Error, LocalizedError { +public enum EncounterStoreError: Error, LocalizedError, Sendable { case notFound(UUID) - case saveFailed(UUID, Error) - case deleteFailed(UUID, Error) + case saveFailed(UUID, String) + case deleteFailed(UUID, String) public var errorDescription: String? { switch self { case .notFound(let id): return "No encounter definition found with ID \(id)." - case .saveFailed(let id, let underlying): - return "Failed to save encounter \(id): \(underlying.localizedDescription)" - case .deleteFailed(let id, let underlying): - return "Failed to delete encounter \(id): \(underlying.localizedDescription)" + case .saveFailed(let id, let description): + return "Failed to save encounter \(id): \(description)" + case .deleteFailed(let id, let description): + return "Failed to delete encounter \(id): \(description)" } } } @@ -81,6 +81,12 @@ public final class EncounterStore { /// Non-nil if the last `load()` failed at the directory level. public private(set) var loadError: (any Error)? + // MARK: Reentrancy tracking + private var savesInFlight: Set = [] + private var deletesInFlight: Set = [] + private var duplicatesInFlight: Set = [] + private var createInFlight = false + // MARK: - Init public init(directory: URL) { @@ -196,6 +202,9 @@ public final class EncounterStore { /// Creates a new ``EncounterDefinition``, persists it, and inserts it /// into ``definitions``. public func create(name: String) async throws { + guard !createInFlight else { return } + createInFlight = true + defer { createInFlight = false } let def = EncounterDefinition(name: name) try await persist(def) insertSorted(def) @@ -212,6 +221,9 @@ public final class EncounterStore { /// - Throws: ``EncounterStoreError/notFound(_:)`` if the ID is not in /// the current ``definitions``. public func save(_ definition: EncounterDefinition) async throws { + guard !savesInFlight.contains(definition.id) else { return } + savesInFlight.insert(definition.id) + defer { savesInFlight.remove(definition.id) } guard definitions.contains(where: { $0.id == definition.id }) else { throw EncounterStoreError.notFound(definition.id) } @@ -227,6 +239,9 @@ public final class EncounterStore { /// /// - Throws: ``EncounterStoreError/notFound(_:)`` if the ID is unknown. public func delete(id: UUID) async throws { + guard !deletesInFlight.contains(id) else { return } + deletesInFlight.insert(id) + defer { deletesInFlight.remove(id) } guard definitions.contains(where: { $0.id == id }) else { throw EncounterStoreError.notFound(id) } @@ -234,7 +249,7 @@ public final class EncounterStore { do { try await Self.deleteEncounter(at: url) } catch { - throw EncounterStoreError.deleteFailed(id, error) + throw EncounterStoreError.deleteFailed(id, error.localizedDescription) } definitions.removeAll { $0.id == id } } @@ -254,6 +269,9 @@ public final class EncounterStore { /// /// - Throws: ``EncounterStoreError/notFound(_:)`` if the source ID is unknown. public func duplicate(id: UUID) async throws { + guard !duplicatesInFlight.contains(id) else { return } + duplicatesInFlight.insert(id) + defer { duplicatesInFlight.remove(id) } guard let original = definitions.first(where: { $0.id == id }) else { throw EncounterStoreError.notFound(id) } @@ -279,7 +297,7 @@ public final class EncounterStore { do { try await Self.writeEncounter(definition, to: url) } catch { - throw EncounterStoreError.saveFailed(definition.id, error) + throw EncounterStoreError.saveFailed(definition.id, error.localizedDescription) } } diff --git a/Sources/DHModels/AdversarySlot.swift b/Sources/DHModels/AdversarySlot.swift new file mode 100644 index 0000000..6924627 --- /dev/null +++ b/Sources/DHModels/AdversarySlot.swift @@ -0,0 +1,63 @@ +// +// AdversarySlot.swift +// DHModels +// +// A single adversary participant in a live encounter. +// + +import Foundation + +/// A single adversary participant in a live encounter. +/// +/// Wraps a reference to a catalog ``Adversary`` with runtime state: +/// current HP, current Stress, defeat status, and an optional individual name +/// (useful when running multiple copies of the same adversary). +/// +/// `maxHP` and `maxStress` are snapshotted from the catalog at slot-creation +/// time so that HP/stress clamping works correctly even if the source adversary +/// is later edited or removed from the ``Compendium`` (homebrew orphan safety). +/// +/// All properties are immutable. Mutations are performed by ``EncounterSession``, +/// which replaces slots wholesale (copy-with-update pattern). +public struct AdversarySlot: CombatParticipant, Sendable, Equatable, Hashable { + public let id: UUID + /// The slug that identifies this adversary in the ``Compendium``. + public let adversaryID: String + /// Display name override (e.g. "Grimfang" for a named bandit leader). + /// Falls back to the catalog name when `nil`. + public let customName: String? + + // MARK: Stat Snapshot (from catalog at creation time) + public let maxHP: Int + public let maxStress: Int + + // MARK: Tracked Stats + public let currentHP: Int + public let currentStress: Int + public let isDefeated: Bool + public let conditions: Set + + // MARK: - Init + + public init( + id: UUID = UUID(), + adversaryID: String, + customName: String? = nil, + maxHP: Int, + maxStress: Int, + currentHP: Int? = nil, + currentStress: Int = 0, + isDefeated: Bool = false, + conditions: Set = [] + ) { + self.id = id + self.adversaryID = adversaryID + self.customName = customName + self.maxHP = maxHP + self.maxStress = maxStress + self.currentHP = currentHP ?? maxHP + self.currentStress = currentStress + self.isDefeated = isDefeated + self.conditions = conditions + } +} diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index 95d0273..95f5cb7 100644 --- a/Sources/DHModels/DifficultyBudget.swift +++ b/Sources/DHModels/DifficultyBudget.swift @@ -32,8 +32,8 @@ nonisolated public enum DifficultyBudget { /// - Leader: 3 points /// - Bruiser: 4 points /// - Solo: 5 points - public static func cost(for type: AdversaryType) -> Int { - switch type { + public static func cost(for role: AdversaryType) -> Int { + switch role { case .minion, .social, .support: return 1 case .horde, .ranged, .skulk, .standard: return 2 case .leader: return 3 diff --git a/Sources/DHModels/EncounterParticipant.swift b/Sources/DHModels/EncounterParticipant.swift index 61ec276..0444d33 100644 --- a/Sources/DHModels/EncounterParticipant.swift +++ b/Sources/DHModels/EncounterParticipant.swift @@ -16,13 +16,12 @@ public protocol EncounterParticipant: Identifiable where ID == UUID {} /// An encounter participant that tracks HP, Stress, and Conditions. /// -/// Conformed to by ``AdversarySlot`` and ``PlayerSlot``. Enables -/// unified combat mutation methods on ``EncounterSession`` without -/// requiring separate adversary- and player-specific overloads. +/// Conformed to by ``AdversarySlot`` and ``PlayerSlot``. Used as a read/display +/// contract; all mutations are performed by ``EncounterSession`` via UUID. public protocol CombatParticipant: EncounterParticipant { - var currentHP: Int { get set } + var currentHP: Int { get } var maxHP: Int { get } - var currentStress: Int { get set } + var currentStress: Int { get } var maxStress: Int { get } - var conditions: Set { get set } + var conditions: Set { get } } diff --git a/Sources/DHModels/EnvironmentSlot.swift b/Sources/DHModels/EnvironmentSlot.swift new file mode 100644 index 0000000..8d3865a --- /dev/null +++ b/Sources/DHModels/EnvironmentSlot.swift @@ -0,0 +1,33 @@ +// +// EnvironmentSlot.swift +// DHModels +// +// An environment element active in the current encounter scene. +// + +import Foundation + +/// An environment element active in the current encounter scene. +/// +/// Environments have no HP or Stress — they are tracked only for +/// their features and activation state. +/// +/// All properties are immutable. Mutations are performed by ``EncounterSession``, +/// which replaces slots wholesale (copy-with-update pattern). +public struct EnvironmentSlot: EncounterParticipant, Sendable, Equatable, Hashable { + public let id: UUID + /// The slug identifying this environment in the ``Compendium``. + public let environmentID: String + /// Whether this environment element is currently active/visible to players. + public let isActive: Bool + + public init( + id: UUID = UUID(), + environmentID: String, + isActive: Bool = true + ) { + self.id = id + self.environmentID = environmentID + self.isActive = isActive + } +} diff --git a/Sources/DHModels/PlayerSlot.swift b/Sources/DHModels/PlayerSlot.swift index 4657e31..fd129b9 100644 --- a/Sources/DHModels/PlayerSlot.swift +++ b/Sources/DHModels/PlayerSlot.swift @@ -19,17 +19,17 @@ import Foundation /// /// Tracks combat-relevant PC stats the GM needs to resolve hits and /// track health during play. The full character sheet remains with the player. -nonisolated public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Hashable { +public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Hashable { public let id: UUID - public var name: String + public let name: String // MARK: Hit Points public let maxHP: Int - public var currentHP: Int + public let currentHP: Int // MARK: Stress public let maxStress: Int - public var currentStress: Int + public let currentStress: Int // MARK: Defense /// The DC for all rolls made against this PC. @@ -43,10 +43,10 @@ nonisolated public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Ha /// Total Armor Score (number of Armor Slots available). public let armorSlots: Int /// Remaining unused Armor Slots. - public var currentArmorSlots: Int + public let currentArmorSlots: Int // MARK: Conditions - public var conditions: Set + public let conditions: Set // MARK: - Init diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index c871f3c..7ed8320 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -56,7 +56,7 @@ import Testing session.add(adversary: soldier) let slot = session.adversarySlots[0] - session.applyDamage(4, to: slot) + session.applyDamage(4, to: slot.id) #expect(session.adversarySlots[0].currentHP == 2) } @@ -66,7 +66,7 @@ import Testing session.add(adversary: soldier) let slot = session.adversarySlots[0] - session.applyDamage(100, to: slot) + session.applyDamage(100, to: slot.id) #expect(session.adversarySlots[0].currentHP == 0) #expect(session.adversarySlots[0].isDefeated == true) #expect(session.activeAdversaries.isEmpty) @@ -95,7 +95,7 @@ import Testing #expect(session.isOver == false) let slot = session.adversarySlots[0] - session.applyDamage(999, to: slot) + session.applyDamage(999, to: slot.id) #expect(session.isOver == true) } @@ -144,7 +144,7 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyCondition(.restrained, to: slot) + session.applyCondition(.restrained, to: slot.id) #expect(session.adversarySlots[0].conditions.contains(.restrained)) } @@ -153,8 +153,8 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyCondition(.hidden, to: slot) - session.removeCondition(.hidden, from: slot) + session.applyCondition(.hidden, to: slot.id) + session.removeCondition(.hidden, from: slot.id) #expect(!session.adversarySlots[0].conditions.contains(.hidden)) } @@ -163,8 +163,8 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyCondition(.vulnerable, to: slot) - session.applyCondition(.vulnerable, to: slot) + session.applyCondition(.vulnerable, to: slot.id) + session.applyCondition(.vulnerable, to: slot.id) #expect(session.adversarySlots[0].conditions.count == 1) } @@ -173,7 +173,7 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyCondition(.custom(""), to: slot) + session.applyCondition(.custom(""), to: slot.id) #expect(session.adversarySlots[0].conditions.isEmpty) } @@ -182,7 +182,7 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyCondition(.custom(" "), to: slot) + session.applyCondition(.custom(" "), to: slot.id) #expect(session.adversarySlots[0].conditions.isEmpty) } @@ -191,7 +191,7 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyCondition(.custom("Enraged"), to: slot) + session.applyCondition(.custom("Enraged"), to: slot.id) #expect(session.adversarySlots[0].conditions.contains(.custom("Enraged"))) } } @@ -262,7 +262,7 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyDamage(2, to: slot) + session.applyDamage(2, to: slot.id) #expect(session.playerSlots[0].currentHP == 4) } @@ -271,7 +271,7 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyDamage(100, to: slot) + session.applyDamage(100, to: slot.id) #expect(session.playerSlots[0].currentHP == 0) } @@ -280,7 +280,7 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyStress(3, to: slot) + session.applyStress(3, to: slot.id) #expect(session.playerSlots[0].currentStress == 3) } @@ -289,7 +289,7 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyStress(100, to: slot) + session.applyStress(100, to: slot.id) #expect(session.playerSlots[0].currentStress == 6) } @@ -298,8 +298,8 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyDamage(4, to: slot) - session.heal(2, to: slot) + session.applyDamage(4, to: slot.id) + session.applyHealing(2, to: slot.id) #expect(session.playerSlots[0].currentHP == 4) } @@ -308,8 +308,8 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyDamage(2, to: slot) - session.heal(100, to: slot) + session.applyDamage(2, to: slot.id) + session.applyHealing(100, to: slot.id) #expect(session.playerSlots[0].currentHP == 6) } @@ -318,8 +318,8 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyStress(4, to: slot) - session.reduceStress(2, from: slot) + session.applyStress(4, to: slot.id) + session.reduceStress(2, from: slot.id) #expect(session.playerSlots[0].currentStress == 2) } @@ -353,7 +353,7 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyCondition(.custom(""), to: slot) + session.applyCondition(.custom(""), to: slot.id) #expect(session.playerSlots[0].conditions.isEmpty) } @@ -362,7 +362,7 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyCondition(.vulnerable, to: slot) + session.applyCondition(.vulnerable, to: slot.id) #expect(session.playerSlots[0].conditions.contains(.vulnerable)) } @@ -371,8 +371,8 @@ import Testing session.add(player: makePlayer()) let slot = session.playerSlots[0] - session.applyCondition(.hidden, to: slot) - session.removeCondition(.hidden, from: slot) + session.applyCondition(.hidden, to: slot.id) + session.removeCondition(.hidden, from: slot.id) #expect(!session.playerSlots[0].conditions.contains(.hidden)) } @@ -483,7 +483,8 @@ import Testing } @Test func slotSnapshotsMaxHPAndMaxStress() { - let slot = AdversarySlot.make(from: makeSoldier()) + let soldier = makeSoldier() + let slot = AdversarySlot(adversaryID: soldier.id, maxHP: soldier.hp, maxStress: soldier.stress) #expect(slot.maxHP == 6) #expect(slot.maxStress == 3) } @@ -493,7 +494,7 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyStress(100, to: slot) + session.applyStress(100, to: slot.id) #expect(session.adversarySlots[0].currentStress == 3) } @@ -502,8 +503,8 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyStress(1, to: slot) - session.applyStress(1, to: slot) + session.applyStress(1, to: slot.id) + session.applyStress(1, to: slot.id) #expect(session.adversarySlots[0].currentStress == 2) } @@ -512,8 +513,8 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyDamage(4, to: slot) - session.heal(100, to: slot) + session.applyDamage(4, to: slot.id) + session.applyHealing(100, to: slot.id) #expect(session.adversarySlots[0].currentHP == 6) } @@ -522,9 +523,9 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.applyDamage(999, to: slot) + session.applyDamage(999, to: slot.id) #expect(session.adversarySlots[0].isDefeated == true) - session.heal(6, to: slot) + session.applyHealing(6, to: slot.id) #expect(session.adversarySlots[0].isDefeated == false) #expect(session.adversarySlots[0].currentHP == 6) } diff --git a/Tests/DHKitTests/EncounterStoreTests.swift b/Tests/DHKitTests/EncounterStoreTests.swift index 5582300..2cffac4 100644 --- a/Tests/DHKitTests/EncounterStoreTests.swift +++ b/Tests/DHKitTests/EncounterStoreTests.swift @@ -24,15 +24,15 @@ struct EncounterStoreErrorTests { @Test func saveFailedDescription() { let id = UUID() - let underlying = CocoaError(.fileWriteNoPermission) - let error = EncounterStoreError.saveFailed(id, underlying) + let error = EncounterStoreError.saveFailed( + id, CocoaError(.fileWriteNoPermission).localizedDescription) #expect(error.errorDescription?.hasPrefix("Failed to save encounter \(id):") == true) } @Test func deleteFailedDescription() { let id = UUID() - let underlying = CocoaError(.fileNoSuchFile) - let error = EncounterStoreError.deleteFailed(id, underlying) + let error = EncounterStoreError.deleteFailed( + id, CocoaError(.fileNoSuchFile).localizedDescription) #expect(error.errorDescription?.hasPrefix("Failed to delete encounter \(id):") == true) } } diff --git a/Tests/DHKitTests/SessionRegistryTests.swift b/Tests/DHKitTests/SessionRegistryTests.swift index 3777e69..5b6060a 100644 --- a/Tests/DHKitTests/SessionRegistryTests.swift +++ b/Tests/DHKitTests/SessionRegistryTests.swift @@ -66,7 +66,7 @@ import Testing let def1 = makeDefinition(adversaryIDs: ["goblin"]) let s1 = registry.session(for: def1.id, definition: def1, compendium: compendium) - s1.applyDamage(2, to: s1.adversarySlots[0]) + s1.applyDamage(2, to: s1.adversarySlots[0].id) #expect(s1.adversarySlots[0].currentHP == 1) let s2 = registry.resetSession(for: def1.id, definition: def1, compendium: compendium) From ab92aa09494e30ca306129a408933d42bb3c44a7 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 28 Mar 2026 21:49:09 -0700 Subject: [PATCH 2/5] refactor: apply API design review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add applying(...) copy-with-update helpers to AdversarySlot and PlayerSlot; refactor all EncounterSession mutation methods to use them (removes ~70 lines of repetitive full-struct reconstruction) - Add logger.warning for unmatched-ID mutations in EncounterSession - Rename removeAdversary(id:)/removePlayer(id:) → removeAdversary(withID:)/removePlayer(withID:) - Rename Compendium.adversaries(ofType:) → adversaries(ofRole:) - Rename DifficultyBudget.Rating.totalCost → .cost to avoid shadowing the static method - Strengthen EncounterStore.save(_:) doc comment re: dropped concurrent saves --- Sources/DHKit/Compendium.swift | 2 +- Sources/DHKit/EncounterSession.swift | 149 +++++-------------- Sources/DHKit/EncounterStore.swift | 5 + Sources/DHModels/AdversarySlot.swift | 21 +++ Sources/DHModels/DifficultyBudget.swift | 8 +- Sources/DHModels/PlayerSlot.swift | 21 +++ Tests/DHKitTests/EncounterSessionTests.swift | 2 +- Tests/DHModelsTests/ModelTests.swift | 6 +- 8 files changed, 90 insertions(+), 124 deletions(-) diff --git a/Sources/DHKit/Compendium.swift b/Sources/DHKit/Compendium.swift index 2d3b849..986dd1f 100644 --- a/Sources/DHKit/Compendium.swift +++ b/Sources/DHKit/Compendium.swift @@ -246,7 +246,7 @@ public final class Compendium { } /// Return all adversaries of a given role. - public func adversaries(ofType role: AdversaryType) -> [Adversary] { + public func adversaries(ofRole role: AdversaryType) -> [Adversary] { adversaries.filter { $0.role == role } } diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index 8b9f50c..61da0f6 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -132,7 +132,7 @@ public final class EncounterSession: Identifiable, Hashable { } /// Remove an adversary slot by ID. - public func removeAdversary(id: UUID) { + public func removeAdversary(withID id: UUID) { _adversarySlots.removeAll { $0.id == id } if spotlightedSlotID == id { spotlightedSlotID = nil } } @@ -145,7 +145,7 @@ public final class EncounterSession: Identifiable, Hashable { } /// Remove a player slot by ID. - public func removePlayer(id: UUID) { + public func removePlayer(withID id: UUID) { _playerSlots.removeAll { $0.id == id } if spotlightedSlotID == id { spotlightedSlotID = nil } } @@ -178,12 +178,7 @@ public final class EncounterSession: Identifiable, Hashable { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] let newHP = max(0, s.currentHP - amount) - _adversarySlots[i] = AdversarySlot( - id: s.id, adversaryID: s.adversaryID, customName: s.customName, - maxHP: s.maxHP, maxStress: s.maxStress, - currentHP: newHP, currentStress: s.currentStress, - isDefeated: newHP == 0, conditions: s.conditions - ) + _adversarySlots[i] = s.applying(currentHP: newHP, isDefeated: newHP == 0) if newHP == 0 { logger.info("Slot \(id) defeated") } else { @@ -192,16 +187,11 @@ public final class EncounterSession: Identifiable, Hashable { return } if let i = _playerSlots.firstIndex(where: { $0.id == id }) { - let s = _playerSlots[i] - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, - currentHP: max(0, s.currentHP - amount), - maxStress: s.maxStress, currentStress: s.currentStress, - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots, conditions: s.conditions - ) + _playerSlots[i] = _playerSlots[i].applying( + currentHP: max(0, _playerSlots[i].currentHP - amount)) + return } + logger.warning("applyDamage: no slot found for id \(id)") } /// Heal a combat participant by ID, clamping HP to the slot's maximum. @@ -210,79 +200,47 @@ public final class EncounterSession: Identifiable, Hashable { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] let newHP = min(s.maxHP, s.currentHP + amount) - _adversarySlots[i] = AdversarySlot( - id: s.id, adversaryID: s.adversaryID, customName: s.customName, - maxHP: s.maxHP, maxStress: s.maxStress, - currentHP: newHP, currentStress: s.currentStress, - isDefeated: newHP > 0 ? false : s.isDefeated, - conditions: s.conditions - ) + _adversarySlots[i] = s.applying( + currentHP: newHP, isDefeated: newHP > 0 ? false : s.isDefeated) logger.debug("Slot \(id) healed \(amount), HP now \(newHP)/\(s.maxHP)") return } if let i = _playerSlots.firstIndex(where: { $0.id == id }) { let s = _playerSlots[i] - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, - currentHP: min(s.maxHP, s.currentHP + amount), - maxStress: s.maxStress, currentStress: s.currentStress, - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots, conditions: s.conditions - ) + _playerSlots[i] = s.applying(currentHP: min(s.maxHP, s.currentHP + amount)) + return } + logger.warning("applyHealing: no slot found for id \(id)") } /// Apply stress to a combat participant by ID, clamping to the slot's maximum. public func applyStress(_ amount: Int, to id: UUID) { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] - _adversarySlots[i] = AdversarySlot( - id: s.id, adversaryID: s.adversaryID, customName: s.customName, - maxHP: s.maxHP, maxStress: s.maxStress, - currentHP: s.currentHP, - currentStress: min(s.maxStress, s.currentStress + amount), - isDefeated: s.isDefeated, conditions: s.conditions - ) + _adversarySlots[i] = s.applying(currentStress: min(s.maxStress, s.currentStress + amount)) return } if let i = _playerSlots.firstIndex(where: { $0.id == id }) { let s = _playerSlots[i] - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, - maxStress: s.maxStress, - currentStress: min(s.maxStress, s.currentStress + amount), - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots, conditions: s.conditions - ) + _playerSlots[i] = s.applying(currentStress: min(s.maxStress, s.currentStress + amount)) + return } + logger.warning("applyStress: no slot found for id \(id)") } /// Reduce stress on a combat participant by ID, clamping to 0. public func reduceStress(_ amount: Int, from id: UUID) { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] - _adversarySlots[i] = AdversarySlot( - id: s.id, adversaryID: s.adversaryID, customName: s.customName, - maxHP: s.maxHP, maxStress: s.maxStress, - currentHP: s.currentHP, - currentStress: max(0, s.currentStress - amount), - isDefeated: s.isDefeated, conditions: s.conditions - ) + _adversarySlots[i] = s.applying(currentStress: max(0, s.currentStress - amount)) return } if let i = _playerSlots.firstIndex(where: { $0.id == id }) { let s = _playerSlots[i] - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, - maxStress: s.maxStress, - currentStress: max(0, s.currentStress - amount), - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots, conditions: s.conditions - ) + _playerSlots[i] = s.applying(currentStress: max(0, s.currentStress - amount)) + return } + logger.warning("reduceStress: no slot found for id \(id)") } // MARK: - Condition Management @@ -297,56 +255,30 @@ public final class EncounterSession: Identifiable, Hashable { return } if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { - let s = _adversarySlots[i] - var newConditions = s.conditions - newConditions.insert(condition) - _adversarySlots[i] = AdversarySlot( - id: s.id, adversaryID: s.adversaryID, customName: s.customName, - maxHP: s.maxHP, maxStress: s.maxStress, - currentHP: s.currentHP, currentStress: s.currentStress, - isDefeated: s.isDefeated, conditions: newConditions - ) + var updated = _adversarySlots[i].conditions + updated.insert(condition) + _adversarySlots[i] = _adversarySlots[i].applying(conditions: updated) return } if let i = _playerSlots.firstIndex(where: { $0.id == id }) { - let s = _playerSlots[i] - var newConditions = s.conditions - newConditions.insert(condition) - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, - maxStress: s.maxStress, currentStress: s.currentStress, - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots, conditions: newConditions - ) + var updated = _playerSlots[i].conditions + updated.insert(condition) + _playerSlots[i] = _playerSlots[i].applying(conditions: updated) } } /// Remove a condition from a combat participant by ID. public func removeCondition(_ condition: Condition, from id: UUID) { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { - let s = _adversarySlots[i] - var newConditions = s.conditions - newConditions.remove(condition) - _adversarySlots[i] = AdversarySlot( - id: s.id, adversaryID: s.adversaryID, customName: s.customName, - maxHP: s.maxHP, maxStress: s.maxStress, - currentHP: s.currentHP, currentStress: s.currentStress, - isDefeated: s.isDefeated, conditions: newConditions - ) + var updated = _adversarySlots[i].conditions + updated.remove(condition) + _adversarySlots[i] = _adversarySlots[i].applying(conditions: updated) return } if let i = _playerSlots.firstIndex(where: { $0.id == id }) { - let s = _playerSlots[i] - var newConditions = s.conditions - newConditions.remove(condition) - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, - maxStress: s.maxStress, currentStress: s.currentStress, - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots, conditions: newConditions - ) + var updated = _playerSlots[i].conditions + updated.remove(condition) + _playerSlots[i] = _playerSlots[i].applying(conditions: updated) } } @@ -357,27 +289,14 @@ public final class EncounterSession: Identifiable, Hashable { guard let i = _playerSlots.firstIndex(where: { $0.id == slotID }) else { return } let s = _playerSlots[i] guard s.currentArmorSlots > 0 else { return } - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, - maxStress: s.maxStress, currentStress: s.currentStress, - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: s.currentArmorSlots - 1, conditions: s.conditions - ) + _playerSlots[i] = s.applying(currentArmorSlots: s.currentArmorSlots - 1) } /// Restore one Armor Slot on a player (undo a mark, or recover via a rest ability). public func restoreArmorSlot(for slotID: UUID) { guard let i = _playerSlots.firstIndex(where: { $0.id == slotID }) else { return } let s = _playerSlots[i] - _playerSlots[i] = PlayerSlot( - id: s.id, name: s.name, maxHP: s.maxHP, currentHP: s.currentHP, - maxStress: s.maxStress, currentStress: s.currentStress, - evasion: s.evasion, thresholdMajor: s.thresholdMajor, - thresholdSevere: s.thresholdSevere, armorSlots: s.armorSlots, - currentArmorSlots: min(s.armorSlots, s.currentArmorSlots + 1), - conditions: s.conditions - ) + _playerSlots[i] = s.applying(currentArmorSlots: min(s.armorSlots, s.currentArmorSlots + 1)) } // MARK: - Fear & Hope diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 06f06f2..7f46334 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -218,6 +218,11 @@ public final class EncounterStore { /// invariant is maintained regardless of whether the caller has updated /// individual properties through their `didSet` observers. /// + /// If a save for the same definition ID is already in flight, this call + /// returns immediately without writing or queuing. Callers that need to + /// ensure the latest value is persisted should await the first save before + /// calling again. + /// /// - Throws: ``EncounterStoreError/notFound(_:)`` if the ID is not in /// the current ``definitions``. public func save(_ definition: EncounterDefinition) async throws { diff --git a/Sources/DHModels/AdversarySlot.swift b/Sources/DHModels/AdversarySlot.swift index 6924627..d11ec4b 100644 --- a/Sources/DHModels/AdversarySlot.swift +++ b/Sources/DHModels/AdversarySlot.swift @@ -60,4 +60,25 @@ public struct AdversarySlot: CombatParticipant, Sendable, Equatable, Hashable { self.isDefeated = isDefeated self.conditions = conditions } + + /// Returns a copy of this slot with the specified mutable fields replaced. + /// + /// Omit any parameter to preserve the existing value. This is the preferred + /// way to produce updated copies; it avoids repeating every unchanged field + /// at mutation sites in ``EncounterSession``. + public func applying( + currentHP: Int? = nil, + currentStress: Int? = nil, + isDefeated: Bool? = nil, + conditions: Set? = nil + ) -> AdversarySlot { + AdversarySlot( + id: id, adversaryID: adversaryID, customName: customName, + maxHP: maxHP, maxStress: maxStress, + currentHP: currentHP ?? self.currentHP, + currentStress: currentStress ?? self.currentStress, + isDefeated: isDefeated ?? self.isDefeated, + conditions: conditions ?? self.conditions + ) + } } diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index 95f5cb7..76633e7 100644 --- a/Sources/DHModels/DifficultyBudget.swift +++ b/Sources/DHModels/DifficultyBudget.swift @@ -65,13 +65,13 @@ nonisolated public enum DifficultyBudget { /// Total Battle Points available (base budget + adjustment). public let budget: Int /// Total Battle Points spent on the adversary roster. - public let totalCost: Int + public let cost: Int /// Budget minus cost. Negative means over-budget. public let remaining: Int - public init(budget: Int, totalCost: Int, remaining: Int) { + public init(budget: Int, cost: Int, remaining: Int) { self.budget = budget - self.totalCost = totalCost + self.cost = cost self.remaining = remaining } } @@ -90,7 +90,7 @@ nonisolated public enum DifficultyBudget { ) -> Rating { let budget = baseBudget(playerCount: playerCount) + budgetAdjustment let cost = totalCost(for: adversaryTypes) - return Rating(budget: budget, totalCost: cost, remaining: budget - cost) + return Rating(budget: budget, cost: cost, remaining: budget - cost) } // MARK: - Adjustment Suggestions diff --git a/Sources/DHModels/PlayerSlot.swift b/Sources/DHModels/PlayerSlot.swift index fd129b9..7c92c8d 100644 --- a/Sources/DHModels/PlayerSlot.swift +++ b/Sources/DHModels/PlayerSlot.swift @@ -77,4 +77,25 @@ public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Hashable { self.currentArmorSlots = currentArmorSlots ?? armorSlots self.conditions = conditions } + + /// Returns a copy of this slot with the specified mutable fields replaced. + /// + /// Omit any parameter to preserve the existing value. This is the preferred + /// way to produce updated copies; it avoids repeating every unchanged field + /// at mutation sites in ``EncounterSession``. + public func applying( + currentHP: Int? = nil, + currentStress: Int? = nil, + currentArmorSlots: Int? = nil, + conditions: Set? = nil + ) -> PlayerSlot { + PlayerSlot( + id: id, name: name, + maxHP: maxHP, currentHP: currentHP ?? self.currentHP, + maxStress: maxStress, currentStress: currentStress ?? self.currentStress, + evasion: evasion, thresholdMajor: thresholdMajor, thresholdSevere: thresholdSevere, + armorSlots: armorSlots, currentArmorSlots: currentArmorSlots ?? self.currentArmorSlots, + conditions: conditions ?? self.conditions + ) + } } diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index 7ed8320..378754a 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -381,7 +381,7 @@ import Testing session.add(player: makePlayer()) let slotID = session.playerSlots[0].id - session.removePlayer(id: slotID) + session.removePlayer(withID: slotID) #expect(session.playerSlots.isEmpty) #expect(session.spotlightedSlotID == nil) } diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 01499ff..cfac10b 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -402,7 +402,7 @@ struct DifficultyBudgetTests { adversaryTypes: [.standard, .standard, .minion], playerCount: 4 ) - #expect(rating.totalCost == 5) + #expect(rating.cost == 5) #expect(rating.budget == 14) #expect(rating.remaining == 9) } @@ -412,7 +412,7 @@ struct DifficultyBudgetTests { adversaryTypes: [.solo, .solo, .bruiser], playerCount: 3 ) - #expect(rating.totalCost == 14) + #expect(rating.cost == 14) #expect(rating.budget == 11) #expect(rating.remaining == -3) } @@ -424,7 +424,7 @@ struct DifficultyBudgetTests { budgetAdjustment: -2 ) #expect(rating.budget == 12) - #expect(rating.totalCost == 2) + #expect(rating.cost == 2) #expect(rating.remaining == 10) } From cc7663a85fabc2c0f522b918257e00d65fb8e5ca Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 28 Mar 2026 22:05:09 -0700 Subject: [PATCH 3/5] additional review fixes --- Sources/DHKit/EncounterSession.swift | 21 +++++++++----------- Sources/DHKit/EncounterStore.swift | 12 +++++++++++ Sources/DHModels/AdversarySlot.swift | 10 ++++++++++ Sources/DHModels/EnvironmentSlot.swift | 11 ++++++++++ Tests/DHKitTests/EncounterSessionTests.swift | 10 +++++----- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index 61da0f6..c871e28 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -117,13 +117,7 @@ public final class EncounterSession: Identifiable, Hashable { /// Add a new adversary slot populated from a catalog entry. public func add(adversary: Adversary, customName: String? = nil) { - _adversarySlots.append( - AdversarySlot( - adversaryID: adversary.id, - customName: customName, - maxHP: adversary.hp, - maxStress: adversary.stress - )) + _adversarySlots.append(AdversarySlot(from: adversary, customName: customName)) } /// Add an environment slot. @@ -157,8 +151,8 @@ public final class EncounterSession: Identifiable, Hashable { /// Increments ``spotlightCount`` on every call. The GM typically /// spends 1 Fear (tracked separately on ``fearPool``) when seizing /// the spotlight to act. - public func spotlight(_ participant: some EncounterParticipant) { - spotlightedSlotID = participant.id + public func spotlight(id: UUID) { + spotlightedSlotID = id spotlightCount += 1 } @@ -178,7 +172,7 @@ public final class EncounterSession: Identifiable, Hashable { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] let newHP = max(0, s.currentHP - amount) - _adversarySlots[i] = s.applying(currentHP: newHP, isDefeated: newHP == 0) + _adversarySlots[i] = s.applying(currentHP: newHP, isDefeated: newHP == 0 ? true : nil) if newHP == 0 { logger.info("Slot \(id) defeated") } else { @@ -264,7 +258,9 @@ public final class EncounterSession: Identifiable, Hashable { var updated = _playerSlots[i].conditions updated.insert(condition) _playerSlots[i] = _playerSlots[i].applying(conditions: updated) + return } + logger.warning("applyCondition: no slot found for id \(id)") } /// Remove a condition from a combat participant by ID. @@ -279,7 +275,9 @@ public final class EncounterSession: Identifiable, Hashable { var updated = _playerSlots[i].conditions updated.remove(condition) _playerSlots[i] = _playerSlots[i].applying(conditions: updated) + return } + logger.warning("removeCondition: no slot found for id \(id)") } // MARK: - Armor Slot Management @@ -347,8 +345,7 @@ public final class EncounterSession: Identifiable, Hashable { ) -> EncounterSession { let adversarySlots: [AdversarySlot] = definition.adversaryIDs.compactMap { id in guard let adversary = compendium.adversary(id: id) else { return nil } - return AdversarySlot( - adversaryID: adversary.id, maxHP: adversary.hp, maxStress: adversary.stress) + return AdversarySlot(from: adversary) } let environmentSlots: [EnvironmentSlot] = definition.environmentIDs.compactMap { id in diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 7f46334..4d327cf 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -201,6 +201,10 @@ public final class EncounterStore { /// Creates a new ``EncounterDefinition``, persists it, and inserts it /// into ``definitions``. + /// + /// If a create operation is already in flight, this call returns immediately + /// without creating a second definition. Callers that need to ensure creation + /// occurred should await this call to completion before calling again. public func create(name: String) async throws { guard !createInFlight else { return } createInFlight = true @@ -242,6 +246,10 @@ public final class EncounterStore { /// Removes a definition from memory and deletes its backing file. /// + /// If a delete for the same ID is already in flight, this call returns + /// immediately. Callers that need to ensure deletion occurred should await + /// the first call to completion before calling again. + /// /// - Throws: ``EncounterStoreError/notFound(_:)`` if the ID is unknown. public func delete(id: UUID) async throws { guard !deletesInFlight.contains(id) else { return } @@ -265,6 +273,10 @@ public final class EncounterStore { /// `createdAt`, and a `" (Copy)"` suffix on the name. Persists it and /// adds it to ``definitions``. /// + /// If a duplicate for the same source ID is already in flight, this call + /// returns immediately without creating a second copy. Callers that need + /// a second copy should await the first call to completion before calling again. + /// /// The copy is inserted with `createdAt = modifiedAt = .now`, so it sorts /// to the top of ``definitions``. /// diff --git a/Sources/DHModels/AdversarySlot.swift b/Sources/DHModels/AdversarySlot.swift index d11ec4b..517189d 100644 --- a/Sources/DHModels/AdversarySlot.swift +++ b/Sources/DHModels/AdversarySlot.swift @@ -61,6 +61,16 @@ public struct AdversarySlot: CombatParticipant, Sendable, Equatable, Hashable { self.conditions = conditions } + /// Convenience initializer: creates a slot pre-populated from a catalog entry. + public init(from adversary: Adversary, customName: String? = nil) { + self.init( + adversaryID: adversary.id, + customName: customName, + maxHP: adversary.hp, + maxStress: adversary.stress + ) + } + /// Returns a copy of this slot with the specified mutable fields replaced. /// /// Omit any parameter to preserve the existing value. This is the preferred diff --git a/Sources/DHModels/EnvironmentSlot.swift b/Sources/DHModels/EnvironmentSlot.swift index 8d3865a..94580bc 100644 --- a/Sources/DHModels/EnvironmentSlot.swift +++ b/Sources/DHModels/EnvironmentSlot.swift @@ -30,4 +30,15 @@ public struct EnvironmentSlot: EncounterParticipant, Sendable, Equatable, Hashab self.environmentID = environmentID self.isActive = isActive } + + /// Returns a copy of this slot with the specified mutable fields replaced. + /// + /// Omit any parameter to preserve the existing value. + public func applying(isActive: Bool? = nil) -> EnvironmentSlot { + EnvironmentSlot( + id: id, + environmentID: environmentID, + isActive: isActive ?? self.isActive + ) + } } diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index 378754a..36adb1e 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -109,7 +109,7 @@ import Testing session.add(adversary: makeSoldier()) let slot = session.adversarySlots[0] - session.spotlight(slot) + session.spotlight(id: slot.id) #expect(session.spotlightCount == 1) #expect(session.spotlightedSlotID == slot.id) @@ -125,8 +125,8 @@ import Testing let first = session.adversarySlots[0] let second = session.adversarySlots[1] - session.spotlight(first) - session.spotlight(second) + session.spotlight(id: first.id) + session.spotlight(id: second.id) #expect(session.spotlightCount == 2) #expect(session.spotlightedSlotID == second.id) } @@ -250,10 +250,10 @@ import Testing let adversarySlot = session.adversarySlots[0] let playerSlot = session.playerSlots[0] - session.spotlight(adversarySlot) + session.spotlight(id: adversarySlot.id) #expect(session.spotlightedSlotID == adversarySlot.id) - session.spotlight(playerSlot) + session.spotlight(id: playerSlot.id) #expect(session.spotlightedSlotID == playerSlot.id) } From d5a06e0980c786ef9998dcacedef5c1b37d8c0a4 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 28 Mar 2026 22:08:10 -0700 Subject: [PATCH 4/5] linux fix --- Sources/DHKit/EncounterStore.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 4d327cf..893af37 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -107,6 +107,7 @@ public final class EncounterStore { @concurrent nonisolated private static func resolveDefaultDirectory() async -> URL { + #if canImport(Darwin) let fm = FileManager.default if let ubiquity = fm.url(forUbiquityContainerIdentifier: nil) { let dir = @@ -116,6 +117,7 @@ public final class EncounterStore { try? fm.createDirectory(at: dir, withIntermediateDirectories: true) return dir } + #endif return Self.localDirectory } @@ -161,7 +163,10 @@ public final class EncounterStore { /// Local Application Support directory. A pure URL — no file I/O performed. nonisolated public static var localDirectory: URL { - URL.applicationSupportDirectory.appending(path: "Encounters") + let base = + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return base.appending(path: "Encounters") } /// Switches the storage directory and clears `definitions`. From 97258de5a21189b1db7ae1900aeaf330d9d3e112 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 28 Mar 2026 22:10:08 -0700 Subject: [PATCH 5/5] format fixes --- Sources/DHKit/EncounterStore.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 893af37..5bb8506 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -108,15 +108,15 @@ public final class EncounterStore { @concurrent nonisolated private static func resolveDefaultDirectory() async -> URL { #if canImport(Darwin) - let fm = FileManager.default - if let ubiquity = fm.url(forUbiquityContainerIdentifier: nil) { - let dir = - ubiquity - .appending(path: "Documents") - .appending(path: "Encounters") - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } + let fm = FileManager.default + if let ubiquity = fm.url(forUbiquityContainerIdentifier: nil) { + let dir = + ubiquity + .appending(path: "Documents") + .appending(path: "Encounters") + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } #endif return Self.localDirectory }