From 593c4322edfc413c6f090d183d9e2652587d3d13 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sun, 29 Mar 2026 11:15:43 -0700 Subject: [PATCH 1/2] api refinement --- Sources/DHKit/EncounterSession.swift | 2 +- Sources/DHKit/EncounterStore.swift | 2 +- Sources/DHKit/SessionRegistry.swift | 14 ++++++------- Tests/DHKitTests/EncounterSessionTests.swift | 2 +- Tests/DHKitTests/SessionRegistryTests.swift | 22 ++++++++++---------- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index c871e28..bf81454 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -223,7 +223,7 @@ public final class EncounterSession: Identifiable, Hashable { } /// Reduce stress on a combat participant by ID, clamping to 0. - public func reduceStress(_ amount: Int, from id: UUID) { + public func reduceStress(_ amount: Int, for id: UUID) { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] _adversarySlots[i] = s.applying(currentStress: max(0, s.currentStress - amount)) diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 5bb8506..8a092df 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -22,7 +22,7 @@ import Observation // MARK: - EncounterStoreError /// Errors thrown by ``EncounterStore`` operations. -public enum EncounterStoreError: Error, LocalizedError, Sendable { +nonisolated public enum EncounterStoreError: Error, LocalizedError, Sendable { case notFound(UUID) case saveFailed(UUID, String) case deleteFailed(UUID, String) diff --git a/Sources/DHKit/SessionRegistry.swift b/Sources/DHKit/SessionRegistry.swift index f257a84..113c168 100644 --- a/Sources/DHKit/SessionRegistry.swift +++ b/Sources/DHKit/SessionRegistry.swift @@ -34,15 +34,14 @@ public final class SessionRegistry { public init() {} - /// Return the existing session for `definitionID`, or create and store a new one. + /// Return the existing session for `definition.id`, or create and store a new one. public func session( - for definitionID: UUID, - definition: EncounterDefinition, + for definition: EncounterDefinition, compendium: Compendium ) -> EncounterSession { - if let existing = sessions[definitionID] { return existing } + if let existing = sessions[definition.id] { return existing } let newSession = EncounterSession.make(from: definition, using: compendium) - sessions[definitionID] = newSession + sessions[definition.id] = newSession return newSession } @@ -57,12 +56,11 @@ public final class SessionRegistry { /// Use this when the GM wants to restart an encounter from scratch without navigating away. @discardableResult public func resetSession( - for definitionID: UUID, - definition: EncounterDefinition, + for definition: EncounterDefinition, compendium: Compendium ) -> EncounterSession { let newSession = EncounterSession.make(from: definition, using: compendium) - sessions[definitionID] = newSession + sessions[definition.id] = newSession return newSession } } diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index 36adb1e..64f9e41 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -319,7 +319,7 @@ import Testing let slot = session.playerSlots[0] session.applyStress(4, to: slot.id) - session.reduceStress(2, from: slot.id) + session.reduceStress(2, for: slot.id) #expect(session.playerSlots[0].currentStress == 2) } diff --git a/Tests/DHKitTests/SessionRegistryTests.swift b/Tests/DHKitTests/SessionRegistryTests.swift index 5b6060a..f19d7d8 100644 --- a/Tests/DHKitTests/SessionRegistryTests.swift +++ b/Tests/DHKitTests/SessionRegistryTests.swift @@ -36,7 +36,7 @@ import Testing let compendium = makeCompendium() let def = makeDefinition() - let session = registry.session(for: def.id, definition: def, compendium: compendium) + let session = registry.session(for: def, compendium: compendium) #expect(session.adversarySlots.count == 1) } @@ -45,8 +45,8 @@ import Testing let compendium = makeCompendium() let def = makeDefinition() - let s1 = registry.session(for: def.id, definition: def, compendium: compendium) - let s2 = registry.session(for: def.id, definition: def, compendium: compendium) + let s1 = registry.session(for: def, compendium: compendium) + let s2 = registry.session(for: def, compendium: compendium) #expect(s1 === s2) } @@ -55,7 +55,7 @@ import Testing let compendium = makeCompendium() let def = makeDefinition() - _ = registry.session(for: def.id, definition: def, compendium: compendium) + _ = registry.session(for: def, compendium: compendium) registry.clearSession(for: def.id) #expect(registry.sessions[def.id] == nil) } @@ -65,11 +65,11 @@ import Testing let compendium = makeCompendium() let def1 = makeDefinition(adversaryIDs: ["goblin"]) - let s1 = registry.session(for: def1.id, definition: def1, compendium: compendium) + let s1 = registry.session(for: def1, compendium: compendium) 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) + let s2 = registry.resetSession(for: def1, compendium: compendium) #expect(s2 !== s1) #expect(s2.adversarySlots[0].currentHP == 3) @@ -80,10 +80,10 @@ import Testing let compendium = makeCompendium() let def = makeDefinition() - let s1 = registry.session(for: def.id, definition: def, compendium: compendium) - let s2 = registry.resetSession(for: def.id, definition: def, compendium: compendium) + let s1 = registry.session(for: def, compendium: compendium) + let s2 = registry.resetSession(for: def, compendium: compendium) - let s3 = registry.session(for: def.id, definition: def, compendium: compendium) + let s3 = registry.session(for: def, compendium: compendium) #expect(s3 === s2) #expect(s3 !== s1) } @@ -95,8 +95,8 @@ import Testing var def2 = def1 def2.adversaryIDs = ["goblin", "goblin"] - _ = registry.session(for: def1.id, definition: def1, compendium: compendium) - let s2 = registry.resetSession(for: def2.id, definition: def2, compendium: compendium) + _ = registry.session(for: def1, compendium: compendium) + let s2 = registry.resetSession(for: def2, compendium: compendium) #expect(s2.adversarySlots.count == 2) } From c64826991a045e837dbcdf9a5dfa40322fb196a3 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sun, 29 Mar 2026 11:25:59 -0700 Subject: [PATCH 2/2] harden to precondition, not expected to be raced --- Sources/DHKit/EncounterStore.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 8a092df..21094f2 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -188,7 +188,10 @@ public final class EncounterStore { /// Valid definitions are published via ``definitions``, sorted by /// `modifiedAt` descending. Directory-level errors are stored in ``loadError``. public func load() async { - guard !isLoading else { return } + // EncounterStore is a single shared instance loaded once at app startup. + // Concurrent calls to load() are a programming error, not a normal operating + // condition — use precondition so violations surface immediately in debug builds. + precondition(!isLoading, "load() called while a load is already in progress") isLoading = true loadError = nil defer { isLoading = false }